Skip to content

Lab2: BombLab

字数
6691 字
阅读时间
27 分钟

Deadline: 2025-10-30 23:59:59

1. 实验概述

本实验为 CSAPP 第三章配套实验。

这是一款全新的代码世界冒险游戏,相比 csapp 的经典款BombLab有着更高的可玩性。在这座即将走向末路的“二进制城市”中,你将扮演一位精通汇编语言/ linux / GDB / 数据结构的幸存者,通过自己的智慧与胆量发现隐藏在混乱的 x86 汇编背后的真相......

  • 你的核心目标:尽力找出二进制炸弹bomb++所有关卡的口令,拆除炸弹无伤通关。

前排温馨提醒:本实验任务量可能较大,请尽早开始。

IMPORTANT

点击 此链接 领取作业


游戏中你可能的身份
0 engineer
1 professor
2 undergraduate
3 commander
4 spy
5 soldier
6 wanderer
7 pacifist

2. 实验组成

2.1 互动式GDB教程

注:本部分仅起到让大家快速上手 GDB 的教学作用。不占分,也不要求必须完成。

教程位于 ./gdb-tutor 目录下。

  • gdb-tutor:一个旨在帮助各位学习和练习使用 GDB 的可执行文件。侧重于实验所需的调试技巧。
  • gdb-tutor.c:与 gdb-tutor 可执行文件对应的源代码。可以选择边阅读该 .c 源码中的文字和提示,边在终端与可执行文件交互。

使用方法: 直接在 ./src/gdb-tutor 目录下执行命令:

shell
 ./gdb-tutor

2.2 实验主体:二进制炸弹 bomb++

实验主体位于 ./bomb 目录下

bomb++ 是一个包含多个关卡的可执行文件。其核心机制为:输入正确口令→通过关卡;错误口令→炸弹爆炸。

你的目标是使用各种分析手段,找到每个关卡对应的通关口令并运行 bomb++ 输入它们,尽可能多地拆除阶段直至无伤通关。

其他实验文件

  • main.cpp:
    • 实验主程序的源代码。由于敌人没收了炸弹的具体实现方法,故单独的 main.cpp 并不能直接运行;但你可以阅读 main.cpp 大致获取本实验代码的整体流程,或许能够为拆除炸弹提供一些帮助。

3. 实验前准备

3.1 环境配置

i. 拉取实验仓库

推荐使用ssh方式克隆仓库(想必你已经熟悉这一点)。

shell
git clone git@github.com:ICS-25Fall-FDU/lab2-bomblab-xxx.git # 将xxx替换为自己的用户名

cd lab2-bomblab-xxx

ii. 安装和配置 GDB

在 Ubuntu 环境中安装 GDB 非常简单:

shell
# 更新软件包列表
sudo apt update

# 安装gdb
sudo apt install gdb

iii. 按需安装 pwndbg 插件

本部分不作要求,主要起到告知同学们 pwndbg 的存在的作用,并进行一定程度上的推荐与劝退。如果你赶时间,或许可以跳过这部分。

pwndbg 是一个专为二进制安全和逆向工程设计的 GDB 增强插件。它让原本朴素的 GDB 变得可视化、自动化,并且对黑客和安全研究员极其友好。读完下面的简介,如果对 pwndbg 感兴趣,可以自行上网查阅其安装与配置方法。

“pwndbg” 这个名字来源于黑客术语 pwn(意为“攻破”或“完全控制”)和 dbg(debugger)

通过装载 pwndbg,你可以获得:

  • 更加一目了然的上下文和更丰富的信息量。

    当程序暂停(例如命中断点、单步执行)时,你不需要输入任何命令,就能直接从 pwndbg 自动显示的色彩丰富的上下文面板中看到寄存器(比如某个寄存器里可能就存着炸弹期望的字符串的地址...)、代码(或许能看到当前正在与你的输入进行比较的汇编指令)、堆栈(或许能直接看到你输入的字符串在内存中的样子)等的详细信息。

  • 更加丰富的显示色彩和轻松愉悦的心情

  • ……

