概览
压缩包内有三个文件:没什么用的README
,可执行文件bomb
和源代码bomb.c
。当然这个源代码只包含main()
函数,主要的功能函数需要gdb调试进行分析。
main()
函数的主要逻辑很简单:根据入参判断是否从文件读取密码;初始化炸弹;依次读取密码并判断密码是否正确(共6个)。
FILE *infile;
int main(int argc, char *argv[]) {
/* When run with no arguments, the bomb reads its input lines
* from standard input. */
if (argc == 1) {
infile = stdin;
}
/* Do all sorts of secret stuff that makes the bomb harder to defuse. */
initialize_bomb();
/* Hmm... Six phases must be more secure than one phase! */
input = read_line(); /* Get input */
phase_1(input); /* Run the phase */
phase_defused(); /* Drat! They figured it out!
* Let me know how they did it. */
printf("Phase 1 defused. How about the next one?\n");
/* 后面的五个炸弹也是相同的逻辑 */
}
需要说明的是,argc
是命令行参数的个数,argv
是一个字符数组指针,数组中的每个指针指向参数字符串。在当前目录下以./bomb
命令执行这个可执行文件时,arg == 1
,argv[0]
指向"<prefix>/bomb"
,可执行文件路径会补全成绝对路径。
反汇编
开始之前可以先用objdump
命令查看一个程序的汇编代码大概长什么样子:objdump bomb -d > bomb.s
,这样可以把反汇编得到的汇编代码输入到bomb.s
这个文件方便查看。-d
选项表示只解析代码段。
|
|
从bomb.s
的第264行开始才是main
函数的汇编代码,前面的都是一些操作系统入口函数和标准库函数,也就是说main
函数是主程序的入口,但是不是整个可执行文件的入口。
文件结构很好理解,400da0
(十六进制)是指令的地址,后面是十六进制表示的指令内容,再后面是指令的汇编助记符和注释。
push %rbx
这条指令中,push
为指令操作码,%rbx
为操作数,指令总长为一个字节(两位十六进制),所以下一条指令的地址是400da0 + 1
。注意,0x53
是push %rbx
这条指令,而不只是push
这个操作码;同样是push
操作码,push %rbp
的内容就是0x58
。
cmp $0x1,%edi
这条指令的长度为三个字节,表示将立即数0x1
和寄存器%edi
的内容作比较。这个寄存器表示函数的第一个参数,所以这条指令就与if (argc == 1)
这行C代码对上了。
使用gdb调试得到的内容和bomb.s
的内容是一样的,我们会在后面的分析过程中学习这些指令的具体作用。
下面需要了解一下x86-64架构下的运行时栈和CPU寄存器。
运行时栈
在程序运行时,会有两个指针在内存中卡出来一个区域,给当前的函数存储临时变量用。第一个指针在函数执行过程中是静态的,表示这段内存开始的地方,而第二个指针是动态的,总是指向下一个可用的内存字节,当前函数往内存中存储临时变量时,第二个指针也会跟着变化。
当发生函数调用时,当前函数的第二个指针的内容就变成了被调用函数的第一个指针的内容,随着被调用函数往内存中存储临时变量,被调用函数的第二个指针也会动态变化。这样就划分出了一个新的内存段。以此往复。
而被调用函数总是比当前函数先执行完成,所以这些内存段符合先进后出的逻辑,故而谓之栈。这些内存段也被叫做栈帧。
寄存器
x86-64架构共16个通用整数寄存器,如下图:
**位数:**由于不是所有的数据都需要64位来保存,所以用不同的名字来表示不同的位数。例如前面提到的%edi
,
Caller saved & Callee saved:这涉及到寄存器数据一致性问题。CPU并不保证某个通用寄存器在调用函数前和被调用函数执行后的值保持一致,因为被调用函数可能也会使用这些寄存器。所以需要保持寄存器数据一致时,就需要把寄存器的值保存在栈中,被调用函数返回时再恢复到寄存器中。
如果当前函数需要在调用其它函数之后继续使用Caller saved寄存器的值,那么它就有义务在调用其它函数之前保存这些寄存器的值,负责其数据一致性;如果后续不再使用,也就不用保存。相对的,如果被调用函数需要改变Callee saved寄存器的值,那么它就需要在改变之前先将数据入栈保存,并且在函数返回之前还原该寄存器;如果不改变寄存器的值,则不需要保存。
把寄存器分成这两类,可以使编译器制定比较好的寄存器分配策略,减少访存次数,提高程序运行效率。
除此之外还会用到eflags
寄存器,它是运算标记寄存器,会根据当前运算结果更新,具体如下图:
GDB调试
使用过程如下。
先执行命令gdb bomb
,进入GDB环境,此时程序不会自动执行。然后打断点或者单步执行,在到达断点或者单步执行之后,可以停下来查看寄存器和内存的数据,或者反编译某个函数进行手动模拟。
下面是一些常用命令示例。
控制执行:
命令 | 说明 |
---|---|
run |
开始运行程序,直到遇到断点或程序结束。 |
continue |
继续运行程序,直到遇到断点或程序结束。 |
finish |
执行到当前函数结束。 |
setpi [<num>] |
执行<num> 条指令,缺省为1。 |
nexti [<num>] |
执行<num> 个函数,缺省为1。 |
break <func> 或b <func> |
在函数<func> 上设置断点。 |
break * <addr> 或b * <addr> |
在地址<addr> 上设置断点。 |
delete / delete <num> |
删除全部断点。/ 删除标号为<num> 的断点。 |
quit |
退出GDB环境。 |
查看代码/数据:
命令 | 说明 |
---|---|
disas [<func>] |
反汇编<func> 函数,缺省为当前函数。 |
print $rip |
输出PC寄存器的值(十进制)。 |
print /x $rax |
输出%rax 的值(十六进制)。/t 则表示二进制。 |
pint /x *(long *) <addr> |
输出从地址<addr> 开始的八个字节的内容(十六进制)。 |
pint /t *(char *) ($rsp + <bias>) |
输出从地址%rsp + <bias> 开始的一个字节的内容(二进制)。 |
x /s <addr> |
以字符串形式检查内存中从<addr> 开始的内容(即直到遇到\0 )。 |
查看信息:
命令 | 说明 |
---|---|
info stack 或info s |
查看栈信息 |
info frame 或info f |
查看当前栈帧信息 |
info registers 或info r |
查看全部寄存器信息 |