关注腾讯云大学,了解行业最新技术动态
腾讯云大学知识分享月已经开幕了
为了让大家沉淀知识,
我们邀请了
赵昕讲师
将直播内容整理成了文章
话不多说让我们再来回顾一下课程内容吧
(课程精彩片段,戳阅读原文观看完整回放)
直 播 回 顾
简介
动态链接库(SO文件)在Linux中使用非常广泛,对于后台开发来说,服务器进程往往加载和使用了很多的SO文件,当需要更新某个SO时往往需要重启进程。本课程将讲述如何做到不重启进程,而将so的修改热更新生效!
原理
不管是热更新so还是其他方式操作so,都要先注入才行。所以先考虑如何注入so。 其实往一个进程注入so的方法,很简单,让进程自己调用一下dlopen即可。这个就是基本原理,剩下的事情,就是如何让他调用。 那么如何操作?这里要介绍一下linux的ptrace函数。
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
elf全名Executable and Linkable Format,存在的主要目的,就是为了程序的执行。而程序的执行,需要哪些信息呢?
最后这些信息,加上一些乱七八糟的,以一块一块(section)的形式组合而成,就是elf文件了。
查找过程
查找函数地址的过程也分为两步,查找so起始内存地址,查找函数所在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偏移
前面我们找到了so的起始地址,也分析了elf格式,剩下的就是照着elf关系图,通过名字查找函数了。具体的查找关系图如下:
简单讲解一下上图
这里派生出几个问题
往上找一找,找到puts@plt的定义,即0x580的位置,可以看到机器码如下:
第一行jmpq通过got的值跳转,在初始时got的值直接为下一行,即0x586,于是开始执行第二行。第二行和 第三行传参调用libc完成了绑定puts的过程,并且更新got。 后续再调用第一行,就直接跳转到了目标函数了。
4、为什么plt里不直接存放地址,要搞个got? 理论上是可以got里的每个地址拆分放到plt中,可能是出于逻辑与数据分离考虑,并且分开后内存页的读写 权限更好管理,毕竟一个是可执行,一个是可写。
到这里,函数的地址就拿到了,如果是外部函数,还知道了存放函数地址的指针在哪,即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 .soret = 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_settop的地方断住,此时可以认为Lua的栈是稳定的,我们只要保证执行后,Lua栈一致即可。当断住后,拿到第一个参数L,执行rebind(lua_State * L)完成重新绑定。