同时,作为汇编新手,你可能会遭受一些折磨。包括但不限于:

  • 信息严重过载

    很容易面对庞大且冗杂的信息量感到迷茫。

  • 或许会在一定程度上掩盖 GDB 的基础原理,导致学习曲线混乱

  • 部分命令与 GDB 原生命令不兼容

TIP

对于 BombLab 初学者,可先使用纯 GDB 掌握基础调试技能,待熟悉后再使用 pwndbg 提升效率。请自行判断,按需装载。

不过好消息是,即便后悔了也可以随时禁用或启用之。总之,欢迎尝试。

3.2 配置文件设置

config.txt 是本次实验的配置文件,包含以下几个配置项:

  1. 配置学号

    本次 BombLab 采用了个性化答案制度,即每个人的答案会依据自己的学号发生变化。因此,请务必在config.txt中填写自己的学号!

WARNING

助教会根据每个人的学号来判断答案;漏填学号或者填错学号导致的错误助教概不负责。

  1. 设置测试关卡: test_phase = 1,2,3,4,5,6

    通过修改 test_phase 的值,可以实现跳关。该功能能够完美适应某个 phase 做不下去或希望单独测试某 phase 的情况。记得按需使用。

  2. 剧情模式开关

    剧情模式是为了增加做实验过程中的乐趣所设,我们真心希望大家写 Lab 时能乐在其中!当然,如果你觉得剧情模式的大段输出影响到了你正常解题,可以在 config.txt 中随时将其关闭。

    关闭剧情模式:story_mode = false

    开启剧情模式:story_mode = true

    请确保在提交时,config.txt 中,story_mode 被设置为 false


4. 拆弹指导

4.1 关卡提示

实验各个关卡涉及的知识大致如下:

  1. 函数调用
  2. 循环
  3. 分支
  4. 递归
  5. 面向对象
  6. 单调栈

Secret. ████

TIP

在解决每一个关卡时,将该关卡对应涉及的知识牢记于心或许会事半功倍。因此强烈推荐常回来查看上面的关卡提示。

此外,请善读函数名并不要无视其含义,或许能帮助你理解每一步的工作。

4.2 动态分析:GDB

GDB(GNU symbolic debugger),Linux 下常用的程序调试器。本次 lab 的主力之一。所谓调试,就是让代码一步一步慢慢执行,跟踪程序的运行过程。比如,可以让程序停在某个地方,查看当前所有变量的值,或者内存中的数据;也可以让程序一次只执行一条或者几条语句,看看程序到底执行了哪些代码。

小提示:用 GDB 装载程序后运行程序,程序很可能会停留在等待输入的地方,gdb>提示符看起来消失了。此时可以按^+C (Ctrl+C) 中断程序,回到调试模式。

GDB 作为调试器是在你的程序跑起来之后才发挥主要作用的。用法可以参考 gdb-tutor 实操上手,也可以自行上网学习。你也可以直接通过命令 man gdb (man 指 manual) 在终端查看其使用指导,或者上网搜索来获得其更详尽的用法。或许也可以去网上找一个 gdb cheatsheet(小抄),方便你查找想用的指令。在此,我们也给出一个挺好的英文教程:Tudor's gdb crash course

以下是一些 GDB 常用指令:

命令(缩写)作用示例
tab命令或关键字补全
run (r)无断点直接运行;有断点从第一个断点处开始运行r
quit (q)退出 GDBq
break (b)打断点,可指定函数名或具体地址b *0x400540
disable b(breakpoints)禁用所有断点
enable b(breakpoints)启用所有断点
disable b(breakpoint) + 断点编号禁用指定断点
enable b(breakpoint) + 断点编号启用指定断点
info b查看断点信息(如被命中几次......)
continue (c)继续运行程序,一般是从断点处开始c
print (p)打印数据,可以指定格式,也可以指定寄存器、内存、变量等p /x *($rsp+8)
display (disp)指定的内容和print一样,不过可以重复展示display /5i $pc
info (i)获取信息,可指定寄存器、函数、断点信息等i r{egister} pc
delete删除GDB指定的内容,可指定断点等(默认是断点)d {break} 1
stepi (si)执行一条指令,后可跟数字表示执行多条si 10
examine (x)代码检查,后跟具体地址,可以指定输出的方式和格式x /10i $pc
call在GDB中进行函数调用(很神奇的功能)call printf("\n")
backtrace (bt)查看调用栈,即函数调用的情况bt

