导读
你想要在没有源代码的情况下调试一个C/C++程序吗?
你想要在不重新安装php的情况下调试php吗?
你想要打印或者修改网络通信的数据包吗?
你想要在异步或者多进程、多线程下跟踪调试吗?
你想要随意修改程序的逻辑、数据,或异常测试,或破解?
…………
本文将通过testdbg的原理及实现来介绍如何做到这些,具体的使用说明可以参考testdbg工具。
概要
testdbg是通过动态修改程序的内存映像来达到hook的目的,不会对程序的二进制文件有任何影响,所以程序裸跑的时候和原来行为完全一致。
采用的是一种汇编指令级别的hook手段,也可以说是一种hack的方式,所以不仅可以实现API hook,还可以操控、改变程序的任何逻辑(一切都在掌握之中)。
不需要root权限,不需要修改源代码,不需要重新编译源程序。
原理
testdbg主要包含3个模块:
• 主程序testdbg,负责加载hook主模块到目标程序内存空间,然后启动目标程序
• 主模块hookmon.so,负责加载用户编写的桩模块,实现原代码到桩的内存hook
• 桩模块hook.so,用户编写的桩
内存分布
为了更好地说明它们和目标程序的关系,可以先看下程序单独运行以及用testdbg启动运行时的内存镜像:
(a)中是程序单独启动时的内存情况,首先加载的是程序的.text和.data段,其次加载了依赖的系统共享库。
(b)中用testdbg启动程序,首先加载的也是程序的代码段,接着优先加载了主模块hookmon.so,在其初始化的过程中加载了桩模块hook.so,同时修改内存建立hook,这样程序在运行的时候便会根据主模块修改的逻辑执行,完成之后才开始加载依赖的系统库。
建立hook
主模块hookmon.so由testdbg加载到目标程序内存后,会先在堆里动态申请一段内存来保存被修改的原函数入口(简称为跳板),然后修改原函数入口令其跳转到指定的桩代码中执行,桩代码在处理完逻辑后再通过跳板来执行原函数,关系如下图所示:
实现步骤
testdbg实现主要分为以下几个步骤:
1. 将桩代码动态加载到程序的内存空间
2. 在程序启动前获取控制权以便修改内存
3. 分析ELF文件获取所需的符号信息
4. 反汇编备份原指令并修改成远跳转到桩代码
5. 桩代码通过跳板执行原指令
桩代码动态加载到程序的内存空间
要将桩代码插入到源程序,可以考虑通过修改源代码、编译期插桩或者修改生成的可执行文件等实现,但这几种方法都存在一些缺点:
• 直接修改源代码插桩需要考虑版本升级后完整的将桩代码移植
• 编译期插桩需要重新编译源程序
• 修改可执行文件难度大,而且破坏了原文件
将桩动态插入到程序的内存空间可以实现桩的热加载,使用起来更灵活~
ptrace尝试
一开始考虑的是一种比较hack的方法,让源程序以子进程的方式启动,并设置PTRACE_TRACEME由父进程控制,父进程获取控制权时在源程序入口处下软中断以获取下一次的控制权,然后分析ELF获取动态链接器的内部结构link_map,该结构是一个链表,保存着已加载的动态链接库的信息,遍历link_map查找_dl_open内部函数(或dlopen函数,但前提是源程序有链接libdl共享库,否则无法找到),接着修改RIP执行_dl_open来加载桩程序到源程序的内存空间,但这种方法在新版本glibc已经不可用了_dl_open会验证调用者的返回地址是否在有效的内存地址以防止hack,如下检查调用者是否在libc或者libdl的内存空间:
int
attribute_hidden
_dl_check_caller (const void *caller, enum allowmask mask)
{
static const char expected1[] = LIBC_SO;
static const char expected2[] = LIBDL_SO;
//……
for (Lmid_t ns = 0; ns < DL_NNS; ++ns)
for (struct link_map *l = GL(dl_ns)[ns]._ns_loaded; l != NULL;
l = l->l_next)
if (caller >= (const void *) l->l_map_start
&& caller < (const void *) l->l_text_end)
{
/* The address falls into this DSO's address range. Check the
name. */
if ((mask & allow_libc) && strcmp (expected1, l->l_name) == 0)
return 0;
if ((mask & allow_libdl) && strcmp (expected2, l->l_name) == 0)
return 0;
//……
}
return 1;
}
LD_REPLOAD
使用linux提供的LD_PRELOAD环境变量,它可以在程序所有共享库加载之前优先加载LD_PRELOAD设置的共享库到程序的内存空间,先看个例子,一个简单hello程序:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("hello, world!\n");
pause();
return 0;
}
编译后ldd看一下都依赖了哪些共享库:
[[email protected] preload]$ ldd hello
libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x0000003b8c100000)
libm.so.6 => /lib64/tls/libm.so.6 (0x0000003b8a500000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x0000003b8c900000)
libc.so.6 => /lib64/tls/libc.so.6 (0x0000003b8a200000)
/lib64/ld-linux-x86-64.so.2 (0x0000003b8a000000)
[[email protected] preload]$ ./hello
hello, world!
设置LD_PRELOAD变量,ldd发现多了./preload.so,再运行./hello调用printf时优先查找到了preload.so的printf函数,打印了"hack!!"
[[email protected] preload]$ cat preload.c
int printf ( const char * format, ... )
{
puts("hack !!\n");
return 0;
}
[[email protected] preload]$ export LD_PRELOAD="./preload.so"
[[email protected] preload]$ ldd hello
./preload.so (0x0000002a95557000)
libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x0000003b8c100000)
libm.so.6 => /lib64/tls/libm.so.6 (0x0000003b8a500000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x0000003b8c900000)
libc.so.6 => /lib64/tls/libc.so.6 (0x0000003b8a200000)
/lib64/ld-linux-x86-64.so.2 (0x0000003b8a000000)
[[email protected] preload]$ ./hello
hack !!
可以通过strace看下系统调用:
[[email protected] preload]$ strace ./hello
execve("./hello", ["./hello"], [/* 38 vars */]) = 0
uname({sys="Linux", node="db-testing-t61.db01.baidu.com", ...}) = 0
brk(0) = 0x501000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x2a95556000
open("./preload.so", O_RDONLY) = 3
……
open("/usr/lib64/libstdc++.so.6", O_RDONLY) = 3
……
open("/lib64/tls/libm.so.6", O_RDONLY) = 3
……
LD_REPLOAD指定的./preload.so最先被加载,看下此时hello的内存map:
[[email protected] preload]$ pgrep hello
24934
[[email protected] preload]$ cat /proc/24934/maps
00400000-00401000 r-xp 00000000 08:03 42893328 /home/xuanbiao/studio/code/elf/preload/hello
00500000-00501000 rw-p 00000000 08:03 42893328 /home/xuanbiao/studio/code/elf/preload/hello
2a95556000-2a95557000 rw-p 2a95556000 00:00 0
2a95557000-2a95558000 r-xp 00000000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
2a95558000-2a95657000 ---p 00001000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
2a95657000-2a95658000 rw-p 00000000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
可以看到preload.so已经被加载到hello程序内存的0x2a95557000 - 0x2a95658000之间~
使用LD_REPLOAD有2个好处,一个好处是可以很轻易将桩程序加载到源程序的内存空间,另一个好处是当程序调用如malloc、printf、open、read、write等系统函数时会优先查找LD_PRELOAD设置的共享库,所以你的共享库如果存在和这些系统函数声明一致的函数则会优先被调用进而达到API Hook的目的,对于模拟磁盘满、内存分配失败、网络读写等异常通过这种方法就可以实现,但对于非共享库的函数(可执行文件及静态库的函数)则需要继续往下看。
在程序启动前获取控制权
桩程序加载到源程序内存空间后需要在源程序启动前获取控制权,以便触发一些初始化操作,否则源程序还是会按原来的逻辑执行,除了系统函数的hook有效外没有其它改变,但这不是我们的目的。
ptrace再次尝试
原以为linux的so共享库并没有类似于windows的dll动态库一样存在DllMain函数来执行初始化,还是考虑了上面提到ptrace方法,只要启动主程序与桩程序约定哪个函数作为初始化函数就可以了,但这种实现过于麻烦,而且容易引发兼容性问题,这里不做过多的介绍。
共享库的构建与析构函数
共享库可以通过__attribute__((constructor))和__attribute__((destructor))分别定义构建和析构函数,构建函数会在dlopen返回前执行,如果是在加载期加载则会在main函数调用前执行,析构函数在dlclose返回前执行,如果是在加载期加载的则会在exit()或者main函数完成后执行,函数原型如下:
void __attribute__ ((constructor)) my_init(void);
void __attribute__ ((destructor)) my_fini(void);
需要注意的是在gcc编译共享库时不能加入'-nostartfiles'和'-nostdlib'选项,否则将不会被执行。
分析ELF文件获取所需的符号信息
testdbg里实现了非共享库函数的hook,在hook之前需要获取指定函数的入口虚拟地址(即内存地址),可以通过分析ELF文件(即程序的二进制文件,以前老的的格式这里不做讨论)的符号节.symtab来获取,该节保存了所有有用的符号信息(包括引用的外部共享库的符号),这里只需要过滤获取符合要求的函数符号,然后通过符号信息获取指定函数的入口虚拟地址即可。
下面是demo1的代码及用readelf查看到的符号信息:
#include <stdio.h>
int func(int val, char *str)
{
printf("val:%d, str:%s\n", val, str);
return 0;
}
int main()
{
func(10, "hello, world!");
func(20, "just a test!");
return 0;
}
用readelf -s demo1查看符号信息:
[[email protected] demo1]$ readelf -s demo1
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
2: 0000000000000000 160 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 (2)
Symbol table '.symtab' contains 72 entries:
Num: Value Size Type Bind Vis Ndx Name
50: 0000000000400558 44 FUNC GLOBAL DEFAULT 12 _Z4funciPc
53: 0000000000000000 160 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.2.5
57: 0000000000400584 41 FUNC GLOBAL DEFAULT 12 main
可以看到打印了2个symbol table,.dynsym动态符号节和.symtab符号节,.dynsym只包含引用的外部符号信息,.symtab则包含了所有的符号信息,在.symtab节中可以看到func、printf和main函数的符号信息,由于demo1是用g++编译的,func的符号名变成了_Z4funciPc,这是由于C++允许重载,所以采用了name mangling来为每个函数生成不同的符号名。
用c++filt可以查看该符号名的原型:
[[email protected] demo1]$ c++filt _Z4funciPc
func(int, char*)
反汇编备份原指令并修改成远跳转到桩代码
非共享库函数hook一个关键的地方就是修改原函数的入口来跳转到hook函数来执行,但这样就破坏了原函数,如果要在hook函数里调用原函数则需要在修改之前将原函数备份,由于原函数只是在入口处修改成一个跳转指令,所以可以通过动态创建一个"跳板"函数,保存被修改之前的指令,然后再跳转回剩下的指令执行,这就使得在hook函数里调用跳板函数和调用原函数一样了。
函数hook关系图
原函数、hook函数及跳板函数的关系如下:
汇编指令备份拷贝
原函数入口的汇编指令备份拷贝需要注意2个地方,一个是指令需对齐,不能中间截断,另一个是拷贝到新内存后需要将原来的相对地址重新计算。
指令对齐需要先进行反汇编,可以使用libopcodes里的API实现,具体的细节可以直接看testdbg里的代码实现。
包含相对地址的指令拷贝到新内存,如果该相对地址的实际跳转地址不在一起拷贝过去的内存中则会出错,所以需要将这些相对地址都重新计算。根据绝对地址的计算公式:
绝对地址=当前指令所在地址+当前指令长度+相对地址
很容易计算出实际的绝对地址,再根据新内存的地址和新的指令长度即可计算出新的相对地址,需要注意的是小于4字节的相对地址重新计算后可能变成了4字节的相对地址,这就需要将原指令的操作码都替换成对应的支持4字节的操作码,保持语义的一致性。
修改内存实现远跳转
无条件JMP跳转指令的直接寻址跳转能力只有32位,而64位操作系统却有64位的寻址能力,而且在之前LD_PRELOAD一节中看到的程序内存map,共享库都被加载到了高位地址,超出了32位地址范围,所以JMP跳转只能通过间接寻址跳转,比如jmp %rax、jmp %rbx、jmp imm(%rip)等,考虑到不破坏原寄存器的值,这里用的是jmp 0(%rip),然后将要跳转的64位目标地址写入到紧挨该指令的内存地址中。
demo程序的func原函数反汇编:
(gdb) disass func
Dump of assembler code for function _Z4funciPc:
0x0000000000400558 <_Z4funciPc+0>: push %rbp
0x0000000000400559 <_Z4funciPc+1>: mov %rsp,%rbp
0x000000000040055c <_Z4funciPc+4>: sub $0x10,%rsp
0x0000000000400560 <_Z4funciPc+8>: mov %edi,0xfffffffffffffffc(%rbp)
0x0000000000400563 <_Z4funciPc+11>: mov %rsi,0xfffffffffffffff0(%rbp)
0x0000000000400567 <_Z4funciPc+15>: mov 0xfffffffffffffff0(%rbp),%rdx
0x000000000040056b <_Z4funciPc+19>: mov 0xfffffffffffffffc(%rbp),%esi
0x000000000040056e <_Z4funciPc+22>: mov $0x40069c,%edi
0x0000000000400573 <_Z4funciPc+27>: mov $0x0,%eax
0x0000000000400578 <_Z4funciPc+32>: callq 0x400480
0x000000000040057d <_Z4funciPc+37>: mov $0x0,%eax
0x0000000000400582 <_Z4funciPc+42>: leaveq
0x0000000000400583 <_Z4funciPc+43>: retq
End of assembler dump.
被修改后的func函数反汇编:
(gdb) disass func
Dump of assembler code for function _Z4funciPc:
0x00000000004005a8 <_Z4funciPc+0>: jmpq *0(%rip) # 0x4005ae <_Z4funciPc+6>
0x00000000004005ae <_Z4funciPc+6>: movl %?,(%rax)
0x00000000004005b0 <_Z4funciPc+8>: (bad)
0x00000000004005b1 <_Z4funciPc+9>: xchg %eax,%ebp
0x00000000004005b2 <_Z4funciPc+10>: sub (%rax),%al
0x00000000004005b4 <_Z4funciPc+12>: add %al,(%rax)
0x00000000004005b6 <_Z4funciPc+14>: lock mov 0xfffffffffffffff0(%rbp),%rdx
0x00000000004005bb <_Z4funciPc+19>: mov 0xfffffffffffffffc(%rbp),%esi
0x00000000004005be <_Z4funciPc+22>: mov $0x4006fc,%edi
0x00000000004005c3 <_Z4funciPc+27>: mov $0x0,%eax
0x00000000004005c8 <_Z4funciPc+32>: callq 0x4004b8
0x00000000004005cd <_Z4funciPc+37>: mov $0x0,%eax
0x00000000004005d2 <_Z4funciPc+42>: leaveq
0x00000000004005d3 <_Z4funciPc+43>: retq
End of assembler dump.
(gdb) x /xg 0x00000000004005ae
0x4005ae <_Z4funciPc+6>: 0x0000002a9582388c
func函数入口已经被修改为了jmpq *0(%rip),由于紧挨着该指令的内存为目标跳转地址,是数据不是指令,所以后面的指令反汇编后大部分是错的,0x00000000004005ae保存的0x0000002a9582388c即为hook函数的虚拟地址,再看下跳板函数的反汇编:
(gdb) p /x old_func
$2 = 0x502720
(gdb) disass 0x502720 0x502738
Dump of assembler code from 0x502720 to 0x502738:
0x0000000000502720: push %rbp
0x0000000000502721: mov %rsp,%rbp
0x0000000000502724: sub $0x10,%rsp
0x0000000000502728: mov %edi,0xfffffffffffffffc(%rbp)
0x000000000050272b: mov %rsi,0xfffffffffffffff0(%rbp)
0x000000000050272f: jmpq *0(%rip) # 0x502735
0x0000000000502735: mov $0x5,%bh
0x0000000000502737: add %al,(%rax)
End of assembler dump.
(gdb) x /xg 0x0000000000502735
0x502735: 0x00000000004005b7
func原函数入口的指令备份到了这里,然后紧接着又是一条jmp远跳转指令到0x00000000004005b7执行func剩下的部分。
桩代码通过跳板执行原指令
跳板的原理前面也提到了,等价于备份的原指令+跳转到剩余指令,其作用和修改前的原指令是一样的,只是绕了个弯,所以hook函数里要调用原函数则可以直接调用跳板就可以了。
后记
上面介绍的是只是针对非动态库函数的hook,根据这个原理可以衍生出更多的应用,比如:
• 在任意地址注入一段代码。如testdbg函数调用跟踪的实现,只是比函数hook多了一步备份和恢复寄存器。
• 反汇编修改原程序的逻辑。比如je改成jne等,cracker常用的破解手段,呵呵。
• ……
反馈建议