一点声明
原文作者: http://eli.thegreenplace.net/
原文链接: http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints/


译者注:

上篇译文的附件运行总是会segfault. 调试了很久之后,我发现bug在Tracer.c的第39行data.eip = (long) begin; 应该将它修改为data.eip = (long) begin+2; ,这里作者希望eip指向shellcode的首地址,然而,这是错误的.因为如果ptrace中断了一个系统调用,那么在PTRACER_DETACH时,内核会自动将eip-2,以此重新产生系统调用.修改过的代码理论上已经没有问题了,然而如果让它ptrace另外一个进程,仍然会杯具的segfault.可是,很奇怪的,如果用修改过的程序ptrace它的子进程(子进程与上面提到的被跟踪进程代码一样),就不会segfault了,输出是Oh,caught.(这是放在char *mesg的那一堆16进制数的输出结果,个人觉得,mesg里面的数据其实是一个输出字符串"Oh,caught"的汇编程序的二进制码,它是由objdump对这个汇编程序编译而成的二进制可执行文件做objdump -d输出得到的,而且,这里的汇编代码应该用到了由forward保存字符串,然后forward和backward之间jmp的trick,以此拿到字符串"Oh,caught"的地址.再次强调,个人推测,勿跨省)..ptrace一个原生进程会segfault,而ptrace子进程就可以运行得很好,这很诡异,对此我没有什么头绪.

这一篇文章介绍了如何在被跟踪进程设置断点,有点难度,但是比起上篇译文,却又有所不及了,因为上篇译文介绍的是如何将代码注入被跟踪进程,这里涉及到的知识更加底层(需要对eip和esp,ebp有充分的了解).

translated by Arthur1989, 个人主页: https://godorz.info


这是本系列的第2篇How debuggers work: Part 2 – Breakpoints,在读这篇文章之前,请先确定你读过了part 1.

In this part

我会展示调试器中的"断点"是如何实现的.作为调试器的两大杀手级功能(另一个是查看进程内存),尽管在part 1中对断点我们已经有了一个初步的了解,但它还是太神秘了.不过,没关系,相信在读过本文之后,浮云终将不能蔽日.

Software interrupts

在x86上实现断点,需要使用软件中断(software interrupts,也叫trap).在埋头细节之前,我想对中断的概念做个大体的介绍.

CPU是线性执行每条机器指令的[1].对于非同步事件(asynchronous events),比如说IO和硬件计时器,CPU需要中断的协助.硬件中断往往是一个发往特殊"响应装置"(special "response circuitry")的专用电子信号.响应装置捕捉到中断,使CPU暂停并保存它的状态,然后跳转到预定义中断处理程序(handler routine for the interrupt)地址,当中断处理程序结束时,CPU从它被中断的地方恢复运行.

软件中断理论上和硬件中断类似,但是在实现上,还是有所不同的.CPU支持使进程中断的特殊指令,当这类特殊指令被执行时,CPU把它看成是一个中断.这些中断(或称陷阱traps)造就了现代操作系统的很多神话(任务调度task scheduling, 虚拟内存virtual memory, 内存保护memory protection, 调试debugging).

某些编程错误(比如说除以0) -- 通常叫做异常(exception),也被CPU当做是中断,在这种情况下,因为我们很难说,到底异常是硬件中断呢,还是软件中断 -- 它们的界限变得模糊了.好吧,我已经离题千里了,让我们回到断点.

int 3 in theory

有了前面的知识作为背景,我终于可以对断点做出说明了,它是由CPU执行一个特殊的指令(int 3)实现的.所谓int,行话是x86上中断指令,意即对预定义中断处理程序的调用.x86用一个8位的运算数来标识中断号,所以理论上一共有256条中断指令.前面的32条指令由CPU专用,其中,3号中断指令 -- 调试器中断(trap to debugger)是我们需要的.

不啰嗦了,拜读圣经[2]:

The INT 3 instruction generates a special one byte opcode (CC) that is intended for calling the debug exception handler. (This one byte form is valuable because it can be used to replace the first byte of any instruction with a breakpoint, including other one byte instructions, without over-writing other code).

INT 3指令是个一字节的操作码(CC),它被用来调用异常调试程序(debug exception handler).(这个"一字节"弥足珍重,它能覆盖任何一条指令的前8位,而不篡改其他位,以此我们可以放置一个断点,或者是其他的一字节操作码.)

括号里面的内容相当重要,但是现在解释还是为时过早了,在文章后半部分我们再做回顾.

int 3 in practice

