在分析Linux共享库文件(扩展名是so)时,我们经常会遇到具有下述特征的指令代码:
指令清单67.1 x86下的libc-2.17.so
.text:0012D5E3 __x86_get_pc_thunk_bx proc near ; CODE XREF: sub_17350+3
.text:0012D5E3 ; sub_173CC+4 ...
.text:0012D5E3 mov ebx, [esp+0]
.text:0012D5E6 retn
.text:0012D5E6 __x86_get_pc_thunk_bx endp
...
.text:000576C0 sub_576C0 proc near ; CODE XREF: tmpfile+73
...
.text:000576C0 push ebp
.text:000576C1 mov ecx, large gs:0
.text:000576C8 push edi
.text:000576C9 push esi
.text:000576CA push ebx
.text:000576CB call __x86_get_pc_thunk_bx
.text:000576D0 add ebx, 157930h
.text:000576D6 sub esp, 9Ch
...
.text:000579F0 lea eax, (a__gen_tempname - 1AF000h)[ebx] ; "__gen_tempname"
.text:000579F6 mov [esp+0ACh+var_A0], eax
.text:000579FA lea eax, (a__SysdepsPosix - 1AF000h)[ebx] ; "../sysdeps/posix/tempname.c"
.text:00057A00 mov [esp+0ACh+var_A8], eax
.text:00057A04 lea eax, (aInvalidKindIn_ - 1AF000h)[ebx] ; "! \"invalid ↙
↘ KIND in __gen_tempname\""
.text:00057A0A mov [esp+0ACh+var_A4], 14Ah
.text:00057A12 mov [esp+0ACh+var_AC], eax
.text:00057A15 call __assert_fail
所有字符串指针都被一些常数修正过,并且相关函数都在开始的几条指令里重新调整EBX中的值。这类指令称作“位置无关的代码PIC(Position Independent Code)”。因为进程或对象会被操作系统的链接器加载到任意内存地址,所以代码里的指令无法直接确定(hardcoded)绝对内存地址。
PIC在早期的计算机系统中非常关键,而目前在没有虚拟内存支持的嵌入式系统中就更为重要了。对于那些没有采用虚拟内存技术的嵌入式设备来说,所有进程都存放于一个连续的内存块中。PIC至今仍然用于*NIX系统的共享目标库,因为不同进程可能会链接同一个共享库。库文件只会被操作系统加载一次。当应用程序调用库时,直接把共享的地址复制过来。这种情况下,调用库函数的进程会被加载到不同地址,而库文件的加载地址却固定不变。这些因素决定,共享库的库函数不得使用绝对地址(至少对内部对象而言),否则就不能被多个进程同时调用。
我们来做一个简单的试验:
#include <stdio.h>
int global_variable=123;
int f1(int var)
{
int rt=global_variable+var;
printf ("returning %d\n", rt);
return rt;
};
我们在GCC 4.7.3下编译一下,然后使用IDA打开编译后的.so文件。
编译的命令行为:
gcc -fPIC -shared -O3 -o 1.so 1.c
指令清单67.2 GCC 4.7.3
.text:00000440 public __x86_get_pc_thunk_bx
.text:00000440 __x86_get_pc_thunk_bx proc near ; CODE XREF: _init_proc+4
.text:00000440 ; deregister_tm_clones+4 ...
.text:00000440 mov ebx, [esp+0]
.text:00000443 retn
.text:00000443 __x86_get_pc_thunk_bx endp
.text:00000570 public f1
.text:00000570 f1 proc near
.text:00000570
.text:00000570 var_1C = dword ptr -1Ch
.text:00000570 var_18 = dword ptr -18h
.text:00000570 var_14 = dword ptr -14h
.text:00000570 var_8 = dword ptr -8
.text:00000570 var_4 = dword ptr -4
.text:00000570 arg_0 = dword ptr 4
.text:00000570
.text:00000570 sub esp, 1Ch
.text:00000573 mov [esp+1Ch+var_8], ebx
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, ds:(global_variable_ptr - 2000h)[ebx]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, (aReturningD - 2000h)[ebx] ; "returning %d\n"
.text:00000594 add esi, [esp+1Ch+arg_0]
.text:00000598 mov [esp+1Ch+var_18], eax
.text:0000059C mov [esp+1Ch+var_1C], 1
.text:000005A3 mov [esp+1Ch+var_14], esi
.text:000005A7 call ___printf_chk
.text:000005AC mov eax, esi
.text:000005AE mov ebx, [esp+1Ch+var_8]
.text:000005B2 mov esi, [esp+1Ch+var_4]
.text:000005B6 add esp, 1Ch
.text:000005B9 retn
.text:000005B9 f1 endp
上述代码的关键在于:每个函数都在启动之后调整了字符串"returning %d\n" 和global_variable的指针。__x86_get_pc_thunk_bx()函数通过EBX返回一个指向自身的指针。而位于其后(偏移量0x57C处)的指令再次对ebx进行了修正。这是一种获取PC指针(EIP)的取巧办法。
常数0x1A84是函数的启始地址与“全局偏移表(global offset table)GOT”和“过程链接表(Procedure Linkage Table)PLT”之间的地址差。在可执行文件中,GOT、PLT都有各自的相应段(section)。全局变量global_variable的指针正好位于全局偏移量表GOT之后。为了便于我们理解偏移量和各表之间的关系,IDA对显示的偏移量进行了某种挑战。这部分的原始指令实际上是:
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, [ebx-0Ch]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, [ebx-1A30h]
EBX寄存器存储着GOT PLT的指针(相应section的启始地址)。因此在计算全局变量global_variable的指针时(该指针保存在GOT中),必须从EBX减去地址差,即常数0xC。同理,在计算“returning %d\n”的字符串指针时,必须从EBX减去0x1A30。
实际上,AMD64的指令集支持基于RIP的相对寻址就是为了简化PIC代码的操作。
然后,我们用同样版本的GCC把这段C代码编译为64位目标文件。
IDA会在显示代码的时候隐藏那些基于RIP的寻址细节。因此,我们通过objdump查看汇编代码:
0000000000000720 <f1>:
720: 48 8b 05 b9 08 20 0 mov rax,QWORD PTR [rip+0x2008b9] # 200fe0 <_DYNAMIC+0x1d0>
727: 53 push rbx
728: 89 fb mov ebx,edi
72a: 48 8d 35 20 00 00 00 lea rsi,[rip+0x20] # 751 <_fini+0x9>
731: bf 01 00 00 00 mov edi,0x1
736: 03 18 add ebx,DWORD PTR [rax]
738: 31 c0 xor eax,eax
73a: 89 da mov edx,ebx
73c: e8 df fe ff ff call 620 <__printf_chk@plt>
741: 89 d8 mov eax,ebx
743: 5b pop rbx
744: c3 ret
我们来看看以上程序代码中的RIP后面的两个偏移量:
① 指令0x720处。0x2008b9是该地址与全局变量global_variable之间的地址差。
② 指令0x72A处。0x20则是该地址与“returning %d”字符串指针之间的地址差。
可能读者也已经注意到了,经常进行地址重复计算会降低程序的执行效率(虽然在64位系统下可能会表现稍好)。因此,注重性能的时候,最好采用使用静态链接的静态库。
Windows的DLL加载机制不是PIC机制。如果Windows加载器要把DLL加载到另外一个基地址,它就会内存中对DLL进行“修补”处理(重定位技术),从而可以正确地处理所有符号地址。这就意味着多个Windows进程无法在不同进程内存块的不同地址共享一份DLL,因为每个被加载在内存里的实例只能访问自己的地址空间。
Linux程序可以加载其他动态库之前、甚至在加载系统库(例如libc.so.6)之前加载自己的动态库。
借助这项功能,我们能够编写自定义的函数“替换”系统库中的同名函数。进一步说,劫持time()、read()、write()等系统函数并非难事。
接下来,我们以系统工具uptime为例进行演示。我们都知道,该应用可以显示计算机已经工作了多少时间。借助另一款系统工具strace可知,uptime通过/proc/uptime文件获取计算机的工作时长:
$ strace uptime
...
open("/proc/uptime", O_RDONLY) = 3
lseek(3, 0, SEEK_SET) = 0
read(3, "416166.86 414629.38\n", 2047) = 20
...
其实,/proc/uptime并不是真正意义上的磁盘文件。它是由Linux Kernel产生的虚拟文件。这个文件具有两项数值:
$ cat /proc/uptime
416690.91 415152.03
查查维基百科,我们可以得到以下的信息:
第一项数值显示的是系统已经运行的总时长;第二项数值是计算机处于空闲状态的时间总和。这两项数据都以秒为单位。
我们编写一个声明open()、read()和close()函数的自定义动态链接库。
首先要处理的就是open()函数。它应能判断程序打开的文件是否是我们需要的文件。如果两者相符,那么open()函数就应当记录并返回文件描述符。接下来要处理的是read()函数。read()函数应能判断程序打开的是否是我们关注的文件描述符。如果两者相符,那么就用某些数据替代原有文件内容;否则就调用libc.so.6里的原有函数。最后需要处理的是close()函数,它应能正确关闭已经打开的外部文件。
本例通过dlopen()和dlsym()函数获取同名函数在libc.so.6里的函数地址。虽然本例的确是要劫持系统函数,但是也得将控制权交还给原来的“正牌”函数。
另外一方面,如果我们要劫持strcmp()函数(字符串比较函数)、以此获取每组对比的字符串,那么我们就得手写一个strcpm()函数了,而无法继续调用原有函数。
这种劫持功能的程序源代码如下:
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <dlfcn.h>
#include <string.h>
void *libc_handle = NULL;
int (*open_ptr)(const char *, int) = NULL;
int (*close_ptr)(int) = NULL;
ssize_t (*read_ptr)(int, void*, size_t) = NULL;
bool inited = false;
_Noreturn void die (const char * fmt, ...)
{
va_list va;
va_start (va, fmt);
vprintf (fmt, va);
exit(0);
};
static void find_original_functions ()
{
if (inited)
return;
libc_handle = dlopen ("libc.so.6", RTLD_LAZY);
if (libc_handle==NULL)
die ("can't open libc.so.6\n");
open_ptr = dlsym (libc_handle, "open");
if (open_ptr==NULL)
die ("can't find open()\n");
close_ptr = dlsym (libc_handle, "close");
if (close_ptr==NULL)
die ("can't find close()\n");
read_ptr = dlsym (libc_handle, "read");
if (read_ptr==NULL)
die ("can't find read()\n");
inited = true;
}
static int opened_fd=0;
int open(const char *pathname, int flags)
{
find_original_functions();
int fd=(*open_ptr)(pathname, flags);
if (strcmp(pathname, "/proc/uptime")==0)
opened_fd=fd; // that's our file! record its file descriptor
else
opened_fd=0;
return fd;
};
int close(int fd)
{
find_original_functions();
if (fd==opened_fd)
opened_fd=0; // the file is not opened anymore
return (*close_ptr)(fd);
};
ssize_t read(int fd, void *buf, size_t count)
{
find_original_functions();
if (opened_fd!=0 && fd==opened_fd)
{
// that's our file!
return snprintf (buf, count, "%d %d", 0x7fffffff, 0x7fffffff)+1;
};
// not our file, go to real read() function
return (*read_ptr)(fd, buf, count);
};
我们用通用的动态库来编译它:
gcc -fpic -shared -Wall -o fool_uptime.so fool_uptime.c –ldl
最后,我们通过LD_PRELOAD指令优先加载自定义的函数库:
LD_PRELOAD='pwd'/fool_uptime.so uptime
上述指令的输出结果为:
01:23:02 up 24855 days, 3:14, 3 users, load average: 0.00, 0.01, 0.05
如果我们在系统的环境变量中设定了LD_PRELOAD、让它指向我们自定义的动态链接库,那么所有的进程都会在启动之前加载我们自定义的动态链接库。
更多例子请参阅:
① Very simple interception of the strcmp() (Yong Huang) :
https://yurichev.com/mirrors/LD_PRELOAD/Yong%20Huang%20LD_PRELOAD.txt
② Fun with LD_PRELOAD (Kevin Pulo): https://yurichev.com/mirrors/LD_PRELOAD/lca2009.pdf
。
③ File functions interception for compression/decompression:
ftp://metalab.unc.edu/pub/Linux/libs/compression