Introduction
概览
实验材料内有一个bomb.c
源码和debug模式编译的可执行文件bomb
。
bomb.c
的主要逻辑(伪代码)如下:
#include <stdio.h>
#include <stdlib.h>
#include "support.h"
#include "phases.h"
FILE *infile;
int main(int argc, char *argv[]) {
infile = argc == 1 ? stdin : fopen(argv[1], "r");
char *input;
initialize_bomb();
for (int i = 1; i <= 6; i++) {
input = read_line();
phase_i(input);
phase_defused();
}
return 0;
}
read_line()
会从infile
中依次读取字符行,然后在phase_i
中执行解密,如果解密失败,会在phase_i
中直接exit整个程序,解密成功则phase_defuesd
。
总共有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寄存器的值,那么它就需要在改变之前先将数据入栈保存,并且在函数返回之前还原该寄存器;如果不改变寄存器的值,则不需要保存。
标志寄存器
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
的值减去元素大小。
以函数Q对应的栈帧为例,与%rsp
有关的操作通常是:
push %reg ; 把需要保存的寄存器放在栈中,同时将%rsp向下移对应的位置
push %reg
sub $0x10,%rsp ; 把%rsp向下移,开辟临时空间
; 函数逻辑
add $0x10,%rsp ; 把%rsp向上移,回收临时空间
push %reg
push %reg ; 恢复寄存器值,同时将%rsp向上移对应的位置
也就是说栈帧是靠%rsp
严谨的移动形成的逻辑帧,实际上还是一长串连续的线性空间。
执行完这些指令之后%rsp
指向了Return address
这里,这个Return address
是函数P在执行call Q
指令时,将call Q
这条指令的下一条指令的地址放在这里,意味着函数Q执行完之后,应该接着执行这条指令。在函数Q的最后,会非常配合的执行一条ret
指令,将Return address
放到PC中,以便接下来执行call Q
之后的那条指令。