理论我们已经谈得太多了,但是,它们到底意味着什么呢?我们如何使用int 3实现断点呢?Talk is cheap,show me the codes!

其实,实践起来还是很简单的.一旦进程执行了int 3指令,OS就会暂停它[3](Linux发给进程一个信号SITTRAP).

就这样!!回忆一下part 1,跟踪进程可以获得它的子进程(或者被跟踪进程)接收到的所有信号,嗯,如今你总该摸清下一步的方向了吧.

讲解结束,没有什么***101招之类的把戏.是时候举个例子了.

Setting breakpoints manually

我得秀一秀在程序中插入断点的代码,首先看看目标程序:

section    .text
    ; The _start symbol must be declared for the linker (ld)
    global _start

_start:

    ; Prepare arguments for the sys_write system call:
    ;   - eax: system call number (sys_write)
    ;   - ebx: file descriptor (stdout)
    ;   - ecx: pointer to string
    ;   - edx: string length
    mov     edx, len1
    mov     ecx, msg1
    mov     ebx, 1
    mov     eax, 4

    ; Execute the sys_write system call
    int     0x80

    ; Now print the other message
    mov     edx, len2
    mov     ecx, msg2
    mov     ebx, 1
    mov     eax, 4
    int     0x80

    ; Execute sys_exit
    mov     eax, 1
    int     0x80

section    .data

msg1    db      'Hello,', 0xa
len1    equ     $ - msg1
msg2    db      'world!', 0xa
len2    equ     $ - msg2

为了避免使用C语言所带来的复杂的编译过程和一大串的符号表,这里我们使用汇编语言,这个程序打印"Hello,",然后在下一行打印"world!",这跟part 1的例子是很类似的.

假设我们希望在第一次输出之后,在第2次输出之前插入断点,也就是在第一个int 0x80 [4]之后,在mov edx, len2之前.首先我们需要知道这条指令(mov edx, len2)被映射到虚拟内存何处,objdump -d:

traced_printer2:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000033  08048080  08048080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         0000000e  080490b4  080490b4  000000b4  2**2
                  CONTENTS, ALLOC, LOAD, DATA

Disassembly of section .text:

08048080 <.text>:
 8048080:     ba 07 00 00 00          mov    $0x7,%edx
 8048085:     b9 b4 90 04 08          mov    $0x80490b4,%ecx
 804808a:     bb 01 00 00 00          mov    $0x1,%ebx
 804808f:     b8 04 00 00 00          mov    $0x4,%eax
 8048094:     cd 80                   int    $0x80
 8048096:     ba 07 00 00 00          mov    $0x7,%edx
 804809b:     b9 bb 90 04 08          mov    $0x80490bb,%ecx
 80480a0:     bb 01 00 00 00          mov    $0x1,%ebx
 80480a5:     b8 04 00 00 00          mov    $0x4,%eax
 80480aa:     cd 80                   int    $0x80
 80480ac:     b8 01 00 00 00          mov    $0x1,%eax
 80480b1:     cd 80                   int    $0x80

可以看到,断点所在位置为0×8048096.等等,有点不对劲啊,调试器都是在源代码中插入断点的,哪有这样赤裸裸的直接往内存地址插断点的?好吧,被你知道了,你的怀疑完全正确,只是,我们差的还太远了 -- 要像真实的调试器一样在代码中插入断点,只怕我们马上会被符号表和调试信息搞晕的,so,这个留给本系列的下几part来实现吧.当下,我们还是先搞定最基本的内存地址吧.

额,在这里我又要偏题了.如果你对为什么地址会是0×8048096,这个地址又意味着什么实在感兴趣的话,可以读读下面的Digression – process addresses and entry point.不然的话,直接跳过.

Digression – process addresses and entry point

老实说,0×8048096代表的东西很少,它离二进制文件的.text section不过几个字节罢了.(译注: 可以对着此图参考这里).仔细研究一下上面的dump输出,我们可以看到text段从地址0×08048080开始,这说明OS把ELF文件中的.text section映射到此地址开始的虚拟内存空间中.在Linux上,这个地址是独立的(也就是说,当可执行文件被装载到内存时,它不可重定位.)(译注: 自行google -fPIC),因为虚拟内存机制使得每个进程看到的是整个32位空间(故称线性地址).

用readelf看看ELF头部[5]:

$ readelf -h traced_printer2
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x8048080
  Start of program headers:          52 (bytes into file)
  Start of section headers:          220 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           40 (bytes)
  Number of section headers:         4
  Section header string table index: 3

