GDB原理

ptrace

简介

linux下gdb调试都是通过ptrace来实现的,通过名字我们就可以看到ptrace是一个用于进程追踪的系统调用,当进程调用了 ptrace 跟踪某个进程之后:

  • 被跟踪进程的进程状态被标记为 TASK_STOPED
  • 发送给被跟踪子进程的信号 (SIGKILL 除外) 会被转发给父进程, 而子进程会被阻塞
  • 父进程收到信号后, 可以对子进程进行检查和修改, 然后让子进程继续执行
1
2
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

其中 request 参数指定了我们要使用 ptrace 的什么功能, 大致可以分为以下几类:

  • PTRACE_ATTACH 或 PTRACE_TRACEME 建立进程间的跟踪关系;
    • PTRACE_TRACEME 是被跟踪子进程调用的, 表示让父进程来跟踪自己, 通常是通过 GDB 启动新进程的时候使用;
    • PTRACE_ATTACH 是父进程调用 attach 到已经运行的子进程中; 这个命令会有权限的检查, non-root 的进程不能 attach 到 root 进程中;
  • PTRACE_PEEKTEXT, PTRACE_PEEKDATA, PTRACE_PEEKUSR 等读取子进程内存/寄存器中保留的值;
  • PTRACE_POKETEXT, PTRACE_POKEDATA, PTRACE_POKEUSR 等修改被跟踪进程的内存/寄存器;
  • PTRACE_CONT,PTRACE_SYSCALL, PTRACE_SINGLESTEP 控制被跟踪进程以何种方式继续运行;
    • PTRACE_SYSCALL 会让被调用进程在每次 进入/退出 系统调用时都触发一次 SIGTRAP; strace 就是通过调用它来实现的, 在每次进入系统调用的时候读取出系统调用参数, 在退出系统调用的时候读取出返回值;
    • PTRACE_SINGLESTEP 会在每执行完一条指令后都触发一次 SIGTRAP; GDB 的 nexti, next 命令都是通过它来实现的;
  • PTRACE_DETACH, PTRACE_KILL 脱离进程间的跟踪关系;
    • 当父进程在子进程之前结束时, trace 关系会被自动解除;

参数 pid 表示的是要跟踪进程的 pid, addr 表示要监控的被跟踪子进程的地址.

原理

调用 ptrace() 系统函数时会触发调用内核的 sys_ptrace() 函数,由于不同的 CPU 架构有着不同的调试方式,所以 Linux 为每种不同的 CPU 架构实现了不同的 sys_ptrace() 函数,而本文主要介绍的是 X86 CPU 的调试方式,所以 sys_ptrace() 函数所在文件是 linux-2.4.16/arch/i386/kernel/ptrace.c

sys_ptrace() 函数的主体是一个 switch 语句,会传入的 request 参数不同进行不同的操作,如下:

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
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
struct task_struct *child;
struct user *dummy = NULL;
int i, ret;

...

read_lock(&tasklist_lock);
child = find_task_by_pid(pid); // 获取 pid 对应的进程 task_struct 对象
if (child)
get_task_struct(child);
read_unlock(&tasklist_lock);
if (!child)
goto out;

if (request == PTRACE_ATTACH) {
ret = ptrace_attach(child);
goto out_tsk;
}

...

switch (request) {
case PTRACE_PEEKTEXT:
case PTRACE_PEEKDATA:
...
case PTRACE_PEEKUSR:
...
case PTRACE_POKETEXT:
case PTRACE_POKEDATA:
...
case PTRACE_POKEUSR:
...
case PTRACE_SYSCALL:
case PTRACE_CONT:
...
case PTRACE_KILL:
...
case PTRACE_SINGLESTEP:
...
case PTRACE_DETACH:
...
}
out_tsk:
free_task_struct(child);
out:
unlock_kernel();
return ret;
}

以被追踪模式(PTRACE_TRACEME)为例:

1
2
3
4
5
6
7
8
9
10
11
12
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
...
if (request == PTRACE_TRACEME) {
if (current->ptrace & PT_PTRACED)
goto out;
current->ptrace |= PT_PTRACED; // 标志 PTRACE 状态
ret = 0;
goto out;
}
...
}

ptrace()PTRACE_TRACEME 的处理就是把当前进程标志为 PTRACE 状态。

当一个进程被标记为 PTRACE 状态后,调用 exec() 函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个 SIGCHLD 给父进程。父进程接收到 SIGCHLD 信号后就可以对被调试的进程进行调试。

我们来看看 exec() 函数是怎样实现上述功能的,exec() 函数的执行过程为 sys_execve() -> do_execve() -> load_elf_binary()

1
2
3
4
5
6
7
static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
...
if (current->ptrace & PT_PTRACED)
send_sig(SIGTRAP, current, 0);
...
}

