简介
从本篇文章开始,将陆续讲解一些关于动态修改进程的代码的话题。其中牵扯到
- 如何定位要修改的代码地址
我们以函数为单位进行代码的修改
- 如何准备要修改之后的代码数据
- 修改代码段的方式
等等一系列问题。本篇文章我们先只考虑如何进程进行代码的的自修改,此时要修改的代码地址就是函数指针所指向的地址,因此不用额外叙述。我们先来讨论一下为什么要动态修改进程的代码段。
为什么要修改代码段
动态修改代码段的一个重要用途是在程序运行时对其进程某种升级,比如修复bug,更新功能等等。
需要进行动态修改代码的程序往往是非常重要的程序,不能停止运行,所以不能通过一般的方式进行代码更新。
自修改的几种方式
动态修改代码的实现方式粗略上来讲可以分为两种方式
- 直接在原来的函数位置覆盖原来的函数代码
- 通过在原来函数位置放置特殊的跳转指令使得程序跳转到新的位置执行新的逻辑
其中第一种方式比较简单,但是有一些条件限制,最重要的是它要求修改后的函数代码长度不能大于修改前的函数代码,否则很有可能会覆盖待修改的函数之后的代码,导致程序无法继续正确运行。第二种方式可以用不同的跳转机制来实现,比如跳转指令,软中断等,这个我们以后再讨论。
修改进程代码段的方式
进程的代码段默认是不允许被修改的,我们可以通过 mprotect(2) 来修改该段内存为可写,然后就可以通过函数指针直接修改代码段。修改完成之后,再通过 mprotect 将该段内存的读写权限改回来。
获得要替换的函数
想要替换函数实现,由于是通过直接修改进程代码段的方式,那么就需要获得修改后的函数代码,然后通过上面说的方式将修改后的代码拷贝到相应的位置。那么我们如何获得要替换的函数代码呢。
- 一种最简单的方式是直接手写汇编代码,然后手动转化为机器码,这种方式比较复杂,也比较容易出错,而且不但要手写汇编逻辑代码,还要求手写的函数满足函数调用规约,同时需要保证要修改的程序当前没有正在运行。 因此只能处理一些非常简单的情况,比如要修改后的函数非常短的情况。
- 第二种方式可以使用c代码写出该函数,然后通过gcc -c将其编译为目标文件,再使用gdb得到该函数的汇编代码或者二进制代码。显然第二种方式要比第一种方式好的多。
一个具体的例子
比如我们现在有一个这样的c程序 test.c :
#include <stdio.h> #include <stdlib.h> int add(int a, int b) { return a + b; } int main() { for (;;) { print("%d\n", add(1, 1)); sleep(3); } }
我们想要将其中的add函数的行为修改为返回两个数相加的相反数。
在本篇文章中,我们只演示自修改的,后面的文章里再讨论如果动态修改其他进程的代码。
我们给上面的程序安装一个信号处理器,在收到信号时对add函数进行自修改。
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/mman.h> #include <string.h> #include <stdint.h> #include <errno.h> int add(int a, int b) { return a + b; } char new_func[] = { } ; void sig_user1_handler(int sig, siginfo_t *si, void *data) { int pagesize = sysconf(_SC_PAGE_SIZE); if (pagesize < 0) { pagesize = 4096; } int len = sizeof(new_func); uintptr_t addr = (((uintptr_t)add) / pagesize) * pagesize; fprintf(stderr, "%s: iminus: %p, aligned: 0x%lx, sz %d\n", __func__, add, addr, len); if (mprotect((void*)addr, (uintptr_t)add - addr + len, PROT_WRITE|PROT_READ|PROT_EXEC) < 0) { fprintf(stderr, "%s\n", strerror(errno)); } memcpy((void*)add, (void*)new_func, len); if (mprotect((void*)addr, (uintptr_t)add - addr + len, PROT_READ|PROT_EXEC) < 0) { fprintf(stderr, "%s\n", strerror(errno)); } } int main() { struct sigaction newact, oldact; sigemptyset(&newact.sa_mask); newact.sa_sigaction = sig_user1_handler; sigaction(SIGUSR1, &newact, &oldact); for(;;) { print("%d\n", add(1, 1)); sleep(3); } }
其中 new_func 数组中存放的内容就是上文所说的需要得到的修改后的函数的二进制数据。
我们先编写一个简单的程序main.c,定义一个新的add函数:
int add(int a, int b) { return -(a + b); } int main() { return 0; }
将其编译一下,然后通过gdb来得到实际的函数二进制代码:
gcc -g main.c -o main gdb -batch -ex 'file main' -ex 'disassemble/rs add'
同时对test.c也需要执行相应的操作,查看输出保证修改后的代码长度不大于修改前的代码。
可以得到二进制数据为
0x55, 0x48, 0x89, 0xe5, 0x89, 0x7d, 0xfc, 0x89, 0x75, 0xf8, 0x8b, 0x55, 0xfc, 0x8b, 0x45, 0xf8, 0x01, 0xd0, 0xf7, 0xd8, 0x5d, 0xc3
将其复制到上面的测试代码中,补全new_func的定义。
char new_func[] = { 0x55, 0x48, 0x89, 0xe5, 0x89, 0x7d, 0xfc, 0x89, 0x75, 0xf8, 0x8b, 0x55, 0xfc, 0x8b, 0x45, 0xf8, 0x01, 0xd0, 0xf7, 0xd8, 0x5d, 0xc3 };
编译运行这个程序:
gcc -g test.c -o test; ./test
可以看到程序一直在打印2,然后在另一个终端执行kill -SIGUSR1 `pidof test`,可以看到程序改变了自身行为,开始打印-2。
总结
上面的测试程序演示了如何实现一个程序的自修改,当然这种自修改并没有什么用处,但是我们可以通过另外的方式来对任意一个运行中的程序进行修改,那时动态修改进程代码的作用就会展现出来,就这些问题我们留到以后的文章里讨论。