如果你还想用 pwndbg,也可以自行上网查阅其用法。pwndbg 的用法会比 GDB 稍微丰富一些,能够直接展示的关键信息也更加复杂,请按需查阅资料使用。

4.3 静态分析:阅读汇编代码

我们回顾一下 C 程序的编译过程:源代码 -> 汇编代码 -> 机器码,中间两步分别称为编译汇编

如前文所述,二进制炸弹 bomb++ 是一个由二进制机器码组成的可执行文件,因此我们无法指望直接阅读它来拆除炸弹。但我们知道,汇编代码和机器码则是几乎等价的,汇编语言是机器码的助记符。因此,有工具能帮我们将可执行文件中的机器码转化为汇编代码便不足为奇。我们可以使用这样的工具,将可执行文件中的机器码转化为汇编代码后再进行阅读。

使用 objdump 获得汇编代码

objdump 便是本次要介绍的查看目标文件或者可执行的目标文件的汇编代码的常用反汇编工具。其安装非常简单。

如果你直接输入 objdump -d ./bomb > bomb.S 以获得反汇编文件 bomb.S,得到的将是对整个 bomb++ 文件的反汇编,它将会极长从而无法阅读(因为所有关卡的代码全部被包含在内)。因此,我们在使用 objdump 时加入参数 --disassemble=<function_name>,便可以仅输出对应函数的汇编代码。

例如:

bash
objdump -d --disassemble=phase_5 ./bomb++ > phase_5.S

以上命令会将 phase_5 这一函数的汇编代码放入 phase_5.S 中。至此你便可以打开你反汇编得到的.S文件,开始快乐阅读汇编代码了!

你可以尝试在objdump后加上 -D 和/或 -x 和/或 -C,就像上面出现的-d一样。你可以通过搜索或读文档的方式了解这会导致什么后果;TA相信这几个参数会对你的实验有很大帮助!

objdump --help 会打印出 objdump 的所有用法,并且会给出精简的解释。

设置汇编语言风格(Intel/AT&T)

在 CSAPP 课本上,有一个拓展框介绍了两种汇编语言的格式—— AT&T 以及 Intel。

他们有一些显著的区别:

  • 两者的源操作数和目的操作数顺序相反(这会影响你对汇编代码的阅读,所以务必注意你在使用何种顺序习惯的语言)
  • 内存操作数格式:
    • Intel 语法:内存地址通常使用方括号,如 [ebp-4] 表示基址偏移量。
    • AT&T 语法:内存地址使用括号和寄存器,-4(%rbp) 表示相同的偏移量。
  • 偏移量与立即数:
    • Intel 语法:内存引用可以不区分立即数和偏移量。
    • ATT 语法:内存引用需要明确写出,立即数用 $,偏移量不需要。
  • AT&T 中寄存器需要加上 % 前缀,而 Intel 语法则不用。

本课程上课教学时使用的语法是 AT&T,这也是 objdump 默认使用的语法。但如果你并不想看一堆% 想试试 Intel 语法那也十分推荐,因为这是安全研究人员更常用的一种语法。想让 objdump 输出语法变为 Intel,只需要给 objdump 添加参数 -M intel

如果使用 pwndbg,由于汇编代码会在 pwndbg 界面显示,故也请自行设置好你希望阅读的汇编语言风格。

4.4 寄存器相关

通用寄存器

x86-64 架构提供了 16 个 64 位通用寄存器,用于存储数据、地址和参与运算、函数参数传递等通用任务。通用寄存器包括 %rax, %rbx, %rcx, %rdx, %rsi, %rdi, %rbp, %r8-%r15。不同种类寄存器所承担的特殊功能,你可以在课本上轻松查阅。这能为我们推断寄存器中所存数据的作用提供重要线索。

函数调用规则

