调试器的原理

Catalpa 网络安全爱好者

学习调试器的基本原理。

进程

进程:进程是操作系统资源分配的基本单位,操作系统使用进程控制块(PCB)来表示一个进程。进程有属于他自己的堆栈空间,有一定的优先级,每个进程有独一无二的 PID。

fork 函数:Linux 中提供的用于创建新进程的函数。

fork 函数原型: pid_t fork(void),调用 fork 函数的进程称作父进程,被创建的进程称作子进程。如果函数执行成功,父进程返回子进程的 PID,子进程返回 0。如果执行失败,返回负数。

fork 通过完全复制父进程的资源创建子进程,子进程作为父进程的一个副本存在,父子进程几乎是完全相同的。两者都从 fork 之后开始运行
底层原理:fork 通过系统调用创建新的进程,进程被存放在任务队列(双向循环链表)中,链表中的每一项都是进程描述符(PCB,进程控制块)。内核通过 PID 表示每一个进程,默认最大值为 32768 。可在 /proc/sys/kernel/pid_max 查看。

fork 呼叫系统调用,CPU 控制流转向内核 fork 代码,内核会做 4 件事情:

  1. 给子进程分配内存和内核线程
  2. 将父进程的部分数据拷贝到子进程
  3. 将子进程添加到进程列表
  4. fork 返回,开始调度执行。

fork 系列函数都是 clone 系统调用的封装,根据不同函数设置不同的调用参数。

fork 生成的子进程和父进程共享代码段,对于数据段采用写时复制技术。

写时复制:对每个内存区域添加一个计数器,每当一个指针引用了这块内存,计数器就加一。每当一个指针释放了内存,计数器就减一。

两个进程(或多个进程)共享一份内存页,这个内存页被系统标记为写保护。当一个进程想要修改页面内的数据,会因为权限问题触发异常。操作系统捕获这个异常,将受保护的内存页复制出来一份到新的页面,并修改保护标记为可写。想要修改内存数据的进程就去使用新复制出来的页面。原来的内存页依然是写保护的,当其他进程试图修改的时候,系统检测共享这个页面的进程数量,确定写页面的进程是否拥有这个内存页?如果是,就把这个内存页标记为对属主可写。

僵尸进程:和现实一样,父进程有义务去监管子进程(父亲对孩子有抚养义务),当子进程执行完毕或者在执行过程中出现某些错误退出之后,父进程需要调用 waitpid 来清理子进程的内核 PCB 结构。如果父进程没有正确的处理子进程相关信息(父亲跑路了),那么子进程会变成僵尸进程(孤儿)。其保存在内核的 PCB 没人来清理。

理论上所有刚刚结束的进程都是僵尸进程,但是它们的父进程会及时的清理这些已经结束的进程(它们的父亲很有责任心),所以正常使用系统的时候很少会看见僵尸进程。某些软件编写存在疏漏,就会出现僵尸进程。

例如父进程调用 fork 启动了一个子进程,子进程完成任务结束,但是父进程既不清理,也不退出,导致子进程变成孤儿。此时系统看不过去了,会将僵尸进程的父进程变更成 init 这个老大哥。因为 init 是所有进程的父进程,会伴随着整个系统的运行,老大哥会在一定时间对僵尸进程进行清理,实际上这就是系统启动完毕之后 init 进程的主要职责。

调试器基本原理

调试器应该具有的功能:

  1. 追踪进程运行,能够控制执行流。
  2. 访问进程的堆栈内存,并且可以随时修改。
  3. 添加断点,能够单步执行进程每条指令
  4. 随时改变进程代码,并且能够立即产生影响。

调试器还有很多功能,但是具有以上功能的调试器已经可以正常使用了。

现代调试器一般有两种,一是利用 CPU 提供的调试工具,例如 Intel CPU 提供了 INT 3 断点、硬件调试寄存器等。二是针对不同架构实现不同的仿真器,模拟 CPU 、内存等一切外围设施,此时调试器对进程拥有绝对的控制权。

目前几乎所有调试器都采用第一种方式,即使用 CPU 提供的调试工具。

调试器的基本思路:

  1. 将自身附加到进程
  2. 注册为调试器
  3. 读取进程代码段,利用反汇编引擎处理成汇编指令,输出到屏幕
  4. 根据用户选择的指令插入 INT3 断点
  5. 操作系统捕获 INT3,寻找注册的调试器
  6. 捕获信号,将被替换的指令还原
  7. 单步运行,读取堆栈内存等

