第67章 Linux

67.1 位置无关的代码

在分析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位系统下可能会表现稍好)。因此,注重性能的时候,最好采用使用静态链接的静态库。

67.1.1 Windows

Windows的DLL加载机制不是PIC机制。如果Windows加载器要把DLL加载到另外一个基地址,它就会内存中对DLL进行“修补”处理(重定位技术),从而可以正确地处理所有符号地址。这就意味着多个Windows进程无法在不同进程内存块的不同地址共享一份DLL,因为每个被加载在内存里的实例只能访问自己的地址空间。

67.2 在Linux下的LD_PRELOAD

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