函数调用时,被调用函数可能也会用到寄存器,覆盖了当前函数需要的数据,为了避免数据覆盖,x86 人为规定了通用寄存器的保存方式

  • caller save(调用者保存):在进行函数调用前,调用者需要提前保存好这些寄存器的值(如果后续还需要的话),因为被调用者可能会修改它们的值
  • callee save(被调用者保存):调用者不必保存,被调用者若要使用这些寄存器必须先保存

专用寄存器

区别于通用寄存器,有两个系统级的专用寄存器:

  • %rip(instruction pointer)== PC(program counter):指向当前程序运行的地方

  • %rsp(stack pointer):栈指针,用来存放临时变量、函数返回值等等(从高地址向低地址减少,本次实验对程序栈有个印象即可,不需要深入了解)

4.5 地址格式

形似 <offset>(<reg1>, <reg2>, <imme>) 的地址(其中各个字段可能省略),一般是表示 reg1+reg2*imme+offset 该地址对应的值(其中 lea 指令比较特殊,是直接加载地址)举例:

  • lea 0x0(,%rax,4),%rdx:将 %rax 乘 4 的(也就是地址本身)加载到 %rdx 中,常见于数组索引当中(为什么?)
  • mov -0x8(%rbp),%rax:将 %rbp 加上 -0x8 的地址存放的值加载到 %rax 中
  • mov (%rdx,%rax,1),%eax:将 %rdx 加上 %rax 乘 1的结果作为地址,取出这个地址存放的值,然后加载到 %eax 中
  • add %eax,-0x8(%rbp):将 %eax 和 (%rbp 加上 -0x8的地址存放的值) 求和,然后再写回%rbp 加上 -0x8的地址处

4.6 常见汇编代码

条件判断
assembly
if(array == 0)return;

endbr64 
test   %rdi,%rdi                      #让函数第一个参数和自己作与运算
je     11cb <for_sum(int*, int)+0x22> #若结果为0,则跳转;相当于判断参数是否为0,为0则跳转
循环结构
assembly
s = 0
for(int i = 0; i < size; ++i)s += array[i];

mov    $0x0,%eax  #初始化自增条件变量 i = 0
mov    $0x0,%edx  #初始化总和  s = 0
cmp    %esi,%eax  #判断条件变量是否满足条件 i < size ?
jge    11d0 <for_sum(int*, int)+0x27>  #大于或等于则跳出循环
add    (%rdi,%eax,4),%edx    #用i进行数组索引 base+i*4 => array[i]; s += array[i]
add    $0x1,%eax             #自增 i++
jmp    11bc <for_sum(int*, int)+0x13>  #回到比较的地方
switch case结构
  1. 打表:如果case的不同条件比较紧凑规律(比如0、1、2、3......),则将case的条件作为索引、对应的地址(可能是地址偏移量)作为值,创建一个表格存放在内存中。
caseaddr
00x1000
10x1004
20x1008
30x1010
  1. 类似if else嵌套:如果case比较稀疏,则通过采用连续的比较和条件跳转指令
assembly
cmp    $0x61,%dil
je     1224 <switch_case2(char)+0x1c>
cmp    $0x7a,%dil
je     1227 <switch_case2(char)+0x1f>
cmp    $0xa,%dil
je     122b <switch_case2(char)+0x23>
lea    0x1(%rdi),%eax
retq
递归

函数内部调用了函数本身,需要注意对callee-save寄存器的保存和使用

assembly
int recursion(int x){  #求斐波那契数
    if(x < 2)return x;
    return recursion(x-1)+recursion(x-2);
}

000000000000122c <recursion(int)>:
push   %rbp                      #保存 rbp
push   %rbx                      #保存 rbx
mov    %edi,%ebx                 #保存第一个参数x,因为x和rdi寄存器后续函数都要使用,因此将x保存在新的寄存器中(这里是ebx),防止后续的函数将rdi中存放的x覆盖掉,而ebx因为是callee-save,因此提前保存了它
cmp    $0x1,%edi                 
jle    1258 <recursion(int)+0x2c> # 小于等于 1 则跳转,相当于 x < 2
lea    -0x1(%rdi),%edi           # x-1
callq  122c <recursion(int)>     # recursion(x-1)
mov    %eax,%ebp                # 将得到的返回值放在ebp中,因为ebp是callee-save,要提前保存
lea    -0x2(%rbx),%edi          # x-2,这里也用到了x,因此需要提前保存x
callq  122c <recursion(int)>
...
struct