以上是基本的调试器实现思路,但是在 Linux 中我们有一个更好用的工具:ptrace

ptrace 是 Linux 提供的系统调用,其角色和 Windows 中的一系列和进程有关 API 类似,我们可以利用 ptrace 访问目标进程的寄存器,控制目标进程的指令执行等。

wait 系统调用:Linux man page 描述如下

1
2
3
wait、waitpid、waitid 这几个系统调用被用于等待调用者的子进程状态改变。
状态改变意味着子进程终止、子进程收到信号停止或是子进程收到信号重新运行,
对于终止的子进程,父进程调用 waitpid 允许操作系统对这个子进程进行资源回收,否则子进程将会变成僵尸进程。

wait 系统调用 wait(&status) 相当于 waitpid(-1, &status, 0);

waitpid 暂停调用它的进程,直到接收到子进程的状态改变信号。默认情况下,此系统调用只等待终止进程的出现,但可以通过修改 option 参数来修改其行为。

第一个参数是 PID,取值如下

  1. PID < -1,waitpid 等待所有进程组 ID 和 PID 绝对值相等的子进程
  2. PID == -1,等待所有子进程
  3. PID == 0,等待所有进程组 ID 和调用者 pid 相等的子进程
  4. PID > 0,等待 pid 和 PID 相等的子进程

waitpid 的 option 有很多,具体可参考 man page。

ptrace 系统调用:Linux man page 描述如下

1
2
3
4
5
6
ptrace 系统调用允许一个进程监视其他进程的内存和寄存器布局,它主要被用于实现断点调试或是追踪系统调用。
被追踪的进程首先需要被追踪器附加,在一个多线程的进程中,每个线程都可以被一个追踪器附加,或是不被附加而正常运行。所以被追踪的通常是“线程”而不是“进程”。
一个进程可以通过 fork 创建子进程,在子进程中使用 PTRACE_TRACEME 请求父进程对其进行追踪。
在追踪过程中,被追踪进程每次接收到信号的时候就会暂停(SIGKILL 信号除外),追踪者可使用 waitpid 等系统调用获取到子进程暂停这一事实,当子进程处于暂停状态的过程中,追踪者可以使用 ptrace 系列工具对子进程进行各种修改,追踪者可选择继续执行子进程,甚至给子进程传递不同的信号。
如果 PTRACE_O_TRACEEXEC 选项没有生效,那么被追踪进程每次调用 execve 的时候,都会收到 SIGTRAP 信号,给追踪者获取它控制权的机会。
追踪者结束追踪之后可通过 PTRACE_DETACH 使子进程继续正常运行。

实现最简单的调试器

那么如何使用系统提供的 ptrace 实现一个最简单的调试器?

根据前面了解到的有关知识,我们可采用 ptrace + waitpid + fork 方式实现父进程对子进程的调试。

基本思路

  1. 父进程通过 fork 创建子进程
  2. 子进程利用 PTRACE_TRACEME 通知操作系统,要求其父进程对自己进行追踪。
  3. 子进程利用 exec 执行其他程序,此时由于 ptrace 的存在,子进程会收到 SIGTRAP 信号而暂停。
  4. 父进程使用 waitpid 等待,直到子进程暂停,此时父进程可修改子进程的内存或寄存器。
  5. 父进程完成修改,通过 ptrace 使子进程单步运行,父进程继续使用 waitpid 等待子进程暂停,不断循环。

基于以上思路,我们可以实现一个最简单的统计程序 CPU 指令数量的调试器。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <stdio.h>
#include <stdarg.h>
#include <syscall.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <unistd.h>
#include <errno.h>

void startProcess(char* fileName){
printf("Process starting!\n");
if(ptrace(PTRACE_TRACEME, 0, 0, 0) < 0){ // 告知操作系统,要求父进程追踪自己
printf("ptrace Error!1\n");
return;
}

execl(fileName, fileName, 0, NULL); // 调用 exec 的时候子进程收到 SIGTRAP 信号暂停
}

