玩转ptrace (一)

时间:2022-01-15 15:28:39

转自http://www.cnblogs.com/catch/p/3476280.html

[本文翻译自这里: http://www.linuxjournal.com/article/6100?page=0,0,作者:Pradeep Padaia]

你是否曾经想过怎样才能拦截系统调用?你是否曾经想过通过修改一下系统调用的参数来耍一把内核?你是否想过调试器是怎样把一个进程停下来,然后把控制权转移给你的?如果你以为这些都是通过复杂的内核编程来实现的,那你就错了,事实上,Linux 提供了一种很优雅的方式来实现上述所有行为:ptrace 系统调用。ptrace 提供了一种机制使得父进程可以观察和控制子进程的执行过程,ptrace 还可以检查和修改该子进程的可执行文件在内存中的镜像及该子进程所使用的寄存器中的值。这种用法通常来说,主要用于实现对进程插入断点和跟踪子进程的系统调用。

在本篇文章中,我们将学习怎么去拦截一个系统调用并且修改该系统调用的参数,在后续一篇文章中,我们将继续探讨 ptrace 的一些更深入的技术,如设置断点,在运行的子进程中插入代码等。我们将会查看进程的寄存器和数据段,并去修改其中的内容。我们还会介绍一种方式来在进程中插入代码,使得该进程能停下来,并执行我们插入的任意代码。

基础

操作系统通过一个叫做“系统调用”的标准机制来对上层提供服务,他们提供了一系列标准的API来让上层应用程序获取底层的硬件和服务,比如文件系统。当一个进程想要进行一个系统调用的时候,它会把该系统调用所需要用到的参数放到寄存器里,然后执行软中断指令0x80. 这个软中断就像是一个门,通过它就能进入内核模式,进入内核模式后,内核将会检查系统调用的参数,然后执行该系统调用。

在 i386 平台下(本文所有代码都基于 i386), 系统调用的编号会被放在寄存器 %eax 中,而系统调用的参数会被依次放到 %ebx,%ecx,%edx,%exi 和 %edi中,比如说,对于下面的系统调用:

write(2, "Hello", 5)

编译后,它最后大概会被转化成下面这样子:

movl   $4, %eax
movl $2, %ebx
movl $hello,%ecx
movl $5, %edx
int $0x80

其中 $hello 指向字符串 "Hello"。

看完上面简单的例子,现在我们来看看 ptrace 又是怎样执行的。首先,我们假设进程 A 要 ptrace 进程 B。在 ptrace 系统调用真正开始前,内核会检查一下我们将要 trace 的进程 B 是否当前已经正在被 traced 了,如果是,内核就会把该进程 B 停下来,并把控制权交给调用进程 A (任何时候,子进程只能被父进程这唯一一个进程所trace),这使得进程A有机会去检查和修改进程B的寄存器的值。

下面我们用一个例子来说明:

玩转ptrace (一)
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h> /* For constants
ORIG_EAX etc */
int main()
{ pid_t child;
long orig_eax;
child = fork();
if(child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
}
else {
wait(NULL);
orig_eax = ptrace(PTRACE_PEEKUSER,
child, 4 * ORIG_EAX,
NULL);
printf("The child made a "
"system call %ld\n", orig_eax);
ptrace(PTRACE_CONT, child, NULL, NULL);
}
return 0;
}
玩转ptrace (一)

当把上面这段代码编译执行后,终端上除了命令 ls 的输出外,还会输出下面一行:

The child made a system call 11

根据上面的输出,我们知道,在执行 ls 命令的时候,第11号系统调用被执行了,它是子进程中执行的第一个系统调用。如果想查看一下各个系统调用编号对应的名字,可以参考头文件:/usr/include/asm/unistd.h.

正如你在上面的例子中所看到,ptrace 的使用流程一般是这样的:父进程 fork() 出子进程,子进程中执行我们所想要 trace 的程序,在子进程调用 exec() 之前,子进程需要先调用一次 ptrace,以 PTRACE_TRACEME 为参数。这个调用是为了告诉内核,当前进程已经正在被 traced,当子进程执行 execve() 之后,子进程会进入暂停状态,把控制权转给它的父进程(SIG_CHLD信号), 而父进程在fork()之后,就调用 wait() 等子进程停下来,当 wait() 返回后,父进程就可以去查看子进程的寄存器或者对子进程做其它的事情了。

当系统调用发生时,内核会把当前的%eax中的内容(即系统调用的编号)保存到子进程的用户态代码段中(USER SEGMENT or USER CODE),我们可以像上面的例子那样通过调用Ptrace(传入PTRACE_PEEKUSER作为第一个参数)来读取这个%eax的值,当我们做完这些检查数据的事情之后,通过调用ptrace(PTRACE_CONT),可以让子进程重新恢复运行。

ptrace的参数

ptrace 总共有 4 个参数:

long ptrace(enum __ptrace_request request,
pid_t pid,
void *addr,
void *data);

其中第一个参数决定ptrace的行为也决定了接下来其它3个参数是怎样被使用的,第1个参数可以取以下任意一个值:

玩转ptrace (一)
PTRACE_TRACEME,
PTRACE_PEEKTEXT,
PTRACE_PEEKDATA,
PTRACE_PEEKUSER,
PTRACE_POKETEXT,
PTRACE_POKEDATA,
PTRACE_POKEUSER,
PTRACE_GETREGS,
PTRACE_GETFPREGS,
PTRACE_SETREGS,
PTRACE_SETFPREGS,
PTRACE_CONT,
PTRACE_SYSCALL,
PTRACE_SINGLESTEP,
PTRACE_DETACH
玩转ptrace (一)

本文接下来会解释这些参数有什么不同的地方。

读取系统调用的参数

通过调用ptrace并传入PTRACE_PEEKUSER作为第一个参数,我们可以检查子进程中,保存了该进程的寄存器的内容(及其它一些内容)的用户态内存区域(USER area)。内核把寄存器的内容保存到这块区域,就是为了能够让父进程通过ptrace来读取,下面举一个例子来说明一下:

玩转ptrace (一)
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h> /* For SYS_write etc */
int main()
{ pid_t child;
long orig_eax, eax;
long params[3];
int status;
int insyscall = 0;
child = fork();
if(child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
}
else {
while(1) {
wait(&status);
if(WIFEXITED(status))
break;
orig_eax = ptrace(PTRACE_PEEKUSER,
child, 4 * ORIG_EAX, NULL);
if(orig_eax == SYS_write) {
if(insyscall == 0) {
/* Syscall entry */
insyscall = 1;
params[0] = ptrace(PTRACE_PEEKUSER,
child, 4 * EBX,
NULL);
params[1] = ptrace(PTRACE_PEEKUSER,
child, 4 * ECX,
NULL);
params[2] = ptrace(PTRACE_PEEKUSER,
child, 4 * EDX,
NULL);
printf("Write called with "
"%ld, %ld, %ld\n",
params[0], params[1],
params[2]);
}
else { /* Syscall exit */
eax = ptrace(PTRACE_PEEKUSER,
child, 4 * EAX, NULL);
printf("Write returned "
"with %ld\n", eax);
insyscall = 0;
}
}
ptrace(PTRACE_SYSCALL,
child, NULL, NULL);
}
}
return 0;
}
玩转ptrace (一)

编译执行上面的代码,得到的输出和前一个例子的输出有些类似:

玩转ptrace (一)
ppadala@linux:~/ptrace > ls
a.out dummy.s ptrace.txt
libgpm.html registers.c syscallparams.c
dummy ptrace.html simple.c
ppadala@linux:~/ptrace > ./a.out
Write called with 1, 1075154944, 48
a.out dummy.s ptrace.txt
Write returned with 48
Write called with 1, 1075154944, 59
libgpm.html registers.c syscallparams.c
Write returned with 59
Write called with 1, 1075154944, 30
dummy ptrace.html simple.c
Write returned with 30
玩转ptrace (一)

在这个例子中,我们追踪了 write() 这个系统调用,由上面的输出我们可以看出,ls这个程序总共调用了3次 write().

调用 ptrace 并传入参数:PTRACE_SYSCALL, 会使得子进程在每次进行系统调用及结束一次系统调用时都会被内核停下来,这一个过程就相当于做了一个ptrace(PTRACE_CONT) 调用,然后在每次系统调用前和系统调用后就停下来。在前面一个例子中,我们用 PTRACE_PEEKUSER 来读取系统调用的参数,当系统调用结束后,该调用的返回值会被放在%eax中,像上面的例子展示的那样,这个值也是可以被读取的。

至于上面的例子中出现的调用:wait(&status),这是个典型的用于判断子进程是被 ptrace 停住还是已经运行结束了的用法,变量 status 用于标记子进程是否已经结束退出,关于这个 wait() 和 WIFEXITED 的更多细节,读者可以自行查看一下manual(man 2).

读取寄存器的值

如果你想在系统调用开始前或结束后读取多个寄存器的值,上面的代码实现起来会比较麻烦,ptrace提供了另一种方式来一次性读取所有的寄存器的内容,这就是参数:PTRACE_GETREGS的作用。参看下面的例子:

玩转ptrace (一)
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
int main()
{ pid_t child;
long orig_eax, eax;
long params[3];
int status;
int insyscall = 0;
struct user_regs_struct regs;
child = fork();
if(child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
}
else {
while(1) {
wait(&status);
if(WIFEXITED(status))
break;
orig_eax = ptrace(PTRACE_PEEKUSER,
child, 4 * ORIG_EAX,
NULL);
if(orig_eax == SYS_write) {
if(insyscall == 0) {
/* Syscall entry */
insyscall = 1;
ptrace(PTRACE_GETREGS, child,
NULL, &regs);
printf("Write called with "
"%ld, %ld, %ld\n",
regs.ebx, regs.ecx,
regs.edx);
}
else { /* Syscall exit */
eax = ptrace(PTRACE_PEEKUSER,
child, 4 * EAX,
NULL);
printf("Write returned "
"with %ld\n", eax);
insyscall = 0;
}
}
ptrace(PTRACE_SYSCALL, child,
NULL, NULL);
}
}
return 0;
}
玩转ptrace (一)

这个例子和前面一个例子几乎是一模一样的,除了读取寄存器的地方换成了PTRACE_GETREGS.在这里我们用到了user_regs_struct这个结构体,它被定义在<linux/user.h>中。

做点有趣的事情

好,有了前面的基础,现在我们可以来尝试做些有趣的事情了。下面我们将把子进程调用 write 时,传给 write() 的参数都给反转过来,看看会得到怎样的结果。

玩转ptrace (一)
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
const int long_size = sizeof(long);
void reverse(char *str)
{ int i, j;
char temp;
for(i = 0, j = strlen(str) - 2;
i <= j; ++i, --j) {
temp = str[i];
str[i] = str[j];
str[j] = temp;
}
}
void getdata(pid_t child, long addr,
char *str, int len)
{ char *laddr;
int i, j;
union u {
long val;
char chars[long_size];
}data;
i = 0;
j = len / long_size;
laddr = str;
while(i < j) {
data.val = ptrace(PTRACE_PEEKDATA,
child, addr + i * 4,
NULL);
memcpy(laddr, data.chars, long_size);
++i;
laddr += long_size;
}
j = len % long_size;
if(j != 0) {
data.val = ptrace(PTRACE_PEEKDATA,
child, addr + i * 4,
NULL);
memcpy(laddr, data.chars, j);
}
str[len] = '\0';
}
void putdata(pid_t child, long addr,
char *str, int len)
{ char *laddr;
int i, j;
union u {
long val;
char chars[long_size];
}data;
i = 0;
j = len / long_size;
laddr = str;
while(i < j) {
memcpy(data.chars, laddr, long_size);
ptrace(PTRACE_POKEDATA, child,
addr + i * 4, data.val);
++i;
laddr += long_size;
}
j = len % long_size;
if(j != 0) {
memcpy(data.chars, laddr, j);
ptrace(PTRACE_POKEDATA, child,
addr + i * 4, data.val);
}
}
int main()
{
pid_t child;
child = fork();
if(child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
}
else {
long orig_eax;
long params[3];
int status;
char *str, *laddr;
int toggle = 0;
while(1) {
wait(&status);
if(WIFEXITED(status))
break;
orig_eax = ptrace(PTRACE_PEEKUSER,
child, 4 * ORIG_EAX,
NULL);
if(orig_eax == SYS_write) {
if(toggle == 0) {
toggle = 1;
params[0] = ptrace(PTRACE_PEEKUSER,
child, 4 * EBX,
NULL);
params[1] = ptrace(PTRACE_PEEKUSER,
child, 4 * ECX,
NULL);
params[2] = ptrace(PTRACE_PEEKUSER,
child, 4 * EDX,
NULL);
str = (char *)calloc((params[2]+1)
* sizeof(char));
getdata(child, params[1], str,
params[2]);
reverse(str);
putdata(child, params[1], str,
params[2]);
}
else {
toggle = 0;
}
}
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
}
}
return 0;
}
玩转ptrace (一)

上面的代码编译运行后,将得到这样的类似下面的结果:

玩转ptrace (一)
ppadala@linux:~/ptrace > ls
a.out dummy.s ptrace.txt
libgpm.html registers.c syscallparams.c
dummy ptrace.html simple.c
ppadala@linux:~/ptrace > ./a.out
txt.ecartp s.ymmud tuo.a
c.sretsiger lmth.mpgbil c.llacys_egnahc
c.elpmis lmth.ecartp ymmud
玩转ptrace (一)

有趣吧!这个例子使用到了我们前面提到过的所有概念。在这当中,我们通过在 ptrace 中使用 PTRACE_POKEDATA 参数来改变子进程中的数据。这个 PTRACE_POKEDATA 用起来和 PTRACE_PEEKDATA 是一样的,不同之处只在于 PTRACE_POKEDATA 不仅可以读数据,还能往子进程里写数据。

单步执行

ptrace 提供了一种手段使得我们可以像 debugger 一样单步执行子进程的代码,很酷?调用一下 ptrace(PTRACE_SINGLESTEP) 就能完成这样的事情,这个调用会告诉内核,在子进程每执行完一条子令之后,就停一下。

下面的代码演示了怎么读取子进程中当前正在被执行的子令,为了让读者更好的理解发生了什么事情,我自己写了一个很简单的dummy程序来方便大家理解。

下面是一小段汇编代码:

玩转ptrace (一)
.data
hello:
.string "hello world\n"
.globl main
main:
movl $4, %eax
movl $2, %ebx
movl $hello, %ecx
movl $12, %edx
int $0x80
movl $1, %eax
xorl %ebx, %ebx
int $0x80
ret
玩转ptrace (一)

我们用命令把它编译成可执行文件:

gcc -o dummy1 dummy1.s

然后我们将单步执行这个程序:

玩转ptrace (一)
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
int main()
{ pid_t child;
const int long_size = sizeof(long);
child = fork();
if(child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("./dummy1", "dummy1", NULL);
}
else {
int status;
union u {
long val;
char chars[long_size];
}data;
struct user_regs_struct regs;
int start = 0;
long ins;
while(1) {
wait(&status);
if(WIFEXITED(status))
break;
ptrace(PTRACE_GETREGS,
child, NULL, &regs);
if(start == 1) {
ins = ptrace(PTRACE_PEEKTEXT,
child, regs.eip,
NULL);
printf("EIP: %lx Instruction "
"executed: %lx\n",
regs.eip, ins);
}
if(regs.orig_eax == SYS_write) {
start = 1;
ptrace(PTRACE_SINGLESTEP, child,
NULL, NULL);
}
else
ptrace(PTRACE_SYSCALL, child,
NULL, NULL);
}
}
return 0;
}
玩转ptrace (一)

编译运行上面的代码,输出的结果是:

hello world
EIP: 8049478 Instruction executed: 80cddb31
EIP: 804947c Instruction executed: c3

想要看明白这里做了什么事情,你可能需要先查一下 Intel 的手册,弄明白那些指令是干什么的。对程序执行更复杂的单步操作,如加入断点等,我们还需要写一些更细致更复杂的代码,在下一篇文章中,我们会展示一下怎么对程序加入断点。