留意"entry point address"(入口地址),它也指向0×8048080.OS把ELF头部信息解读为:

1. 把text section映射到0×8048080起始的内存中.
2. 从0×8048080开始执行

但是,为什么一定得是0×8048080呢?好吧,这就是所谓的历史遗留问题.每个进程空间的前128M是被栈私用的.128MB = 128 * 1024 KB = 128 * 1024 * 1024B = 2^27B,正好就是0×8000000,可执行文件的其他section可能就从这里开始被映射.特别的,0×8048080是Linux下ld链接器的入口地址,它可以通过传给ld的参数-Ttext修改.

小结一下,入口始址0×8048080没有什么奇异之处,我们可以自由的修改它.只要ELF文件格式是正确的,而且ELF头部中的入口地址跟程序的text section起始地址相符,那就没有问题.

Setting breakpoints in the debugger with int 3

在被跟踪进程的某个特定地址插入断点,跟踪进程需要:

1. 记住目的地址的数据
2. 将目的地址的第一个字节修改为int 3指令

然后,跟踪进程通知OS恢复被跟踪进程的运行(通过PTRACE_CONT),被跟踪进程一直运行,直到执行int 3指令后,OS向它发送一个信号使之再一次暂停.这时,跟踪进程也将接收到一个表示子进程(或被跟踪进程)已经暂停的信号,再一次插手:

1. 将目的地址对应的int 3指令恢复为原来的数据
2. 回滚EIP(减1).因为执行int 3指令后,EIP将指向它的下一条指令
3. 与被跟踪进程做些其他交互,因为这时被跟踪进程仍然在目的地址处暂停.调试器也就是在这时告诉你变量值,调用栈等等的.
4. 这时候如果用户希望被跟踪进程继续运行,那么跟踪进程将在目的地址重新设置断点(在step 1被移除了),除非用户明确要求取消断点.

我们看一下这些步骤是如何被转化为代码的吧.我们将借鉴part 1中的模板(fork一个子进程并跟踪它).完整的源代码请见后文.

/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child started. EIP = 0x%08x\n", regs.eip);

/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);

跟踪进程读出被跟踪进程的eip寄存器值,保存0×8048096对应的第一个字节.如果我们跟踪上文列出的汇编程序,输出如下:

[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba

So far, so good.接下来:

/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);

/* See what's there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);

注意int 3是怎样被插入目的地址的.输出为:

[13028] After trap, data at 0x08048096: 0x000007cc

正如我们期待的,0xba被修改成0xcc.跟踪进程这时让被跟踪进程继续运行,直到它在断点处暂停:

/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);

wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
    procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
}
else {
    perror("wait");
    return;
}

/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);

输出如下:

Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097

可以看到,"Hello,"在断点之前输出,而且这时候,被跟踪进程在执行int 3后立刻暂停.

最后,根据上文的解释,要让被跟踪进程继续运行,我们需要将中断指令恢复成原来的指令.

/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
*/
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, &regs);

/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);

被跟踪进程输出"world!",然后退出.

注意,在这里我们没有重新设置断点.如果要重新设置的话,我们让被跟踪进程单步执行(PTRACE_SINGLE)0x08048096对应的原来执行,然后插回断点,并让被跟踪进程继续运行(PTRACE_CONT).下文的debug库实现了这个功能.

More on int 3

让我们温习一下int 3和它古怪的说明:

This one byte form is valuable because it can be used to replace the first byte of any instruction with a breakpoint, including other one byte instructions, without over-writing other code.

x86上的int指令占据两个字节 –- "0xcd 中断号"[6].如果你讲int 3指令直接code成cd 03,那就上当啦,哈哈.因为在x86的实现上,int 3对应的是单字节指令0xcc.

诡异,为什么会这样呢? 因为只有单字节才允许我们不篡改其他指令地插入断点,再次强调,这是很重要的.考虑下面的样例:

    .. some code ..
    jz    foo
    dec   eax
foo:
    call  bar
    .. some code ..

如果我们想要在dec eax对应的地址插入断点.dec eax的机器码正好是一个字节的(0x48),如果插入的断点机器码超出了一个字节,那么dec eax的下一指令(call)将不可避免的被篡改, 这会让程序产生不可预料的错误.

将int 3设计成单字节指令就是为了解决这个问题的.因为x86上的指令最短为一个字节,所以,我们可以确定只修改了目的指令.

Encapsulating some gory details

为了省事,我把上文提到的龌龊底层和细节封装到API中,命名为库debuglib.接下来就看展示吧.