void MyDebugger(pid_t childPid){
int wait_status;
unsigned int icounter = 0;

wait(&wait_status); // 父进程收到子进程的 SIGTRAP 停止信息
while(WIFSTOPPED(wait_status)){ // 判断子进程是否停止
icounter++; // 指令计数器 +1
if(ptrace(PTRACE_SINGLESTEP, childPid, 0, 0) < 0){ // 告知子进程运行一条指令之后再次暂停
printf("ptrace Error!2\n");
return;
}
wait(&wait_status); // 继续等待子进程暂停
}

printf("Target run %d instructions!\n", icounter);
}


int main(int argc, char** argv){
printf("Debugger Test\n");

pid_t pid = 0;
if(argc != 2){
printf("Please set a filename!\n");
return 0;
}
pid = fork();
if(pid < 0){
printf("fork failed!\n");
return -1;
}
else if(pid == 0){ // child process
printf("Child process running!\n");
startProcess(argv[1]); // 子进程启动另一个程序
}
else if(pid > 0){
printf("Current chind PID: %d\n", pid);
printf("Current parent PID: %d\n", getpid());
MyDebugger(pid); // 父进程准备追踪
}

}

执行结果:

1
2
3
4
5
6
7
Debugger Test
Current chind PID: 7726
Current parent PID: 7725
Child process running!
Process starting!
Hello World!
Target run 11919 instructions!

另外还可以简单修改一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <stdio.h>
#include <stdarg.h>
#include <syscall.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <unistd.h>
#include <errno.h>

void startProcess(char* fileName){
printf("Process starting!\n");
if(ptrace(PTRACE_TRACEME, 0, 0, 0) < 0){
printf("ptrace Error!1\n");
return;
}

execl(fileName, fileName, 0, NULL);
}

void MyDebugger(pid_t childPid){
int wait_status;
unsigned int icounter = 0;

wait(&wait_status);
while(WIFSTOPPED(wait_status)){
icounter++;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, childPid, 0, &regs);
unsigned long long int ins = ptrace(PTRACE_PEEKTEXT, childPid, regs.rip, 0);
printf("Trace: RIP = 0x%08x, TEXT = 0x%08x\n", regs.rip, ins);
if(ptrace(PTRACE_SINGLESTEP, childPid, 0, 0) < 0){
printf("ptrace Error!2\n");
return;
}
wait(&wait_status);
}

printf("Target run %d instructions!\n", icounter);
}


int main(int argc, char** argv){
printf("Debugger Test\n");

pid_t pid = 0;
if(argc != 2){
printf("Please set a filename!\n");
return 0;
}
pid = fork();
if(pid < 0){
printf("fork failed!\n");
return -1;
}
else if(pid == 0){ // child process
printf("Child process running!\n");
startProcess(argv[1]);
}
else if(pid > 0){
printf("Current chind PID: %d\n", pid);
printf("Current parent PID: %d\n", getpid());
MyDebugger(pid);
}

}

通过 ptrace 的其他工具,我们可以实现读取目标进程寄存器、代码段数据的目的(需要注意被追踪的程序架构)。

1
2
3
4
5
6
7
8
9
10
11
12
13
Trace: RIP = 0x0040e95c, TEXT = 0x20fd8148
Trace: RIP = 0x0040e963, TEXT = 0xdf89f072
Trace: RIP = 0x0040e965, TEXT = 0x34e8df89
Trace: RIP = 0x0040e967, TEXT = 0x030034e8
Trace: RIP = 0x0043e9a0, TEXT = 0x49d76348
Trace: RIP = 0x0043e9a3, TEXT = 0xd0c1c749
Trace: RIP = 0x0043e9aa, TEXT = 0x00e7b841
Trace: RIP = 0x0043e9b0, TEXT = 0x00003cbe
Trace: RIP = 0x0043e9b5, TEXT = 0x0f6619eb
Trace: RIP = 0x0043e9d0, TEXT = 0x44d78948
Trace: RIP = 0x0043e9d3, TEXT = 0x0fc08944
Trace: RIP = 0x0043e9d6, TEXT = 0x3d48050f
Target run 11919 instructions!
  • Title: 调试器的原理
  • Author: Catalpa
  • Created at : 2020-02-21 00:00:00
  • Updated at : 2024-10-17 08:47:53
  • Link: https://wzt.ac.cn/2020/02/21/dig_debugger/
  • License: This work is licensed under CC BY-NC-SA 4.0.