简介
上一次我们的文章讲述了一些如何动态修改进程代码段的基础内容,包括
- 如何定位要修改的代码段的地址
- 如何准备要修改的代码
- 修改代码段的方式
并且用一个简单的例子说明如何在程序中进行代码段的自修改。
我们在上文的结尾处说过,自修改其实用处不大,我们举那个例子主要是为了说明原理,另外,上文使用的直接覆盖函数代码段的方式也有缺陷,具体原因我们已经在上文描述过,那么在本文中,我们将讲解更为有用的动态修改其他程序代码段的原理,并且使用跳转的方式来安置新的程序数据,最后给出一个例子, 说明它的用处和用法。
在本文中,我们主要讲解如下的点:
- 如何使用ptrace(2)系统调用来修改其他进程代码段
- 如何使用跳转指令来安置新的程序数据
ptrace系统调用
我们先来讲解ptrace系统调用,这个系统调用对一般的应用程序员来说可能比较陌生,它的作用主要是监控另一个进程的运行,同时还可以修改被监控进程的内存空间和寄存器的值。它的主要用途是实现程序调试器,比如我们常用的gdb就使用了这个系统调用来实现大部分的功能。
ptrace位于sys/ptrace.h
头文件中,函数签名如下:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
其中:
request
表示对被监控程序所进行的某种特定的操作,这个参数有许多不同的值,我们在这儿主要使用的有:PTRACE_ATTACH
、PTRACE_DETACH
、PTRACT_CONT
、PTRACE_GETREGS
、PTRACE_SETREGES
、PTRACE_PEEKTEXT
、PTRACE_POKETEXT
pid
表示被监控的程序的pid。addr
和data
提供了对于每个request
所对应的参数来说的更为具体的参数,因此对于不同的rquest
,它们有不同的意义。
大概流程
对于我们的需求来说,我们使用ptrace的一般流程是这样的:首先使用ptrace连接要被修改的进程,然后修改目标进程,使其调用dlopen(2)加载一个我们事先准备好的动态链接库,在这个动态链接库里,我们编写一个新的函数,这个函数将用来替换原进程中的旧函数。然后,在这个动态链接库中有一个标记为__attribute__((constructor))
的函数,这个函数在该动态链接库被使用dlopen加载后自动执行,在这个函数里,我们使用上文用到的自动修改自身代码的技术来修改进程代码,不过这一次,我们使用跳转的方式来修改代码,而不是直接覆盖代码段。
使用跳转指令来跳转到新函数
这一步其实比较简单,我们使用jmp指令,使程序跳转到新的位置,假如新的函数名字为new_func
,旧函数的名字为old_func
,那么我们只需要使用下面的代码来完成替换即可。
memset(old_func, 0x48, 1);
memset(old_func + 1, 0xb8, 1);
memcpy(old_func + 2, &new_func, 8);
memset(old_func + 10, 0xff, 1);
memset(old_func + 11, 0xe0, 1);
上面的代码意思是将old_func的代码替换为:
mov $new_func, %rax
jmp %rax
这两句汇编代码一目了然,我就不再解释意义了。
使用这种方式的一大好处是,我们无需再担心new_func
的长度太长从而会导致在整体替换old_func
时会产生的溢出问题。
还是一个具体的例子
我们尽量使程序保持简单。
待修改的程序
我们准备一个待修改的程序,这个程序只有一个函数:say_hello
,这个程序内容如下
#include <stdio.h>
#include <stdlib.h></stdlib.h></stdio.h>
void say_hello()
{
printf("hello world\n");
}
int main()
{
for (;;)
{
say_hello();
sleep(3);
}
}
编译运行,我们可以看到这个程序不断的输出。
新函数
下面我们准备一个动态链接库,这个动态链接库需要包含新的函数,并且还有用于替换这个新函数的函数(用__attribute__((constructor))
标识)。
代码如下:
void new_func() {
printf("hello deepin\n");
}
static void __attribute__((constructor)) init(void) {
void* old_func = dlsym(NULL, "say_hello");
int pagesize = sysconf(_SC_PAGE_SIZE);
#define PAGE_MASK (~(pagesize-1))
int ret = mprotect((void*)((uintptr_t)old_func & PAGE_MASK), numpages * pagesize, PROT_READ|PROT_WRITE|PROT_EXEC);
memset(old_func, 0x48, 1);
memset(old_func + 1, 0xb8, 1);
memcpy(old_func + 2, &new_func, 8);
memset(old_func + 10, 0xff, 1);
memset(old_func + 11, 0xe0, 1);
}
上面的部分是核心代码,我们使用dlsym来找到旧函数say_hello
的地址,另外实际编写代码时,不要忘记最后将old_func
的地址改为正确的读写权限。
将其编译为动态链接库。
动态程序修改器
下面是这个例子中最重要的部分,我们暂时把它叫做动态程序修改器,它使用了我们前面提到的方法,使用ptrace
来修改被监控进程的代码,使其执行dlopen
来加载我们准备的动态链接库。
我们将程序打散,并且添加必要的解释。
pid_t pid = (pid_t)strtol(argv[1], '\0', 10);
uintptr_t loader_libc = find_lib_base(getpid(), "libc-");
uintptr_t T_libc = find_lib_base(pid, "libc-");
uintptr_t loader_dlopen = (uintptr_t)dlsym(NULL, "__libc_dlopen_mode");
uintptr_t T_dlopen = T_libc + (loader_dlopen - loader_libc);
首先我们需要找到目标进程内存空间中中dl_open
的地址,通过执行上面的代码,T_dlopen
最后就是得到的dl_open
的地址。我们需要这么做的原因是linux内核在加载二级制程序时,每次都会有一个随机的偏移,但是内存中函数的相对地址却是不变的。然后根据本进程中dl_open
和libc
的地址来计算出dl_open
在libc
库中的偏移量,然后就可以根据这个偏移量和目标进程的libc加载地址得到dl_open
在目标进程的加载地址。
至于libc的加载地址,我们可以通过读取/proc/\/mem这个文件并分析得到。这正是find_lib_base
函数所做的事情。
static unsigned long find_lib_base(pid_t pid, char *so_hint) {
FILE *fp;
char maps[4096], mapbuf[4096], perms[32], libpath[4096];
char *libname;
unsigned long start, end, file_offset, inode, dev_major, dev_minor;
sprintf(maps, "/proc/%d/maps", pid);
fp = fopen(maps, "rb");
if (!fp) {
fprintf(stderr, "Failed to open %s: %s\n", maps, strerror(errno));
return 0;
}
while (fgets(mapbuf, sizeof(mapbuf), fp)) {
sscanf(mapbuf, "%lx-%lx %s %lx %lx:%lx %lu %s", &start,
&end, perms, &file_offset, &dev_major, &dev_minor, &inode, libpath);
libname = strrchr(libpath, '/');
if (libname)
libname++;
else
continue;
if (!strncmp(perms, "r-xp", 4) && strstr(libname, so_hint)) {
fclose(fp);
return start;
}
}
fclose(fp);
return 0;
}
这个函数比较直观,我就不再解释了。
得到这个地址(T_dlopen
)之后,我们调用ptrace来连接到目标进程
static int ptrace_attach(pid_t pid) {
int status;
if (ptrace(PTRACE_ATTACH, pid, NULL, NULL)) {
fprintf(stderr, "Failed to ptrace_attach: %s\n", strerror(errno));
return 1;
}
if (waitpid(pid, &status, __WALL) < 0) {
fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
return 1;
}
return 0;
}
接着,我们保存目标进程现在的现场
int ptrace_getregs(pid_t pid, struct user_regs_struct *regs) {
if (ptrace(PTRACE_GETREGS, pid, 0, regs)) {
fprintf(stderr, "Failed to ptrace_getregs: %s\n", strerror(errno));
return 1;
}
return 0;
}
struct user_regs_struct saved_regs;
memset(&saved_regs, 0, sizeof(struct user_regs_struct));
ptrace_getregs(pid, saved_regs);
下面就是比较重要的部分,
struct user_regs_struct regs;
memcpy(®s, saved_regs, sizeof(struct user_regs_struct));
unsigned long invalid = 0x0;
regs.rsp -= sizeof(invalid);
ptrace_poketext(pid, regs.rsp, ((void *)&invalid), sizeof(invalid));
ptrace_poketext(pid, regs.rsp - 4096, filename, strlen(filename) + 1);
long jmp = 0xe0ff;
*msave = ptrace(PTRACE_PEEKTEXT, pid, regs.rip, 0);
if (*msave == -1) {
fprintf(stderr, "Err: %s\n", strerror(errno));
}
long ret = ptrace(PTRACE_POKETEXT, pid, regs.rip, jmp);
if (ret != 0) {
fprintf(stderr, "Err: %s\n", strerror(errno));
}
regs.rdi = regs.rsp - 4096;
regs.rsi = RTLD_LAZY;
regs.rax = dlopen_addr;
ptrace_setregs(pid, ®s);
这上面这段代码中,我们先复制一份刚才保存的现场,然后我们要对这个现场做某些调整,
具体来说:
- 将当前指令寄存器指向的内存地址内容保存,并修改为
jmp rax
。 - rax寄存器中放着dlopen的地址,意味着接下来程序将跳转执行dlopen函数。
- 按照x86函数调用规约设置好dlopen需要的参数。
- 将当前栈帧寄存器 指向一个非法地址(内容为0x0),这样在执行完dlopen之后,程序将会收到一个信号并停止运行,
我们将在那时重新恢复保存的现场。
做完这些工作之后,我们调用ptrace函数使目标进程继续运行
static int ptrace_cont(pid_t pid) {
int status;
if (ptrace(PTRACE_CONT, pid, NULL, 0)) {
fprintf(stderr, "Failed to ptrace_cont: %s\n", strerror(errno));
return 1;
}
if (waitpid(pid, &status, __WALL) < 0) {
fprintf(stderr, "Failed to wait for PID %d, %s\n", pid, strerror(errno));
return 1;
}
return 0;
}
当dl_open函数执行完成之后,我们所期望的函数已经替换完成。并且由于我们之前将此时,我们再恢复保存的现场,使目标进程继续运行,
ptrace_setregs(pid, &saved_regs);
ptrace(PTRACE_DETACH, pid, 0, 0);
至此,整个程序执行完成之后,我们可以观察到目标程序的输出变化,说明我们的动态修改已经生效。
您好,写的很好,我想复现一下,但您提供的代码不是很完整,想看一下ptrace_poketext函数的实现,如果有源码的github链接提供下就更好了