GDB 的 p 命令实现原理简析 via STRACE
Comment最近用 ptrace 控制进程,想要实现类似 gdb 的表达式求值功能。一开始我沿用了以前写的一个简单的表达式求值功能,用来对各种整型表达式求值,并且为其添加了从字符串到地址的变量解析功能。只不过我写表达式解析这种事情不太在行,特别是表达式会带类型,没能实现结构体成员访问、指针成员访问这些功能。另外,gdb 的表达式求值还有一个很强的功能:解析函数符号并执行函数。
1 | // foo.c |
对于这样的一段 C 程序,使用 -g
编译的话,用 gdb 调试将会产生如下的效果:
1 | (gdb) start |
进一步地,如果程序是动态链接的话,还可以直接调用 printf:
1 | (gdb) printf("%x\n", 114514) |
注意:如果不输出换行的话,在控制台模式下输出有可能会被放在 stdout
的 buffer 中,从而一时看不到。
如果是静态链接的话,foo.c 就不会包含 printf
符号,从而导致:
1 | (gdb) p printf("%x\n", 114514) |
因为调用的 printf("hahaha\n")
会被优化为 puts("hahaha")
。关于这个优化,请参见 clu2’s notes: How GCC generates optimized code for printf (and GCC built-in functions)
使用
-fno-builtin
可以取消此类优化。
1 | 0000000000401126 <foo>: |
still,这样的调用是可以设置断点的。只不过这样的话,求值会在断点处失败。
1 | (gdb) b write |
p
还有一个重要的方面就是能够保留表达式计算产生的所有副作用。从上面 printf
将输出写入到 stdout
的 buffer 这点即可略窥一二。事实上,p
可以对所有的合法表达式进行求值,比如变量赋值表达式 a = 1
。作为结果,会输出 1,并将这个值直接赋给进程中的那个变量。
机制:猜测
实现一般的表达式求值,总的来说可能有两类实现方式:
1、对于纯表达式的无函数调用求值,可以在 gdb 中解析表达式,做求值。通过解析二进制调试信息获得变量地址、字段偏移量、变量类型。通过 ptrace/procfs 获取进程实时信息,读写内存/寄存器实际值。
2、对于所有类型的表达式,都可以分析表达式类型,编译 wrapper 求值函数,注入到 tracee 中,并控制 tracee 执行注入代码求值。
当我觉得 2 似乎是更方便的(写起来一点也不比 1 方便)时候,我还是老老实实去尝试了解 gdb 的真实做法了。
机制:分析
此处我并不想分析 gdb 的源码,因为我读代码的能力一直都很差。因而,寄希望于让程序跑起来,并找机会观测其行为。所幸我们有 strace
这样成熟的工具。
首先运行 gdb ./foo,start,然后输入好函数求值的命令 p main()
。这样在输入回车之前,gdb 不会产生任何其他的系统调用而是卡在 read 上。
接着:
1 | sudo strace -p $(pidof gdb) |
sudo
似乎是必要的。然后我们在 gdb 那里点回车。这让我们能够观察到 gdb
运行这一条命令的时候进行的全部系统调用,一共有 200+ 条。
当一个进程卡在 gdb 给的 TRAP 之后,如果想要对函数求值,那就只能通过执行 tracee 自身的那个函数了。因而 gdb 一定需要某种办法让进程恢复执行,那么最明显的就是 ptrace(PTRACE_CONT)
。在输出的系统调用中,果不其然找到了它的影子:
1 | ptrace(PTRACE_CONT, 3926087, 0x1, 0) = 0 |
合理推测,它的前面一定是将 tracee 的状态设置到适合进行函数调用的状态的过程;而后面则是收集函数调用结果,以及将状态恢复到调用前的过程。由于它会保留调用所有的副作用,因而这个状态的恢复,仅仅是恢复寄存器上下文。
去除掉一些无关的调用之后,来看核心部分:
1 | ptrace(PTRACE_GETSIGINFO, 3926087, NULL, {si_signo=SIGTRAP, si_code=SI_KERNEL, si_addr=NULL}) = 0 |
通过查看 /proc/$(pidof gdb)/fd/14
的符号链接,可以看到这个文件指向的是 /proc/$(pidof foo)/task/$(tidof foo)/mem
。因而,pwrite/pread
就都是直接读写 tracee 的内存了。推测到,由于要恢复一部分状态,因而 pread
是对当前状态的保存,而 pwrite
是改写。未来在执行完成后,这些读取的状态一定还是会被写回的。
首先第一个 ptrace
就 get 到 tracee 进入了 SIGTRAP。在此基础上,进行了两个写入:
1 | pwrite64(14, "\314", 1, 140737488348431) = 1 |
\314
其实就是我们熟悉的 int 0x3 (0xcc)
。140737488348431 = 0x7fffffffe50f
,可以看出这是写在了栈上。
第二个则是向 0x7fffffffe4f8
,也是栈上的位置写入了一个地址 0x7fffffffe50f
。
接下来通过一系列的 ptrace(PTRACE_GETREGS/PTRACE_SETREGS)
,可以看到最终的寄存器状态是这样的:
1 | ... |
此时,RIP
刚好落在 main
的入口,而 RSP
和 RBP
都落在了刚才写入地址的位置上。可以想见,如果接下来开始执行的话,等到函数退出时,RAX
将会代表函数返回值,并且函数返回地址正好在 RBP
的位置写着,跳转之后,等待程序的会是一个 0xcc。
只不过,这个 code 真的被执行了吗?并没有。众所都周知,栈区一般都是 prot = PROT_READ | PROT_WRITE
,而通常不会是可执行的。事实上,我是在看了 PTRACE_CONT
之后的输出才明白了这一点的。
1 | --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_TRAPPED, si_pid=3926087, si_uid=1000, si_status=SIGSEGV, si_utime=0, si_stime=0} --- |
这等于说,如果栈区可执行,那么迎接进程的是 0xcc (SIGTRAP);如果不可执行,就会直接产生一个 SIGSEGV。
不过这样的话,进程不会直接崩溃吗?在 ptrace 下,是 gdb 先收到 SIGCHLD,标志着子进程状态变化。gdb wait 获得了子进程的 STOPSIG 后,才决定是否发送给进程。进程本身没有收到这个信号,因而不会执行 SIGSEGV 的 SIG_DFL 默认信号处理函数。
实际上,也许我们也能强行通过注册信号处理函数,给触发了 SIGSEGV 的进程续命。不过这就需要使用 rt_sigprocmask
或是 siglongjmp
这类办法了。
往后就是恢复进程上下文了。
当然,对于纯表达式的求值,gdb 的确采用了第一种办法,即当场解析表达式并且通过读进程内存来进行计算。