cover

CSAPP Bomb Lab - 前言

这个Lab对应书的第三章,章节主要内容是x86-64体系下的汇编语言、运行时栈和CPU寄存器。

解决这个Lab需要使用gdb调试工具深入程序的执行过程,通过分析程序执行过程中CPU寄存器的状态变化,一步步求解出炸弹密码。读者必须动手了解指令执行时的寄存器和堆栈的细节,比正向写汇编代码的效果好得多。

实验材料可以在这里下载

主程序逻辑

实验材料内有一个bomb.c源码和debug模式编译的可执行文件bomb

bomb.c的主要逻辑(伪代码)如下:

 1#include <stdio.h>
 2#include <stdlib.h>
 3#include "support.h"
 4#include "phases.h"
 5
 6FILE *infile;
 7
 8int main(int argc, char *argv[]) {
 9    infile = argc == 1 ? stdin : fopen(argv[1], "r");
10    char *input;
11    initialize_bomb();
12
13    for (int i = 1; i <= 6; i++) {
14        input = read_line();
15        phase_i(input);
16        phase_defused();
17    }
18
19    return 0;
20}

read_line()会从infile中依次读取字符行,然后在phase_i中执行解密,如果解密失败,会在phase_i中直接exit整个程序,解密成功则phase_defused

总共有6个bomb,所以我们可以先准备一个answer.txt,在里面写入6行test,避免每次都要手动输入答案。

由于核心的解密函数phase_i没有提供源码,所以我们需要使用gdb调试工具逆向分析这6个解密函数,找到正确的答案。

我们可以使用objdump bomb -d > bomb.s导出整个可执行文件的汇编代码,方便后续查看。

在开始之前,我们还需要了解x86-64架构的寄存器和运行时栈知识。

寄存器

我们主要接触两类寄存器:通用寄存器和标志寄存器。

通用寄存器

通用寄存器用于存储整数数据和指针。一个计算机系统是64位还是32位,指的就是这些寄存器的位数。

在x86架构(32位)中,通用寄存器有8个,标号从%eax%esp。扩展到x86-64(64位)之后,这8个寄存器的标号也换成了从%rax%rsp,并且添加了从%r8%r15这8个寄存器。

所以x86-64架构共有16个64位的通用寄存器:

这些寄存器是兼容32位的程序的,只需要在汇编代码中使用低32位对应的名字即可。

Caller saved & Callee saved:这涉及到寄存器数据一致性问题。CPU并不保证某个通用寄存器在调用函数前被调用函数执行后的值保持一致,因为被调用函数可能也会使用这些寄存器。所以需要保持寄存器数据一致时,就需要调用者或者被调用者暂存(存到内存中)并恢复这些寄存器。Caller saved & Callee saved就是约定这些寄存器由谁负责保持数据一致性。

1st argument - 6th argument:函数间默认使用这些寄存器进行参数传递,在函数中看到对%rdi进行操作时,要立马明白这是对第一个入参进行操作。超过6个参数的函数,其余参数使用栈进行传递。

标志寄存器

x86-64中的标志寄存器是一个64位的寄存器,用不同的bit表示不同的状态。常用的bit如下:

  • CF (Carry Flag): (位 0) 进位标志。在无符号运算中指示最高位是否发生进位 (加法) 或借位 (减法)。
  • ZF (Zero Flag): (位 6) 零标志。如果操作结果为零,则置位;否则清零。这是最常用的标志之一。
  • SF (Sign Flag): (位 7) 符号标志。指示结果的最高位。对于补码表示的有符号数,这表示结果的正负。
  • OF (Overflow Flag): (位 11) 溢出标志。在有符号运算中指示结果是否溢出 (超出带符号表示范围)。如果 SF 和 OF 的值不同,表示有符号溢出发生。

运行时栈

众所周知,Linux运行一个程序时,会把这个程序所用的内存分为5个部分:代码区、数据区、BSS区、堆区和栈区。

栈区与程序运行息息相关。程序都是被组织成一个个函数(若干条连续的指令)的,每次执行函数时,都会在栈区形成一个栈帧(Stack Frame),用于保存函数的执行过程中的局部变量。

栈帧的组织方式如下图:

栈的方向是向低地址方向增长的,这么设计的好处是栈顶元素的首地址就是%rsp的值,而不是%rsp的值减去元素大小。

以上图为例,在函数P开始执行时,%rsp向下移动,为函数P开辟一个临时空间用于保存临时变量,随着函数P的执行,%rsp可能也会随之变动。在调用函数Q时,会使用机器指令call,这个指令大概做了三件事:%rsp下移8字节(一个指针变量大小);将函数P的下一条指令(执行完函数Q之后应该执行的那条指令)的地址放入%rsp指向的位置;将函数Q的第一条指令的值写入PC(Program Counter)。

由于PC总是指向下一条要执行的指令,因此CPU接下来就会开始执行函数Q的指令。函数Q的前几条指令,会将%rsp下移若干个字节,为自己开辟一个新的临时空间。在函数Q执行完之后,%rsp会重新指向图中Return address(下方那条粗线)的位置。

而函数Q的最后一条指令ret,先把%rsp指向的内存块的值也就是函数P下一条机器指令的地址压入PC,然后把%rsp上移8个字节,完全恢复到call指令执行之前的状态。这样在CPU的下一个时钟周期,就会继续执行函数P的指令。

也就是说栈帧是靠%rsp严谨的移动形成的逻辑帧,实际上还是一长串连续的线性空间。这些逻辑帧总是先进后出的(函数Q的帧的销毁总会早于函数P的帧),因此称之为栈。

目录

在接下来的几篇文章中,我们会学习gdb的基本用法并一一解开这些炸弹。

第一个炸弹:汇编与gdb基本用法