当进程被标记为 PTRACE 状态时,执行 exec() 函数后便会发送一个 SIGTRAP 的信号给当前进程。信号是通过 do_signal() 函数进行处理的,而对 SIGTRAP 信号的处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int do_signal(struct pt_regs *regs, sigset_t *oldset) 
{
for (;;) {
unsigned long signr;

spin_lock_irq(&current->sigmask_lock);
signr = dequeue_signal(&current->blocked, &info);
spin_unlock_irq(&current->sigmask_lock);

// 如果进程被标记为 PTRACE 状态
if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {
/* 让调试器运行 */
current->exit_code = signr;
current->state = TASK_STOPPED; // 让自己进入停止运行状态
notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程
schedule(); // 让出CPU的执行权限
...
}
}
}

上面的代码主要做了3件事:

  1. 如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行状态。
  2. 发送 SIGCHLD 信号给父进程。
  3. 让出 CPU 的执行权限,使 CPU 执行其他进程。

当父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了

断点

断点属性

  • 是否有条件(由 condition 命令修改)
  • 是否有忽略次数 (由 ignore 命令修改)
  • 是否只针对某个线程有效(由 break 命令的 thread 参数指定)
  • 是否是临时断点(由 tbreak 命令插入)

执行流程

当我们用 GDB 设置断点时, GDB 会把断点处的指令修改成 int 3,同时把断点信息及修改前的指令保存起来. 当被调试子进程运行到断点处时, 便会执行 int 3命令, 从而产生 SIGTRAP 信号. 由于 GDB 已经用 ptrace 和调试进程建立了跟踪关系, 此时的 SIGTRAP 信号会被发送给 GDB, GDB 通过和已有的断点信息做对比 (通过指令位置) 来判断这次 SIGTRAP 是不是一个断点.如果是断点的话, 就回等待用户的输入以做进一步的处理. 如果用户的命令是继续执行的话, GDB 就会先恢复断点处的指令, 然后执行对应的代码.

