之前写了个hookso的工具,用来操作linux进程的动态链接库行为,本文从so注入与热更新入手,简单讲解一下其中的原理,配合源码阅读效果更佳。
不管是热更新so还是其他方式操作so,都要先注入才行。所以先考虑如何注入so。 其实往一个进程注入so的方法,很简单,让进程自己调用一下dlopen即可。这个就是基本原理,剩下的事情,就是如何让他调用。 那么如何操作?这里要介绍一下linux的ptrace函数。
ptrace很多人也用过,大致意思就是拿来控制其他进程的,读写内存,读写寄存器,下断点,追踪系统调用,相当于可编程版gdb,实际上gdb就是基于ptrace实现。 ptrace的定义如下:
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
通过设置request的值,来实现具体的操作,本文用到的大部分如下:
PTRACE_ATTACH:关联上目标进程
PTRACE_GETREGS:读目标进程寄存器
PTRACE_SETREGS:写目标进程寄存器
PTRACE_PEEKTEXT:读目标进程内存数据
PTRACE_POKETEXT:写目标进程内存数据
PTRACE_CONT:目标进程继续
PTRACE_DETACH:断联目标进程
比如设置PTRACE_PEEKTEXT,就能把目标进程的某个地址的内存读到本进程。
前面说到,我们希望让目标进程调用dlopen(target.so)
,来实现target.so的注入。抽象出来,就是如何让目标进程调用一个用户函数(即,非系统调用的函数)。
那么如何调用?可以拆分为两步,第一步找到目标函数的地址,第二步调用它。
我们知道,linux的可执行文件是elf文件格式,动态链接库其实也是elf格式。关于elf,有很多资料,这里简单讲一下elf结构。
elf全名Executable and Linkable Format,存在的主要目的,就是为了程序的执行。而程序的执行,需要哪些信息呢?
int add()
函数,进去的就是这个地方int add()
,最后编译成了main.out
与add.so
两个elf文件。两个elf之前是相互独立的,那么就需要在main.out
记录引用了外部的add函数,add.so
里记录导出了add函数最后这些信息,加上一些乱七八糟的,以一块一块(section)的形式组合而成,就是elf文件了。
查找函数地址的过程也分为两步,查找so起始内存地址,查找函数所在so偏移,两者相加就是函数的地址
首先,我们要查找某个so的函数,就得先找到so所在的内存位置才行。
例如要调用dlopen,而dlopen是在libc.so中,那么我们第一步就是要找到libc.so所在内存的地址。
好在linux很方便的提供了方法,通过cat /proc/进程id/maps
,可以看到内存布局
7f3a9a270000-7f3a9a42a000 r-xp 00000000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a42a000-7f3a9a629000 ---p 001ba000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a629000-7f3a9a62d000 r--p 001b9000 fc:01 25054 /usr/lib64/libc-2.17.so
7f3a9a62d000-7f3a9a62f000 rw-p 001bd000 fc:01 25054 /usr/lib64/libc-2.17.so
这里第一排的7f3a9a270000,就是libc-2.17.so在内存的起始地址。 这里我们先假定elf是完整映射到了内存中,那么只需要分析内存中的elf结构就可以了。实际上某些比较大的如libstdc++.so并不是,对于这种情况,就需要指定具体so的文件路径,解析好函数在文件中的偏移,再加上so内存地址就是函数地址了。
前面我们找到了so的起始地址,也分析了elf格式,剩下的就是照着elf关系图,通过名字查找函数了。具体的查找关系图如下:
简单讲解一下上图
add.so
函数的add函数。那么就需要左边的rela.plt(重定向信息)以及got.plt(位置偏移信息)。这里派生出几个问题
往上找一找,找到puts@plt的定义,即0x580的位置,可以看到机器码如下:
第一行jmpq通过got的值跳转,在初始时got的值直接为下一行,即0x586,于是开始执行第二行。第二行和第三行传参调用libc完成了绑定puts的过程,并且更新got。 后续再调用第一行,就直接跳转到了目标函数了。
到这里,函数的地址就拿到了,如果是外部函数,还知道了存放函数地址的指针在哪,即got的位置,这个后面做替换的时候会用到。
在前面我们已经拿到了函数的地址了,剩下的就是修改目标进程的寄存器与内存。 通过查阅资料可知,linux amd64调用函数,用到的寄存器及含义如下:
比如我们要调用dlopen("target.so")
,dlopen的地址前面我们已经拿到,但是参数"target.so"
是一个字符串,寄存器里存放的是字符串的地址,而目标进程中并没有这个内存,怎么办?同时函数运行需要的栈空间,也需要内存,怎么获取?
解决方法是调用一下系统调用mmap来申请内存,抽象一下,就是如何让目标进程调用系统调用
系统调用比较简单,查阅相关资料,系统调用的寄存器及含义如下:
mmap的定义为
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
第一个参数填0表示系统分配,其他都是int,参数很好解决。系统调用号填9。都准备好,让目标进程执行一个syscall
指令就开始调用了。
剩下的问题就是rip怎么处理?以及如何拿到返回值?
我们期望函数能够跑某段机器码,即设置一个rip。如前所述,申请内存的方式要调用函数,陷入了鸡生蛋的轮回。 有个方法是直接修改elf的某段可执行内存,改完再复原。 这里可以取巧,使用elf头部的8字节无用内存,定义为
Elf64_Ehdr e_ident[8-16]
所以我们就用这8个字节,来作为函数调用需要的机器码存放地址。在数组里写入一个syscall指令
[0x0f, 0x05]
当目标进程执行完syscall后,如何断住,能让本进程拿到返回值,比较简单,直接在前面的code空间里,写入int3
断点指令,再填满无用指令nop
[0x0f, 0x05, 0xcc, 0x90, 0x90, 0x90, 0x90, 0x90]
这样当目标进程执行到0xcc时,会发出SIGTRAP
信号,ptrace它的本进程就会收到信号断住,类似于gdb的断点。
这时候就可以获取寄存器的rax值,拿到返回值。对于mmap,就是实际的内存地址。
在前面,我们找到了函数地址,一系列系统调用,准备好了执行环境,剩下的事情就是调用我们想要的函数了。 这里调用方式与返回值获得,其实和系统调用没啥区别,就不再赘述。 总结系统调用与用户函数调用,如下图所示:
到了这里,我们已经完成了用户函数调用,也即完成了so的注入。下一步就开始具体的热更新操作了。
如前所述,我们可以随意注入so到某个进程,也能找到某个so的某个函数的地址。那么热更新其实比较简单。这里分为了两种,分别是内部函数、外部函数。
还是回到前面的例子,例如main加载了add.so
,执行add.so
的add
函数,我们期望以后调用add
都变成addnew.so
的addnew
函数。这种add
在add.so
内部定义,这种替换方式就叫内部函数替换。
那么如何替换呢?很简单,注入addnew.so
,找到addnew.so
的addnew
函数地址。然后修改add
函数的机器码,写一个jmp到addnew
函数。
替换的代码如下:
int offset = (int) ((uint64_t) new_funcaddr - ((uint64_t) old_funcaddr + 5));
char code[8] = {0};
code[0] = 0xe9;
memcpy(&code[1], &offset, sizeof(offset));
刚才的例子,假设add.so
的add
函数,调用了c标准库libc.so
的puts
函数打印结果,我们期望不要调用puts
,改为自定义的putsnew
。这种puts
在add.so
外部定义,这种替换方式就叫外部函数替换。
那么如何替换呢?很简单,注入查找新的函数地址,直接把新的函数地址写入got即可。
注意这里的修改只对add.so
生效,其他so调用puts
还是不变。
代码如下:
// func out .so
ret = remote_process_write(pid, old_funcaddr_plt, &new_funcaddr, sizeof(new_funcaddr));
if (ret != 0) {
close_so(pid, handle);
return -1;
}
两种替换的示意图如下:
前面我们已经完成了常见的函数热更新,对于某些项目,比如Lua,会将函数地址与字符串做一个映射,然后存到一个map中。 这种情况,修改got已经不能满足了,因为map存放的是最终地址,只能修改函数机器码jmp。 假如有100个函数,那么就要修改100次,对于导出lua函数比较多的so来说,会很麻烦,特别是类成员函数的名字还很复杂。 所以最好能直接注入一个新的so,重新绑定一下,将map中的地址替换为新的函数地址。
这里有几个问题:
lua_State * L
?
众所周知,Lua的数据都是保存在L中,除非搞一个全局变量,不然我们调用绑定函数的时候,需要指定L,如rebind(lua_State * L)
lua_State * L
?
关于拿到L的问题,我们只需要让目标进程在执行某个Lua函数的时候断住,然后获取它的参数,就能拿到L。至于如何断住,和前面的函数调用类似,直接在目标函数的入口写入一个int3即可。具体示意图如下:
最终效果,我们在lua_settop
的地方断住,此时可以认为Lua的栈是稳定的,我们只要保证执行后,Lua栈一致即可。当断住后,拿到第一个参数L,执行rebind(lua_State * L)
完成重新绑定。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。