内部变量按顺序排列,需要对齐

C
typedef struct {
    char a = 'y';      //root     
    unsigned b = 2333; //root+0x4 虽然之前的char只占了1个字节,但是unsigned需要4字节对齐
    float c = 123.123; //root+0x8  0xb-0xf 之间的数值为0
    double d = 1930.1231313;  //root+0x10 虽然之前的float只占了4个字节,但是double需要8字节对齐
} node;
浮点运算
assembly
pxor   %xmm0,%xmm0              #浮点数的异或,这里相当于清零
#cvtsi2ss:源存储器1个32位有符号(补码)整数转为1个单精度浮点数,放入目的寄存器低32位
cvtsi2ss %edi,%xmm0             #这里相当于将一个 int 转换成 float
addss  0xd93(%rip),%xmm0		# a+=10
mulss  %xmm0,%xmm0              # a*=a
subss  0xd8b(%rip),%xmm0        # a-=20
C++虚函数

类的非静态函数调用时,默认第一个参数是指向该对象的指针 this

每一个类有唯一的一个虚函数表(vtable,其中只包含虚函数的指针),不是每个对象都有一个vtable,恰恰是每个同一个类的对象都有一个指针,这个指针指向该类的vtable(当然,前提是这个类包含虚函数)。那么,每个对象只额外增加了一个指针的大小。

assembly
mov    $0x8,%edi                            
callq  1090 <operator new(unsigned long)@plt>  #申请了8个字节的空间
mov    %rax,%rbx				 					#保存该空闲空间的起始地址,即指向对象的指针
mov    %rax,%rdi
callq  158c <B::B()>              #调用初始化函数,进行初始化,第一个参数为空闲空间的起始地址,即指向对象的指针this
#对象已经创建好
mov    (%rbx),%rax                # 对象的内容只包含一个指针,这条指令通过this指针获取对象的内容,即类的vtable的起始地址
mov    (%rax),%rdx                # 根据类的vtable起始地址获取要调用的函数的地址
mov    $0x2,%esi                  # 函数的第二个参数为 2
mov    %rax,%rdi
callq  *%rdx                      # 调用 rdx 位置处的函数(*号只是做标识用,并非取地址),参数分别为对象的地址(this)和2

5. 其他游戏指导

5.1 输入输出效率技巧

在 Linux 系统中,一切皆文件,包括标准输入(stdin)、标准输出(stdout)和标准错误(stderr)流。这种统一的设计让我们能够方便地重定向输入输出流,或使用管道连接多个程序。

输入输出重定向

输入输出重定向就是让程序从文件读取输入,或将输出结果保存到文件,而不是在终端上进行交互。以下是一些例子:

  • 将程序输出定向保存到一个文件,我们使用 > file

    sh
    $ echo hello > hello.txt  # 把stdout重定向到hello.txt中
    $ cat hello.txt
    hello
  • 让一个文件被定向到一个程序的标准输入,我们使用 < file

    sh
    $ cat < hello.txt    # 把hello.txt重定向到cat的标准输入
    hello
  • 同时重定向输入和输出:

    sh
    $ cat < hello.txt > hello2.txt # 同时重定向cat的标准输入和标准输出
    $ cat hello2.txt
    hello

在拆弹过程中,如果你已经将所有正确口令保存在 password.txt 中,可以使用这条输入重定向命令一次性通过所有关卡:

sh
./bomb < password.txt

管道(Pipe)

使用 | 可以将一个程序的输出作为另一个程序的输入:

sh
cat 1.txt | tail -n1    # 输出文件的最后一行
cat 1.txt | grep CH     # 筛选包含"CH"的行

如果你已经知道前某几个阶段的口令并保存在 password.txt 中,但不想在后续阶段中一遍遍手动输入,就可以用 cat 打开那个文本文件以及标准输入,然后利用管道机制将这两个文件的内容导向bomb+的输入:

sh
cat password.txt - | ./bomb