x86系列处理器从其第一代产品英特尔8086开始就提供了一条专门用来支持调试的指令,即INT 3。简单地说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。当CPU执行到INT 3指令时,由于INT 3指令的设计目的就是中断到调试器,因此,CPU执行这条指令的过程也就是产生断点异常(breakpoint exception,简称#BP)并转去执行异常处理例程的过程。在跳转到处理例程之前,CPU会保存当前的执行上下文,包括段寄存器、程序指针寄存器等内容。

上述描述的是软件断点,相应的还有硬件断点

X86系统提供8个调试寄存器(DR0~DR7)和2个MSR用于硬件调试。其中前四个DR0 ~ DR3是硬件断点寄存器,可以放入内存地址或者IO地址,还可以设置为执行、修改等条件。CPU在执行的到这里并满足条件会自动停下来。

断点判断

  • 导致目标程序本次停止运行的信号是不是 SIGTRAP

  • gdb把所有的断点位置都存放在一个链表中,命中判定即把被调试程序当前停止的位置和链表中的断点位置进行比较,看是断点产生的信号,还是无关信号。

  • 若断点存在条件,此时条件是否满足

  • 断点的忽略次数此时是否为 0

  • 若断点只针对某个线程有效,那么遇到该断点的线程是否就是断 点所设定的线程

若前两个条件之一不满足,则认为目标程序本次是因随机信号而停止。 若后三个条件之一不满足,则认为目标程序本次没有命中断点, gdb 会让其继续运行。

断点处理

临时断点:当判定为断点命中之后,若该断 点为临时断点, gdb 就会将这个断点删除。也就是说,临时断点只命 中一次。可能用到临时断点的场合:

  • 用户通过 tbreak 命令显式插入

  • next 、 nexti 、 step 命令需要跨越函数调用的时候,由 gdb 自动 在函数返回地址处插入临时断点

  • finish 命令需要在当前函数返回地址处插入临时断点

  • 带参数的 until 命令需要在当前函数返回地址以及参数指定地址插 入临时断点

  • 在不支持硬件单步的架构上, gdb 需要逐指令插入临时断点来实 现软件单步

gdb 将断点实际插入目标程序的时机:

当用户通过 break 命令设置一 个断点时,这个断点并不会立即生效,因为 gdb 此时只是在内部的断 点链表中为这个断点新创建了一个节点而已。 gdb 会在用户下次发出 继续目标程序运行的命令时,将所有断点插入目标程序,新设置的断 点到这个时候才会实际存在于目标程序中。与此相呼应,当目标程序 停止时, gdb 会将所有断点暂时从目标程序中清除。

断点命中失败的情况下,跨越断点继续运行的过程:

  • 清除断点
  • 单步到断点的下一条指令
  • 恢复断点
  • 继续目标程序运行

修改子进程内存

gdb在调试的时候会修改断点处二进制码为0xcc,我们通过下面的例子来演示父进程如何修改子进程的内存:

  • 父进程创建子进程, 并先让子进程 sleep 一段时间以保证父进程能更早运行;
  • 父进程通过 PTRACE_ATTACH 来和子进程建立跟踪关系;
  • 父进程修改子进程的内存数据;
  • 父进程通过调用 PTRACE_CONT 让子进程恢复执行;
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
#include <sys/ptrace.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#define SHOW(call) ({ int _ret = (int)(call); printf("%s -> %d\n", #call, _ret); if (_ret < 0) { perror(NULL); }})

char changeme[] = "This is a test";

int main (void) {
pid_t pid = fork();
int ret;
int i;
union {
char cdata[8];
int64_t data;
} u = { "Hijacked" };

switch (pid) {
case 0: /* child */
sleep(2);
printf("Children Message: %s\n", changeme);
exit(0);

case -1:
perror("fork");
exit(1);
break;

default: /* parent */
SHOW(ptrace(PTRACE_ATTACH, pid, 0, 0));
SHOW(ptrace(PTRACE_POKEDATA, pid, changeme, u.data));
SHOW(ptrace(PTRACE_CONT, pid, 0, 0));
printf("Parent Message: %s\n", changeme);
wait(NULL);
break;
}

return 0;
}
1
2
3
4
5
6
ptrace(PTRACE_ATTACH, pid, 0, 0) -> 0
ptrace(PTRACE_POKEDATA, pid, changeme, u.data) -> 0
ptrace(PTRACE_CONT, pid, 0, 0) -> 0
Parent Message: This is a test

Children Message: Hijacked a test

可以看出子进程中的字符串已经被修改了, 而父进程中的字符串依旧保持不变.

在调用 ptrace(PTRACE_POKEDATA, pid, changeme, u.data) 时, 最后一个参数实际上是按照 int64_t 来处理的.

next

  • NEXT 命令实现的是 C 代码级的单步。

  • 执行 next 命令时, gdb 会计算出当前停止位置的 c 语句的第一条 指令的地址作为 step_range_start ,然后计算出当前停止位置下一 行的 c 语句的第一条指令的地址作为 step_range_end ,随后控制目标程序从当前停止位置开始走指令级单步,直至 pc 超出 step range 为止。

  • next 命令的结束条件: pc < step_range_start || pc >= step_range_end 。

  • 之所以不能简单地判断 pc 是否到达 step_range_end ,是因为 step_range_end 仅仅是 c 源代码意义上的下一行的第一条指令的地址,目标程序实际运行时未必就会到达那里。因此, next 命令的结束条件可以理解为只要 pc 离开当前源代码行即可。

    image-20210527093518978

跨越函数

我们知道 next 是不会进入函数内部的,下面会介绍一下其原理。

next 命令跨越函数调用的过程:

  • 从当前停止位置开始走指令级单步;
  • 走到子函数第一条指令时发现是函数调用,就在函数返 回地址插入一个临时断点;
  • 让目标程序继续运行,通过子函数体,直至遇到之前插入的临时断点;
  • 继续走指令级单步,直至满足 next 命令的结束条件为止。

image-20210527093915896

step 、nexti 、 stepi

  • step 命令和 next 命令一样,也是实现 c 源代码级的单步,对于简单 语句, step 完全等同于 next 。唯一不同的是,若单步过程中遇到函 数调用, step 命令将停止在子函数的起始处,而不是将其跨越(无 调试信息的子函数除外)。
  • nexti 命令实现指令级单步,和 next 命令类似, nexti 命令单步过程中不会进入子函数调用。
  • stepi 命令实现指令级单步,而且是严格的指令级单步,每次直接走 一条指令后即停止,不再区分是否存在函数调用。

finish

finish 命令会让目标程序继续执行完当前函数的剩余语句,并停止在 返回到上一级函数之后的第一条指令处(也就是调用当前函数时的返 回地址)。因此,实现 finish 命令时,只需找到当前函数的返回地址 ,并在该处插入一个临时断点,然后让目标程序继续运行,直至遇到 该断点而停止。

随机信号的处理

对于 gdb 而言,导致目标程序本次停止的信号有随机和非随机之分。 非随机信号是指 gdb 已经预知其会发生或者本身就是 gdb 导致的信 号,也就是说,这些信号是具有明确的调试含义的,比如遇到断点指 令时的 SIGTRAP 。而随机信号则是 gdb 没有预知的、不了解其实际 含义的信号,比如因程序异常而导致的 SIGSEGV ,因定时机制而产 生的 SIGALRM ,或者是用户程序自己内部使用的信号。

对于随机信号, gdb 提供了两个属性来决定对它的处理方式。一个是 当此信号发生时是否停止目标程序的运行,一个是在目标程序因此信 号而停止之后,用户发出继续目标程序运行的命令时,是否将此信号 交付给目标程序。

可通过 info signals 命令查看信号的配置属性,并通过 handle signal 命令来修改信号的属性。

windows

先占个坑。

参考

https://hiberabyss.github.io/2018/04/04/gdb-internal/

https://www.cnblogs.com/xsln/p/ptrace.html

http://www.kgdb.info/wp-content/uploads/2011/04/GdbPrincipleChinese.pdf

https://cloud.tencent.com/developer/article/1742878