Tracing a C program

目前,我们仅仅跟踪过汇编程序.是时候跟踪C程序了.

出乎意料的,其实这也没有什么不一样的 -- 只是找出断点的地址有点困难而已.考虑这个简单的程序:

#include <stdio.h>

void do_stuff()
{
    printf("Hello, ");
}

int main()
{
    for (int i = 0; i < 4; ++i)
        do_stuff();
    printf("world!\n");
    return 0;
}

在函数do_stuff的入口我们想要插入一个断点.借助我们的老朋友 -- objdump,我们可以很方便地反汇编.因为text段包含了很多C库初始化代码,我们对它没有什么兴趣.还是直接搜索do_stuff吧:

080483e4 :
 80483e4:     55                      push   %ebp
 80483e5:     89 e5                   mov    %esp,%ebp
 80483e7:     83 ec 18                sub    $0x18,%esp
 80483ea:     c7 04 24 f0 84 04 08    movl   $0x80484f0,(%esp)
 80483f1:     e8 22 ff ff ff          call   8048318

 80483f6:     c9                      leave
 80483f7:     c3                      ret

OK,do_stuff入口地址是0×080483e4,它对应着do_stuff函数的第一条指令.用debuglib库实现跟踪进程如下:

void run_debugger(pid_t child_pid)
{
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(0);
    procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid));

    /* Create breakpoint and run to it*/
    debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
    procmsg("breakpoint created\n");
    ptrace(PTRACE_CONT, child_pid, 0, 0);
    wait(0);

    /* Loop as long as the child didn't exit */
    while (1) {
        /* The child is stopped at a breakpoint here. Resume its
        ** execution until it either exits or hits the
        ** breakpoint again.
        */
        procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid));
        procmsg("resuming\n");
        int rc = resume_from_breakpoint(child_pid, bp);

        if (rc == 0) {
            procmsg("child exited\n");
            break;
        }
        else if (rc == 1) {
            continue;
        }
        else {
            procmsg("unexpected: %d\n", rc);
            break;
        }
    }

    cleanup_breakpoint(bp);
}

我们用create_breakpoint, resume_from_breakpoint和cleanup_breakpoint来避免直接修改EIP和目标进程的内存.看一下输出吧:

$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run 'traced_c_loop'
[13363] child now at EIP = 0x00a37850
[13363] breakpoint created
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
world!
[13363] child exited

Just as expected!

The code

这里是这篇文章的完整源代码.这个归档文件包括:

* debuglib.h and debuglib.c – 封装了跟踪进程的一些功能.
* bp_manual.c – 手工插入断点,它调用debuglib库的某些函数.
* bp_use_lib.c – 大部分代码调用了debuglib库,正如我们刚刚展示的那样.

Conclusion and next steps

我们已经介绍了断点是如何实现的.然而,在不同的OS间,实现细节是不可能一样的.不管怎么样,只要你的硬件是x86,那么主要工作就是大同小异的 – 只需要将int 3插入目的地址就可以了.

我知道有些读者对直接在内存中插入断点提不起什么兴趣,其实我也一样,哈哈!!!我们想要的是,仅仅给出命令"在函数do_stuff中中断",甚至是"在函数do_stuff的第*行中断",然后调试器就帮我们实现了.我得说,这很难,不过在下一篇文章中我会展示这是怎么实现的.

References

我觉得这些资源还不错的:

* How debugger works
* Understanding ELF using readelf and objdump
* Implementing breakpoints on x86 Linux
* NASM manual
* SO discussion of the ELF entry point
* This Hacker News discussion 此系列第一部分
* GDB Internals


注:

[1] 在一个更高的层次上看,这是对的.可是,在底层,今天有很多CPU开始并行的执行指令,而且,有些指令还是乱序的.
[2] 自然的,这里的圣经是 Intel’s Architecture software developer’s manual, volume 2A.
[3] OS怎样像这样暂停进程呢? OS自己注册了对int 3的处理程序,就是这么实现的!
[4] 不会吧,还来啊? 那是! Linux使用int 0x80来实现系统调用,以此让进程从用户态进入内核态.用户把中断号和参数写入寄存器,然后执行int 0x80.这时,CPU跳到对应的中断处理程序 -- 它查看寄存器,然后决定应该使用哪个系统调用.
[5] ELF (Executable and Linkable Format) 是Linux上object files, shared libraries 和 executables的通用格式.
[6] 细心的读者可能已经发现,在dump输出中,int 0x80的机器码就是cd 80.