这里的 - 表示标准输入。程序会先读取 password.txt 中的内容,然后等待你输入剩余部分,无需重复输入已知口令。

温馨提示:使用这种操作时注意,由于Bomb+每一阶段都会重新读取一行,所以文件结尾多出的换行符会导致Bomb+下一阶段读到一个寂寞,从而导致炸弹爆炸。因此,在存放口令的文本文件password.txt中,注意不要多加一个空白的结尾行。

以上技巧能显著提升你在 BombLab 或其他命令行场景下的操作效率。

5.2 结局

这座城市会根据你的选择和努力走向三种不同的结局,分别是:(详见 main.cpp

  • BE:Bomb Ending,即炸弹爆炸,拆弹失败~~(并非Bad Ending)~~

    炸弹会读取 fail.txt 并在爆炸时打印其内容。如果你想要其他爆炸效果,可以自行修改 fail.txt 或直接将其删去。

  • NE:Normal Ending,即成功解出 6 个 phase 的口令,拆除炸弹

  • TE:True Ending,需要你在 NE 的基础上继续探索 secret......

TIP

成功拆除炸弹的你,或许想要知道隐藏于这座城市水面之下更深的秘密。整座城市的命运,或许就掌握在你手中......

  1. TE 的入口在 phase 3 中出现,请找到这扇门

  2. 门上有一把密码锁,需要输入密码才能成功入内

  3. 密码就在你一路走来的路上,请仔细关注每一道关卡中出现的数字,它可能在你的答案里,也可能在某个寄存器里。 别忘了全局变量!!! 更别忘了变量名带来的隐晦提示。

  4. 密码格式错误也会导致爆炸!!!如果你不想倒在 true ending 的门前,请仔细分析

  5. 或许你学过有限状态机、正则表达式

  6. 请不要忘记自己的身份 (提示:文档开头的身份牌顺序或许对你很重要)

    WHO ARE YOU???????


6. 提交要求

6.1 必须文件

  • password.txt
    • 所有关卡的正确口令
  • lab2_学号_姓名.pdf:实验报告(注:只接受 PDF 格式提交的实验报告!!)
  • config.txt:正确配置的配置文件
    • story_mode 设置为 false

6.2 实验报告内容

报告没有严格的格式要求,但应包含以下内容:

  1. 你的姓名和学号
  2. 每个关卡的推演过程(重点),如你如何推测函数的功能
  3. 拆弹成功的截图(请关闭剧情模式进行截图)
  4. 如果有,请列出引用的内容以及参考的资料
  5. 对助教和实验的意见或建议(可选)

from TA:

实验报告是用来判断你是否是通过 独立思考 来解决本次Lab的。

我们不希望看到冗长或表意混乱的报告,请一定不要把报告写成论文。(・_・;

6.3 提交

打开终端,执行以下指令:

shell
# 在以下文件的根目录下添加以下三个内容到暂存区
# 如果无法成功添加,请检查终端中的当前目录和以下文件路径是否对应
git add password.txt lab2_<>_<>.pdf config.txt

# 将暂存区的所有更改提交到本地仓库
git commit -m "xxx(你的提交注释)"

# 对最后一个 commit,请添加 tag: final
git tag "final"

# 请务必将本地仓库推送到远程
git push

6.4 评分

本实验满分为 110 分。其中含 10 分隐藏关卡通关奖励。 每迟交一天(迟交 1ms 到 24h 均视作一天)会在你的得分上扣除 5 分,扣完为止。

项目分值
提交格式正确,内容完整1
通过正常关卡 6*1484
实验报告15
bonus: 解开secret,打出TE (True Ending)+10

另:抄袭倒扣分!!勿谓言之不预。

反卷斗士の承诺:

实验过程非抄袭的同学,实验报告部分赋满分。一旦发现抄袭,则零分处理。


参考资料

负责助教:马颢宸 罗贤可昕

The avatar of contributor named as ell77abu ell77abu
The avatar of contributor named as Zecyel Zecyel
The avatar of contributor named as Copilot Copilot
The avatar of contributor named as Ran Xiaoxiao Ran Xiaoxiao