【写在前面】这是一个仍然需要修改和更新的 CSAPP : BombLab 的解题教程,如果读者需要直接的通关技巧,那么,本篇可能不是太适合你,我会在后期更新简化的通关(新手操作)教程。这是一篇本人在学习 IA-32 汇编指令并完成学校实验过程中一点浅薄的见解,现在将其整理出来与君分享。学识尚浅,高手勿喷。(2023.12.5)
运行一个二进制文件 bomb,它包括六个"阶段(phase)",每个阶段要求学生通过 stdin 输入一个特定的字符串。如果输入了预期的字符串,那么该阶段被"拆除",进入下一个阶段,直到所有炸弹被成功"拆除"。否则,炸弹就会"爆炸",打印出"BOOM!!!",并通知教师端。
1.使用 objdump 对炸弹文件进行反汇编;
2.理解汇编语言代码的行为或作用;
3.使用 gdb 调试器单步跟踪调试每一阶段的机器代码,找出拆除炸弹所需的字符串;
4.提交结果,撰写实验报告。
下面的链接共有 3 个不同版本的 VMWare,根据自己的操作系统选择合适的版本:
链接:https://pan.baidu.com/s/1f6MXhyiMqGYpezjZQhSeAA
提取码:bdo6
PS:V16.0.0 只支持 Win10 64 位;Win7 或配置较低的电脑,建议用 v12.5.9 老版本;XP 或 32 位系统只能用 v10.0.7 经典版。
(注意: Win 11 请安装最新版,到官网下载,激活方法网上找激活码啥的,自己查阅资料)
(1)下载 Linux 镜像文件,一定要确保是 32 位 Linux。Linux 推荐使用 Ubuntu 16.04 或 14.04 版本。官方下载地址:
16.04.6:Ubuntu 16.04.7 LTS (Xenial Xerus)
14.04.6:Ubuntu 14.04.6 LTS (Trusty Tahr)
注:Linux iso文件名上带有 i386 字样的为 32 位 Linux,带有 amd64 字样的为 64 位 Linux。
(2)在 VMware 下,点击 文件-新建虚拟机,安装 Linux 虚拟机,各种设置按默认即可。
确保已安装 VMWare Tools,否则无法设置共享文件夹。如果已安装 VMWare Tools,则不需要重新安装。安装方法参照《实验室电脑配置说明》。如果 VMWare 下的 虚拟机-安装 VMWare Tools 按钮是灰色的,导致无法安装,则转第 3 条进行处理;VMWware Tools 安装完成之后,转第5条。
(1)点击虚拟机-设置-CD/DVD(SATA),选中“使用ISO影像文件”,同时选中“已连接”和“启动时连接”。
(2)点击浏览,选择 VMWare 安装目录下的 linux.iso 文件,如:C:\Program Files (x86)\VMware\ VMware Workstation\linux.iso,点击打开,如下图所示:
(3)此时应自动展开 VMWare Tools 虚拟光驱目录,或出现下面的 DVD 图标,用鼠标点击该图标。就可以安装 VMWare Tools 了,具体的安装步骤请自己查阅资料(限于篇幅)。
(4)此时应自动展开 VMWare Tools 虚拟光驱目录,或出现下面的 DVD 图标,用鼠标点击该图标。就可以安装 VMWare Tools 了。
在 VMware 下,点击 虚拟机-设置-选项-共享文件夹-总是启用-添加-浏览-选择 D:\share(在 Windows 下提前建好此文件夹)-下一步-完成。
如果共享文件夹设置好之后,在/mnt/hgfs目录下没有 share 目录,则重新安装 VMWare Tools,一般可解决问题。
在做实验二时,要登录 VPN,能够访问学校内网,才能连接服务器。如果登录了 VPN,仍然不能连接服务器,可以在获得拆弹密码之后,用其他同学的电脑把结果提交到服务器。(我们作业是在线版本的,如果个人完成,则不需要配置网络)
在做实验二时,要确保自己的电脑主机已联网,并将虚拟机设置为“NAT模式”进行联网,否则可能导致无法连接服务器。设置方法:在 VMWare 的 虚拟机-设置-网络适配器 中,选择“NAT模式”,如下图所示。
如果读者不是通过学校课程作业获取到的炸弹文件,那么请按照以下教程自行下载离线版炸弹文件。
使用 wget 下载炸弹文件,需要先 cd 到你想存放炸弹的文件夹,然后使用下面的指令下载文件:
wget csapp.cs.cmu.edu/3e/bomb.tar
通过下面的指令可以解压文件:
tar xvf bomb.tar
这会生成一个 bomb 目录,包含三个文件:bomb, bomb.c, README
查看变量信息:
(gdb) p <变量名>
对于调试来说寄存器中的值也很重要,可以查看到当前正在执行的指令的地址等。具体操作如下:
(gdb) info reg
:显示所有寄存器。可以简写为:i r
。如果要查看具体的寄存器可以这样:
i $ebx
(gdb) p $eax
:显示eax寄存器内容
(gdb) p/c $eax
:用字符显示 eax 寄存器内容
反斜杠后面的是显示格式,可使用的格式见下表:该表在显示内存内容的 x 命令中也是通用的
格式 | 说明 |
---|---|
x | 显示为十六进制数 |
d | 显示为十进制数 |
u | 显示为无符号十六进制数 |
o | 显示为八进制数 |
t | 显示为二进制数 |
a | 显示为地址 |
c | 显示为字符(ASCII) |
f | 显示为浮点数 |
s | 显示为字符串 |
i | 显示为汇编代码(仅在显示内存的x命令中可用) |
查看栈信息:
(gdb) bt
:显示所有栈帧
(gdb) bt 10
:显示前面10个栈帧
(gdb) bt -10
:显示后面10个栈帧
(gdb) bt full
:显示栈帧以及局部变量
(gdb) bt full 10
:显示前面10个栈帧以及局部变量
(gdb) bt full -10
:显示后面10个栈帧以及局部变量
(gdb) frame <栈帧编号>
:进入指定的栈帧中,然后可以查看当前栈帧中的局部变量,以及栈帧内容等信息
(gdb) info frame <栈帧编号>
:可以查看指定栈帧的详细信息
(gdb) up
:进入上层栈帧
(gdb) down
:进入下层栈帧
可以查看具体内存地址中的内容,比如:目前执行的汇编指令,以及栈中的内容等。
(gdb) x $pc
:显示程序指针指向位置的内容
(gdb) x/i $pc
:显示程序当前位置的汇编指令
(gdb) x/10i $pc
:显示程序当前位置开始往后的10条汇编指令
(gdb) break <函数名>
:对当前正在执行的文件中的指定函数设置断点。可简写为:(gdb) b <函数名>
(gdb) break <行号>
:对当前正在执行的文件中的特定行设置断点。可简写为:(gdb) b <行号>
(gdb) break <文件名:行号>
:对指定文件的指定行设置断点。最常用的设置断点方式。可简写为:(gdb) b <文件名:行号>
(gdb) break <文件名:函数名>
:对指定文件的指定函数设置断点。C++类中的方法似乎不好使。可简写为:(gdb) b <文件名:函数名>
(gdb) break <+/-偏移量>
:当前指令行+/-偏移量出设置断点。可简写为:b <+/-偏移量>
(gdb) break <*地址>
:指定地址处设置断点。可简写为:b <*地址>
(gdb) info break
:显示所有断点以及监视点。可简写为:(gdb) i b
(gdb) delete <编号>
:删除编号指向的断电或者监视点。可简写为:(gdb) d <编号>
(gdb) clear <行号>
:删除该行的断点
(gdb) clear <文件号:行号>
:删除该行的断点
(gdb) disble <断电编号>
:当前断点设置为无效
(gdb) enable
:当前断点设置为有效
可以监视某个变量,在变量被访问或者被修改时程序会在当前点进入断点。删除,查看监视点的方式与断点相同。设置监视点方式如下:
(gdb) watch <表达式>
:表达式发生变化时暂停
(gdb) awatch <表达式>
:表达式访问或者改变时暂停
(gdb) rwatch <表达式>
:表达式被访问时暂停
在调试程序过程中,有时候我们只想在某个条件下停止程序,然后进行单步调试,而条件断点就是为此而设计。下面是条件断点的操作方式:
(gdb) b <断点> if <条件表达式>
: 例如:b main.cpp:8 if x=10 && y=10
(gdb) condition <断点编号>
:删除该断点的条件。
(gdb) condition <断点编号> <条件表达式>
:修改断点条件。例如:condition 1 x=10 && y=10
每次断点发生时候,想要查看的变量很多时,如果每个变量都手动 print 需要浪费很多时间。断点命令可以在断点发生时批量执行GDB命令。下面是断点命令的设置方式:
(gdb) commands <断点编号>
(gdb) >print x
(gdb) >print y
(gdb) >end
首先输入 GDB 命令 commands <断点编号> 然后回车,这时候会出现> 提示符。出现> 提示符后可以输入断点发生时需要执行的GDB命令,每行一条,全部输入完成后输入end结束断点命令。
对变量的值进行控制,可以更快的调试自己的程序。下面就是设置变量值的方法:
(gdb) set variable <变量> = <表达式>
:将变量的值设定为指定表达式的值。例如 set variable x=10
前置:
首先我们注意到我们的 jar 解压后一共有三个文件,一个是 bomb.c 还有一个就是炸弹程序 bomb,以及不是很重要的 README 文件。 bomb.c 源码文件是 main 函数的源代码,这有助于我们调试,但是我想先对里面的一些英文注释做翻译:
/********************************85/2000000000000000000*******************************************
* Dr. Evil's Insidious Bomb, Version 1.1
* Copyright 2011, Dr. Evil Incorporated. All rights reserved.
*
* LICENSE:
*
* Dr. Evil Incorporated (the PERPETRATOR) hereby grants you (the
* VICTIM) explicit permission to use this bomb (the BOMB). This is a
* time limited license, which expires on the death of the VICTIM.
* The PERPETRATOR takes no responsibility for damage, frustration,
* insanity, bug-eyes, carpal-tunnel syndrome, loss of sleep, or other
* harm to the VICTIM. Unless the PERPETRATOR wants to take credit,
* that is. The VICTIM may not distribute this bomb source code to
* any enemies of the PERPETRATOR. No VICTIM may debug,
* reverse-engineer, run "strings" on, decompile, decrypt, or use any
* other technique to gain knowledge of and defuse the BOMB. BOMB
* proof clothing may not be worn when handling this program. The
* PERPETRATOR will not apologize for the PERPETRATOR's poor sense of
* humor. This license is null and void where the BOMB is prohibited
* by law.
* // 中文版:
* 邪恶博士的阴险炸弹,1.1 版
* 版权所有 2011,邪恶集结者博士。保留所有权利。
* 许可证:
* 邪恶博士公司(犯罪者)特此明确允许您(受害者)使用此炸弹。
* 这是一个有时间限制的许可证,在受害者死亡时到期。犯罪者对受害者的伤害
* 、沮丧、精神错乱、虫眼、腕管综合征、睡眠不足或其他伤害不承担任何责任。
* 除非犯罪者想获得荣誉,否则受害者不得将此炸弹源代码分发给犯罪者的任何敌人。
* 任何受害者都不得调试、反向工程、运行“字符串”、反编译、解密或使用
* 任何其他技术来获取和拆除炸弹。处理此程序时可能不穿防炸弹的衣服。
* 犯罪者不会为犯罪者缺乏幽默感而道歉。
* 在法律禁止使用炸弹的情况下,此许可证无效。
***************************************************************************/
#include
#include
#include "support.h"
#include "phases.h"
/*
* Note to self: Remember to erase this file so my victims will have no
* idea what is going on, and so they will all blow up in a
* spectaculary fiendish explosion. -- Dr. Evil
*/
// 自我提醒:记住要删除这个文件,这样我的受害者就不知道发生了什么,
// 所以他们都会在一场可怕的爆炸中爆炸。 —— 邪恶博士
FILE *infile;
int main(int argc, char *argv[])
{
char *input;
/* Note to self: remember to port this bomb to Windows and put a
* fantastic GUI on it. */
// 自我提醒:记得把这个炸弹移植到Windows上,并在上面制作一个很棒的GUI。
/* When run with no arguments, the bomb reads its input lines
* from standard input. */
// 关于文件读取:当在没有参数的情况下运行时,炸弹会从标准输入中读取其输入行。
// 译者注:这里是指如果程序没有指定文件参数,则通过标准输入来读取一行字符串
if (argc == 1) {
infile = stdin;
}
/* When run with one argument , the bomb reads from
* until EOF, and then switches to standard input. Thus, as you
* defuse each phase, you can add its defusing string to and
* avoid having to retype it. */
// 当使用一个参数<file>运行时,炸弹从<file>读取直到EOF,然后切换到标准输入。
// 因此,在分解每个阶段时,可以将其分解字符串添加到<file>中,从而避免重新键入它。
// 译者注:意思就是前面通过的关卡,可以通过把结果写入到文件,来避免重复输入。
else if (argc == 2) {
if (!(infile = fopen(argv[1], "r"))) {
printf("%s: Error: Couldn't open %s\n", argv[0], argv[1]);
exit(8);
}
}
/* You can't call the bomb with more than 1 command line argument. */
// 你不能用一个以上的命令行参数调用炸弹。只能最多指定一个文件路径作为参数
else {
printf("Usage: %s []\n", argv[0]);
exit(8);
}
/* Do all sorts of secret stuff that makes the bomb harder to defuse. */
// 做各种秘密的事情,让炸弹更难拆除。
// (作者指混淆,汇编陷阱欺骗反汇编程序,让代码更加难读???)
// 译者注:初始化环境,可能是加分点。需分析后决定功能。
initialize_bomb();
printf("Welcome to my fiendish little bomb. You have 6 phases with\n");
printf("which to blow yourself up. Have a nice day!\n");
/* Hmm... Six phases must be more secure than one phase! */
// 六个阶段必须比一个阶段更安全!(暗示至少有6个阶段)
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. */
/* Drat!他们想通了!
让我知道他们是怎么做到的。*/
printf("Phase 1 defused. How about the next one?\n");
/* The second phase is harder. No one will ever figure out
* how to defuse this... */
// 第二阶段更难。没有人会想到办法化解这个(难题)。。。
input = read_line();
phase_2(input);
phase_defused();
printf("That's number 2. Keep going!\n");
/* I guess this is too easy so far. Some more complex code will
* confuse people. */
// 到目前为止,我想这太容易了。一些更复杂的代码会让人感到困惑。
input = read_line();
phase_3(input);
phase_defused();
printf("Halfway there!\n");*-
/* Oh yeah? Well, how good is your math? Try on this saucy problem! */
// 哦,是吗?你的数学有多好?试试这个恶心的题目!
input = read_line();
phase_4(input);
phase_defused();
printf("So you got that one. Try this one.\n");
/* Round and 'round in memory we go, where we stop, the bomb blows! */
// 在记忆中,我们一圈又一圈地走,停在哪里,炸弹就爆炸!(循环?)
input = read_line();
phase_5(input);
phase_defused();
printf("Good work! On to the next...\n");
/* This phase will never be used, since no one will get past the
* earlier ones. But just in case, make this one extra hard. */
// 这一阶段将永远不会被使用,因为没有人会通过之前的阶段。
// 但以防万一,我让这件事变得格外艰难。
input = read_line();
phase_6(input);
phase_defused();
/* Wow, they got it! But isn't something... missing? Perhaps
* something they overlooked? Mua ha ha ha ha! */
// 哇,他们成功了!但是,遗失的。。。?
// 也许他们忽略了什么?哈————哈,哈!
return 0;
}
注释几乎暗含了每个关卡的方向。下面就是破解和分析时间了(以下内容是干货(错误)比较多的,内容这是从我实验报告上几乎原封不动复制过来的,高手勿喷。准备寒假完善隐藏关卡的解释,并写一个单纯的攻略操作版本,有空再研究一下 x64 的炸弹)
6.1.1 相关汇编代码
08048b33
: 8048b33: 83 ec 14 sub $0x14,%esp
8048b36: 68 84 a1 04 08 push $0x804a184
8048b3b: ff 74 24 1c pushl 0x1c(%esp)
8048b3f: e8 ba 04 00 00 call 8048ffe
8048b44: 83 c4 10 add $0x10,%esp
8048b47: 85 c0 test %eax,%eax
8048b49: 74 05 je 8048b50
8048b4b: e8 eb 06 00 00 call 804923b
8048b50: 83 c4 0c add $0xc,%esp
8048b53: c3 ret
6.1.2 破解过程与结果
在
1.
sub $0x14,%esp
将栈顶指针减去0x14字节来为局部变量和临时数据腾出空间。
2.
push $0x804a184
将绝对地址 0x804a184 处获数据并压栈,根据上下文猜测是在准备下面 strings_not_equal 函数的参数。
3.
pushl 0x1c(%esp)
从栈上偏移量 0x1c 处获取一个32位值,然后将其压入栈中。
4.
call 8048ffe
调用 strings_not_equal 函数来解析输入数据。
再看 main 函数中调用 phase_1 函数的上下文:
8048a79: e8 35 08 00 00 call 80492b3
8048a7e: 89 04 24 mov %eax,(%esp)
8048a81: e8 ad 00 00 00 call 8048b33
8048a86: e8 21 09 00 00 call 80493ac
这里 ` mov %eax,(%esp)` 将寄存器 %eax 的值移动(存储)到栈顶 (sp)。也就是读入了一行字符串,然后将其首地址从 eax(read_line返回值)存储到 esp 。
于是,我们可以知道 地址 0x804a184 处的字符串用于与输入字符串进行比较,继续分析phase_1的下文:
5.
test %eax,%eax
这条指令测试 %eax 寄存器中的值。它将 %eax 与自身进行按位逻辑与(AND)运算,并根据结果设置标志寄存器(FLAGS)。
6.
je 8048b50
如果上一条指令产生的结果为零(也就是 %eax 的值为零),则跳转到地址 0x8048b50 处执行,否则继续执行下一条指令。而下一条指令是 `call 804923b
于是,只需利用 gdb 的 x/s 指令查看 0x804a184 位置对应内存存的字符串即可:
也可以使用 readelf 的readelf -x .rodata bomb 列出整个 rodata 节的数据:
重新运行一个实例,并输入 I was trying to give Tina Fey more material. 可以发现成功通关:
6.2.1 相关汇编代码
08048b54
: 8048b54: 53 push %ebx
8048b55: 83 ec 30 sub $0x30,%esp
8048b58: 65 a1 14 00 00 00 mov %gs:0x14,%eax
8048b5e: 89 44 24 24 mov %eax,0x24(%esp)
8048b62: 31 c0 xor %eax,%eax
8048b64: 8d 44 24 0c lea 0xc(%esp),%eax
8048b68: 50 push %eax
8048b69: ff 74 24 3c pushl 0x3c(%esp)
8048b6d: e8 06 07 00 00 call 8049278
8048b72: 83 c4 10 add $0x10,%esp
8048b75: 83 7c 24 04 00 cmpl $0x0,0x4(%esp)
8048b7a: 79 05 jns 8048b81
8048b7c: e8 ba 06 00 00 call 804923b
8048b81: bb 01 00 00 00 mov $0x1,%ebx
8048b86: 89 d8 mov %ebx,%eax
8048b88: 03 04 9c add (%esp,%ebx,4),%eax
8048b8b: 39 44 9c 04 cmp %eax,0x4(%esp,%ebx,4)
8048b8f: 74 05 je 8048b96
8048b91: e8 a5 06 00 00 call 804923b
8048b96: 83 c3 01 add $0x1,%ebx
8048b99: 83 fb 06 cmp $0x6,%ebx
8048b9c: 75 e8 jne 8048b86
8048b9e: 8b 44 24 1c mov 0x1c(%esp),%eax
8048ba2: 65 33 05 14 00 00 00 xor %gs:0x14,%eax
8048ba9: 74 05 je 8048bb0
8048bab: e8 e0 fb ff ff call 8048790 <__stack_chk_fail@plt>
8048bb0: 83 c4 28 add $0x28,%esp
8048bb3: 5b pop %ebx
8048bb4: c3 ret
08049278
: 8049278: 83 ec 0c sub $0xc,%esp
804927b: 8b 44 24 14 mov 0x14(%esp),%eax
804927f: 8d 50 14 lea 0x14(%eax),%edx
8049282: 52 push %edx
8049283: 8d 50 10 lea 0x10(%eax),%edx
8049286: 52 push %edx
8049287: 8d 50 0c lea 0xc(%eax),%edx
804928a: 52 push %edx
804928b: 8d 50 08 lea 0x8(%eax),%edx
804928e: 52 push %edx
804928f: 8d 50 04 lea 0x4(%eax),%edx
8049292: 52 push %edx
8049293: 50 push %eax
8049294: 68 05 a4 04 08 push $0x804a405
8049299: ff 74 24 2c pushl 0x2c(%esp)
804929d: e8 6e f5 ff ff call 8048810 <__isoc99_sscanf@plt>
80492a2: 83 c4 20 add $0x20,%esp
80492a5: 83 f8 05 cmp $0x5,%eax
80492a8: 7f 05 jg 80492af
80492aa: e8 8c ff ff ff call 804923b
80492af: 83 c4 0c add $0xc,%esp
80492b2: c3 ret
6.2.2 破解过程与结果
【逐行分析】
1.
push %ebx
将 %ebx 寄存器的值推入栈中,保存现场, esp1 = esp0 - 4。
2.
sub $0x30,%esp
在栈上为局部变量和缓冲区开辟空间。 esp2 = esp1 – 48 = esp0 - 52
3.
mov %gs:0x14,%eax
mov %eax,0x24(%esp).
获取操作系统提供的安全机制(通常称为"canary")并放置到栈上以防止缓冲区溢出攻击。根据上下文计算esp0 - 4 - 0x30(48) + 0x24(36) = esp0 – 16,是将gs:0x14对应地址放到了esp0 - 16 处。
4.
xor %eax,%eax
清空 eax 寄存器的值。
5.
lea 0xc(%esp),%eax
push %eax
将 %eax 设置为栈顶地址加上偏移量 12(即 %esp + 0xC 的地址),即esp0 – 40 处作的值压栈,先将将栈顶指针 – 4,即esp3 = esp0 – 56, 然后将值压入到新的栈顶esp0-56处作为后续函数调用的参数。
6.
pushl 0x3c(%esp)
将当前 esp 加上偏移量 0x3c 即 esp0 – 56 + 60 = esp0 + 4 处的值,作为调用 read_six_numbers 函数的参数,由于调用了 push, 这个参数是放在现在的esp4 = esp0 – 60 处。
7.
call 8049278
将前面计算得到的esp0-52处参数2(数组首地址)和esp0 + 4 的栈上数据(源字符串首地址)作为参数1,用这两个参数来调用函数 read_six_numbers。
重难点:观察 mian 函数调用 phase_2 的上下文:
8048a97: e8 17 08 00 00 call 80492b3
8048a9c: 89 04 24 mov %eax,(%esp)
8048a9f: e8 b0 00 00 00 call 8048b54
8048aa4: e8 03 09 00 00 call 80493ac
在调用 phase_2 函数之前,先调用了 read_line 函数,字面理解就是读取了一行输入,返回值是字符串首地址,存放在 eax 寄存器中,然后调用 mov %eax,(%esp) 将 eax 中的地址放到当前的栈顶,即esp 处。可以发现 传递给 phase_2 的参数在传递给 read_six_numbers 时,上面分析的是 esp0 + 4 处的值,为什么两者看似“不等”呢?
【分析】
因为调用 call phase_2指令时,call 指令实际上做了两件事:将PC+偏移量计算得到的地址压入栈顶,并将 esp – 4, 所以我们进入 phase_2 的栈帧时 esp0 = esp – 4。
综上,read_six_numbers 的第一个参数其实就是 main 函数传递给 phase_2 的唯一参数,而不是其他偏移处的值。
8.
add $0x10,%esp
恢复堆栈指针,这里是恢复调用 read_six_numbers 前为其分配参数所占用的空间,将栈帧移动到数组第一个元素地址(esp0-40)的顶部,也就是说,现在的esp5 = esp4 + 16 = esp0 - 44。
9.
cmpl $0x0,0x4(%esp)
jns 8048b81
0x4(%esp),即 esp5 + 4 = esp0 – 44 + 4 = esp0 - 40,根据前面分析,这个值是read_six_numbers参数格式化的第一个数的地址,即6个数的数组的首地址。这两条指令检查第一个输入数值是否大于等于零。如果是负数,则会向下执行,引爆炸弹。
【小结】:需要确保第一个输入数字不为负数。可以通过输入非负整数来绕过这个检查。
10.
call 804923b
此行代码对应一种异常情况,即第一个输入数字为负数,会导致炸弹爆炸。
11.
mov $0x1,%ebx
将寄存器 %ebx 设置为 1,用作循环计数器,即for循环的i 。
11.
mov %ebx,%ea
将 %ebx 的值移动到 %eax 寄存器中,即 %eax = %ebx = 1,用于后续计算。
12.
add (%esp,%ebx,4),%eax
将栈上地址为 (%esp + 4 * %ebx) 处的数值加到 %eax 中。这是一个累加操作, 而eax 用于存放ebx(索引 i)和a[i-1]的和,第一轮是eax = i = 1,经过这个操作后理解为 eax = 1 + a[i - 1]。由于esp5 + 4 = esp0 - 40 是数组的首地址,ebx 作为 i 并初始化为1, (%esp,%ebx,4)就是指 %esp + 4 + (%ebx - 1) * 4,在遍历过程中可以理解为这行指令是依次取数组中元素的意思,即 a[i - 1]且 i=1:n。现在 eax 中的值就是取出的 a[i-1]。
13.
cmp %eax,0x4(%esp,%ebx,4)
将 %eax 和栈上另一个地址 (%esp + 4 * %ebx + 4) 处的数值比较, (%esp + 4 * %ebx + 4)比(%esp,%ebx,4)多加一个4,所以对于每一次循环,这个值代表 a[i],这行指令意思是把eax和a[i]进行比较。
14.
je 8048b96
如果相等,则跳转到 8048b96 处执行下一条指令。而8048b96 处的指令为 add $0x1,%ebx,将 %ebx 加1,增加循环计数器的值。
15.
call 804923b
此行代码对应一种异常情况,即累加结果与所比较的数不相等,会引爆炸弹。
16.
cmp $0x6,%ebx
jne 8048b86
如果 %ebx 不等于 6,则跳转到 8048b86 处,我们再看一下调转的位置:
8048b86: mov %ebx,%eax
8048b88: add (%esp,%ebx,4),%eax
8048b8b: cmp %eax,0x4(%esp,%ebx,4)
可以看出,这里是将之前累加过1的新的ebx 赋值给eax,再看eax 其实每次循环 eax 都会变成当前的索引 i 所以,我们进一步理解了此处的循环到底在做什么,即:每次循环比较 a[i] 和 a[i-1] + i 是否相等,如果不等,就会爆炸。
17.
mov 0x1c(%esp),%eax
将栈上地址为 (%esp + 0x1C) = esp5 + 28 = esp0 – 44 + 28 = esp0 - 16 的值移动到 %eax 中根据上面的分析,这里存储的是 gs:14 生成的校验码,用于下一步的检测缓冲区溢出的操作。(如果数组 a 没有发生栈溢出,则不会覆盖esp0 -16,这个位于它上方的值,如果溢出,则会覆盖掉这个值,导致下一步异或失败)。
18.
xor %gs:0x14,%eax
通过与内存中的gs:0x14值异或操作,检查是否有缓冲区溢出。本行代码是对安全机制(canary)进行验证,略过它即可继续。
19.
je 8048bb0
如果没有缓冲区溢出,则跳转至 8048bb0 进行返回前的堆栈平衡处理。
否则,发生溢出时,会继续向下执行 call 8048790 <__stack_chk_fail@plt>,此行代码对应一种异常情况,即发现了堆栈错误,程序崩溃退出。
19.
8048bb0: add $0x28,%esp
恢复堆栈指针到,esp0 – 16即堆栈检测的校验码处。
20.
pop %ebx
从栈中弹出phase_2为调用者(main)维护的 %ebx 值,恢复寄存器状态。
21.
ret
返回 %eax 最后的值到调用者,也就是堆栈检测异或的结果。
根据以上分析可以尝试画出 phase_2 函数返回前的栈帧结构图:
(P.S. 由于当时实验时候时间紧的原因,栈帧理解有一个地方出错的,就是 call 压入的是返回地址,用于回到调用者,图中文本错了)
综上所述,本关卡的破解建议是输入六个非负整数,以满足每次循环 a[i-1] + 1与a[i]相等(i从1开始)。如果在执行期间触发异常条件,则会引爆炸弹,导致游戏失败。
所以,如果以 a[0] = 1,则后面的数为:2 4 7 11 16。
将结果保存至 anx.txt 并作为运行参数,通关结果如下:
(1)相关汇编代码
08048bb5
: 8048bb5: 83 ec 1c sub $0x1c,%esp
8048bb8: 65 a1 14 00 00 00 mov %gs:0x14,%eax
8048bbe: 89 44 24 0c mov %eax,0xc(%esp)
8048bc2: 31 c0 xor %eax,%eax
8048bc4: 8d 44 24 08 lea 0x8(%esp),%eax
8048bc8: 50 push %eax
8048bc9: 8d 44 24 08 lea 0x8(%esp),%eax
8048bcd: 50 push %eax
8048bce: 68 11 a4 04 08 push $0x804a411
8048bd3: ff 74 24 2c pushl 0x2c(%esp)
8048bd7: e8 34 fc ff ff call 8048810 <__isoc99_sscanf@plt>
8048bdc: 83 c4 10 add $0x10,%esp
8048bdf: 83 f8 01 cmp $0x1,%eax
8048be2: 7f 05 jg 8048be9
8048be4: e8 52 06 00 00 call 804923b
8048be9: 83 7c 24 04 07 cmpl $0x7,0x4(%esp)
8048bee: 77 3c ja 8048c2c
8048bf0: 8b 44 24 04 mov 0x4(%esp),%eax
8048bf4: ff 24 85 e0 a1 04 08 jmp *0x804a1e0(,%eax,4)
8048bfb: b8 c0 03 00 00 mov $0x3c0,%eax
8048c00: eb 3b jmp 8048c3d
8048c02: b8 c6 02 00 00 mov $0x2c6,%eax
8048c07: eb 34 jmp 8048c3d
8048c09: b8 fd 02 00 00 mov $0x2fd,%eax
8048c0e: eb 2d jmp 8048c3d
8048c10: b8 c4 00 00 00 mov $0xc4,%eax
8048c15: eb 26 jmp 8048c3d
8048c17: b8 a6 02 00 00 mov $0x2a6,%eax
8048c1c: eb 1f jmp 8048c3d
8048c1e: b8 f9 02 00 00 mov $0x2f9,%eax
8048c23: eb 18 jmp 8048c3d
8048c25: b8 81 03 00 00 mov $0x381,%eax
8048c2a: eb 11 jmp 8048c3d
8048c2c: e8 0a 06 00 00 call 804923b
8048c31: b8 00 00 00 00 mov $0x0,%eax
8048c36: eb 05 jmp 8048c3d
8048c38: b8 a1 00 00 00 mov $0xa1,%eax
8048c3d: 3b 44 24 08 cmp 0x8(%esp),%eax
8048c41: 74 05 je 8048c48
8048c43: e8 f3 05 00 00 call 804923b
8048c48: 8b 44 24 0c mov 0xc(%esp),%eax
8048c4c: 65 33 05 14 00 00 00 xor %gs:0x14,%eax
8048c53: 74 05 je 8048c5a
8048c55: e8 36 fb ff ff call 8048790 <__stack_chk_fail@plt>
8048c5a: 83 c4 1c add $0x1c,%esp
8048c5d: c3 ret
(2)破解过程与结果
1.
sub $0x1c,%esp
栈顶指针减去 28 个字节,为局部变量和临时数据腾出空间。
2.
mov %gs:0x14,%eax
调用了内核函数,这是一种栈溢出保护手段。
3.
mov %eax,0xc(%esp)
保存 eax 旧的值,便于调用返回时恢复现场;使用 mov 指令从 %gs:0x14 处加载一个值到 %eax 寄存器中,并通过 mov 指令将其拷贝到 %esp + 0xc 的位置。
4.
xor %eax,%eax
将 %eax 寄存器与自身进行异或操作,相当于将其清零。
5.
准备 sscanf 函数的参数:
lea 0x8(%esp),%eax
将倒数第一个参数传递给 eax 寄存器。(格式化的两个数中的第二个数)
push %eax
将%eax寄存器的值压入栈中。
lea 0x8(%esp),%eax
push %eax
重复两条指令,将倒数第二个参数压入栈中。(第一个数)
push $0x804a411
压入绝对地址的字符串,gdb x/s -> "%d %d"; 进一步印证了sscanf 格式化输入两个整数
pushl 0x2c(%esp)
局部变量(位于 phase_3 入口时的 esp + 0xC 处),这个是 sscanf 的第一个参数,也就是源字符串的首地址,是 main函数调用 phase_3 时传入的参数。
6.
call 8048810 <__isoc99_sscanf@plt>
调用 sscanf 格式化输入多参数。
所以,整理一下就是:
int ret = sscanf(p1, "%d %d", x , y);
7.
add $0x10,%esp
这里实现了esp + 16。在函数调用结束时,需要恢复栈的原始状态,以确保函数参数和局部变量在栈上的正确分配与释放。从栈上移除了刚刚传递给__isoc99_sscanf 函数的参数所占据的空间。这些参数可能被压入栈上作为函数调用的参数列表,并且不再需要它们,因此使用这个指令清理栈空间。push 了4次,刚好16字节。
8.
cmp $0x1,%eax
sscanf 函数返回值和立即数 1 进行比较,sscanf 返回值表示实际格式化成功的参数个数。
jg 8048be9
整理一下就是:
if ret > 1:
goto 0x8048be9;
else explode_bomb();
9.
8048be9: cmpl $0x7,0x4(%esp)
8048bee: ja 8048c2c
8048bf0: mov 0x4(%esp),%eax
8048bf4: jmp *0x804a1e0(,%eax,4)
这部分代码比较了位于栈上偏移esp + 4的值与7的大小关系,即判断第一个输入的数和7的大小关系,如果输入的数大于 7或者小于0,则跳转到地址8048c2c,引爆炸弹。注意点:为什么小于 0 的也会引爆?因为 jg是无符号运算大于的时候会跳转,负数采用补码参与运算时,始终比无符号的7要大,这就会导致直接跳转到爆炸处。
8048c2c: call 804923b
否则,将输入的第一个数复制到寄存器 %eax中,并通过间接跳转指令jmp *0x804a1e0(,%eax,4)进行跳转,跳转的位置取决于输入的参数1即现在的 eax 的值,0x804a1e0是一个存放地址值的数组的首地址。
这里我们需要借助工具知道该标签数组存放的是什么数据:
方法一:gdb:x/12w 0x804a1e0
方法二:readelf -x .rodata bomb,并定位0x804a1e0处:
明显,这是一个利用数组存放跳转地址的代码,结合课本已经学过的知识,不难猜测这是一个 switch-case 的“跳转表”,即根据变量(第一个输入的整数)的具体值,到数组中取得地址,然后进行相应的跳转。下面我们理一下跳转的具体过程:
输入的第一个数的数值 |
跳转后第一个指令 |
eax最终的值(十进制) |
0 |
mov $0xa1,%eax |
161 |
1 |
mov $0x3c0,%eax |
960 |
2 |
mov $0x2c6,%eax |
710 |
3 |
mov $0x2fd,%eax |
765 |
4 |
mov $0xc4,%eax |
196 |
5 |
mov $0x2a6,%eax |
678 |
6 |
mov $0x2f9,%eax |
761 |
7 |
mov $0x381,%eax |
897 |
> 7或者 < 0 |
call explode_bomb |
爆炸 |
然后我们再看一下这段代码的上下文:
明显看出这里的跳转表都是执行了 mov 指令将 eax 的值改变掉,然后都跳转到0x8048c3d 处,即下一条指令是 cmp 0x8(%esp),%eax 指令,我们知道 esp + 4 是用户输入的第一个整数,那么esp + 8 则是用户输入的第二个数,这里比较了eax 转换后的值和第二个数是否相等,比较的结果会改变标志寄存器(FLAGS),具体的操作需要看下面的条件跳转指令。
10.
je 8048c48
条件跳转指令,如果相等则跳转到0x8048c48 处,如果不相等,就会继续执行,然后执行 explode_bomb,引发爆炸。
11.
8048c48: mov 0xc(%esp),%eax
8048c4c: xor %gs:0x14,%eax
8048c53: je 8048c5a
8048c55: call 8048790 <__stack_chk_fail@plt>
8048c5a: add $0x1c,%esp
8048c5d: ret
以上代码就是之前条件跳转的地址,这里负责检查缓冲区是否溢出,并进行堆栈平衡,最后 ret 返回函数。
综上,要想通过本关卡,我们需要输入两个整数,第一个数在 0~7之间,然后第二个数必须等于对应的eax 计算得到的值,这一关卡有多组正解,这个在上面的跳转表中已经分析过,这里以“0 161”作为第一种正确的解去尝试,发现成功通关:
(1)相关汇编代码
08048c5e
: 8048c5e: 56 push %esi
8048c5f: 53 push %ebx
8048c60: 83 ec 04 sub $0x4,%esp
8048c63: 8b 54 24 10 mov 0x10(%esp),%edx
8048c67: 8b 74 24 14 mov 0x14(%esp),%esi
8048c6b: 8b 4c 24 18 mov 0x18(%esp),%ecx
8048c6f: 89 c8 mov %ecx,%eax
8048c71: 29 f0 sub %esi,%eax
8048c73: 89 c3 mov %eax,%ebx
8048c75: c1 eb 1f shr $0x1f,%ebx
8048c78: 01 d8 add %ebx,%eax
8048c7a: d1 f8 sar %eax
8048c7c: 8d 1c 30 lea (%eax,%esi,1),%ebx
8048c7f: 39 d3 cmp %edx,%ebx
8048c81: 7e 15 jle 8048c98
8048c83: 83 ec 04 sub $0x4,%esp
8048c86: 8d 43 ff lea -0x1(%ebx),%eax
8048c89: 50 push %eax
8048c8a: 56 push %esi
8048c8b: 52 push %edx
8048c8c: e8 cd ff ff ff call 8048c5e
8048c91: 83 c4 10 add $0x10,%esp
8048c94: 01 d8 add %ebx,%eax
8048c96: eb 19 jmp 8048cb1
8048c98: 89 d8 mov %ebx,%eax
8048c9a: 39 d3 cmp %edx,%ebx
8048c9c: 7d 13 jge 8048cb1
8048c9e: 83 ec 04 sub $0x4,%esp
8048ca1: 51 push %ecx
8048ca2: 8d 43 01 lea 0x1(%ebx),%eax
8048ca5: 50 push %eax
8048ca6: 52 push %edx
8048ca7: e8 b2 ff ff ff call 8048c5e
8048cac: 83 c4 10 add $0x10,%esp
8048caf: 01 d8 add %ebx,%eax
8048cb1: 83 c4 04 add $0x4,%esp
8048cb4: 5b pop %ebx
8048cb5: 5e pop %esi
8048cb6: c3 ret
08048cb7
: 8048cb7: 83 ec 1c sub $0x1c,%esp
8048cba: 65 a1 14 00 00 00 mov %gs:0x14,%eax
8048cc0: 89 44 24 0c mov %eax,0xc(%esp)
8048cc4: 31 c0 xor %eax,%eax
8048cc6: 8d 44 24 08 lea 0x8(%esp),%eax
8048cca: 50 push %eax
8048ccb: 8d 44 24 08 lea 0x8(%esp),%eax
8048ccf: 50 push %eax
8048cd0: 68 11 a4 04 08 push $0x804a411
8048cd5: ff 74 24 2c pushl 0x2c(%esp)
8048cd9: e8 32 fb ff ff call 8048810 <__isoc99_sscanf@plt>
8048cde: 83 c4 10 add $0x10,%esp
8048ce1: 83 f8 02 cmp $0x2,%eax
8048ce4: 75 07 jne 8048ced
8048ce6: 83 7c 24 04 0e cmpl $0xe,0x4(%esp)
8048ceb: 76 05 jbe 8048cf2
8048ced: e8 49 05 00 00 call 804923b
8048cf2: 83 ec 04 sub $0x4,%esp
8048cf5: 6a 0e push $0xe
8048cf7: 6a 00 push $0x0
8048cf9: ff 74 24 10 pushl 0x10(%esp)
8048cfd: e8 5c ff ff ff call 8048c5e
8048d02: 83 c4 10 add $0x10,%esp
8048d05: 83 f8 0a cmp $0xa,%eax
8048d08: 75 07 jne 8048d11
8048d0a: 83 7c 24 08 0a cmpl $0xa,0x8(%esp)
8048d0f: 74 05 je 8048d16
8048d11: e8 25 05 00 00 call 804923b
8048d16: 8b 44 24 0c mov 0xc(%esp),%eax
8048d1a: 65 33 05 14 00 00 00 xor %gs:0x14,%eax
8048d21: 74 05 je 8048d28
8048d23: e8 68 fa ff ff call 8048790 <__stack_chk_fail@plt>
8048d28: 83 c4 1c add $0x1c,%esp
8048d2b: c3 ret
(2)破解过程与结果
首先,第四关和第三关输入函数的调用代码类似,简单概括如下图:
我们接下来分析不一样的地方:
1.
8048ce6: cmpl $0xe,0x4(%esp)
8048ceb: jbe 8048cf2
8048ced: call 804923b
这三行代码检查了格式化输入后的第一个整数,看第一个整数是否不大于0xe,(jbe是条件跳转,当无符号数整数比较A不大于B 时,发生跳转)。如果大于 0xe(十进制的14)或者为负值,则不跳转,继续执行下一条指令,造成炸弹爆炸。
所以,以上代码可以理解为如下伪代码:
unsigned int v1;
int v2;
if ( __isoc99_sscanf(p1, "%d %d", &v1, &v2) != 2 || v1 > 14 )
explode_bomb();// 输入的数不是两个,或第一个数比14大,或第一个数是负数,则都会爆炸。
尤其要注意无符号数比较的问题,不能为负值,负值就爆炸。
2.
8048cf5: push $0xe
8048cf7: push $0x0
8048cf9: pushl 0x10(%esp)
8048cfd: call 8048c5e
这段代码首先准备了函数调用的参数,一共有三个参数,第一个数是我们输入的第一个数,第二个为数0,第三个为数14。然后我们调用call func4 指令,进入 func4 函数。
接着,分析 func4 的功能:
1). 首先这段代码实现了将调用者的 esi 和ebx 压栈,保存现场。然后又预分配了4个字节的空间:
8048c5e: push %esi
8048c5f: push %ebx
8048c60: sub $0x4,%esp
2). 这三条指令是计算了三个局部变量的值:
8048c63: mov 0x10(%esp),%edx ; SrcV:指定的目标数字
8048c67: mov 0x14(%esp),%esi ; 0 -> st:表示搜索范围的起始数字
8048c6b: mov 0x18(%esp),%ecx ; 14 -> ed:表示搜索范围的结尾数字
3). 这段指令序列实现了用 ebx 保存当前 ed – st 的结果。
8048c6f: mov %ecx,%eax ; 临时拷贝 ed 到 eax 寄存器
8048c71: sub %esi,%eax ; ed - st,结果保存在 eax
8048c73: mov %eax,%ebx ; 结果转移到 ebx
4). 这段指令序列实现了将 ebx 中的数值除以2,有符号数不能够整除时向零取整。并将结果继续保存到 ebx 中,所以 %ebx = (ed - st) / 2
8048c75: shr $0x1f,%ebx ; 逻辑右移 31 位,相当于取符号位
8048c78: add %ebx,%eax ; 对于被除数为负数且不能被整除的情况做向零取整,需要加上1,正好符号位是1,所以利用直接加符号位,将不影响正数的计算(这是一个技巧)
8048c7a: sar %eax ; eax 的值算数右移 1 位,相当于有符号数除法,除数为 2.
8048c7c: lea (%eax,%esi,1),%ebx ; 用 st 加上 st 和 ed 之间距离的一半,计算结果是中位数,放到 ebx 中
自此,我们可以用伪代码概括一下上面一段指令的功能:
int mid = (ed - st) / 2 + st; // 求中位数
5). 根据上面计算的中位数(mid)和目标数(SrcV)之间的大小关系,对函数自身进行递归调用,通过分析参数的准备过程不难推断出 func4 的算法是二分,即目标数小于中间数的时候,目标数只能在中位数左侧的搜索区间内,相反只能在右侧的搜索区间内,从而缩小搜索范围。
8048c7f: cmp %edx,%ebx ; 要查找的数 SrcV(在 edx 上),和 ebx 也就是中位数比较
8048c81: jle 8048c98
; 如果 中位数 <= 目标数,则跳转到 0x8048c98
我们现在直接转到 0x8048c98处看:
8048c98: mov %ebx,%eax
将 mid 放到 eax 中,当 下面跳转完成时,用于函数返回 eax,也就是返回 mid。(这地方和下面似乎没有直接关系,实际上决定了递归返回时,第一弹栈返回的值)
8048c9a: cmp %edx,%ebx
8048c9c: jge 8048cb1
中位数和目标数比较,如果中位数 >= 目标数,结合前面的条件中位数 <= 目标数,两个条件要同时成立,则中位数 == 目标数,跳转,并进行递归返回。
8048c9e: sub $0x4,%esp
如果不相等,那么就是 中位数 < 目标数,此时继续执行,这一行是为了递归调用自身而预分配栈空间,用于存放实参
8048ca1: push %ecx
准备第三个参数 ed 不变。
8048ca2: lea 0x1(%ebx),%eax
ebx + 1 也就是中位数 + 1,赋值给 eax ,然后 eax 压栈,作为第二个参数
8048ca5: push %eax
8048ca6: push %edx
edx 一直没被修改,这里 edx 存储整个递归需要用到的目标数字,这一行是准备递归的第一个参数。
8048ca7: call 8048c5e
递归调用自身
8048cac: add $0x10,%esp
8048caf: add %ebx,%eax
将递归调用的func4返回值和ebx 寄存器的值累加,即将返回值和上一轮中位数相加,作为新的返回值(不一定是最后的返回值,一个连续的递归返回的过程)。
8048cb1: add $0x4,%esp ; 恢复栈指针,然后弹栈,最后函数返回
8048cb4: pop %ebx ; 恢复调用者的ebx 寄存器值
8048cb5: pop %esi ; 恢复 esi 的值
8048cb6: ret 函数返回
所以我们可以得知,当中位数 <= 目标数时,如果 中位数 == 目标数,则递归返回;
如果 中位数 < 目标数,则进入右侧搜索分支,伪代码如下:
int func4(int SrcV, int st, int ed){
mid = (ed – st) / 2 + st;
if (mid < SrcV){
return mid + func4(SrcV, mid + 1, ed);
…… // 另外一个分支还未分析
return mid;
}
6). 在第5部分如果条件不满足,那么会继续向下执行,进入另外一个分支:
8048c83: sub $0x4,%esp
如果中位数 > 目标数,此时继续执行,这一行是为了递归调用自身而预分配栈空间,用于为存储实参准备栈空间
8048c86: lea -0x1(%ebx),%eax
8048c89: push %eax
准备递归调用的第三个参数 ebx - 1放到 eax ,即 mid - 1,然后 eax 压栈
8048c8a: push %esi
准备递归调用的第二个参数 保持当前 st 不变
8048c8b: push %edx
edx 存储整个递归需要用到的目标数字,这一行是准备递归的第一个参数。
8048c8c: call 8048c5e
递归调用自身
8048c91: add $0x10,%esp
局部堆栈平衡,恢复调用自身前后消耗的空间
8048c94: add %ebx,%eax
eax 存储后一轮 func4 的返回值,将返回值和这一轮的中位数累加求和,新的值放到 eax 中,递归返回时,返回 eax 中的值
8048c96: jmp 8048cb1
这是一句无条件跳转,直接跳转到函数返回处。
对于 func4 的两个分支的调用和跳转比较复杂,可以用下面的图整理汇编代码:
总结一下就是,最后一次递归调用必定是 目标数等于此轮的中位数,然后此轮返回 mid,弹栈到上一轮,就是用此轮的 mid 加上上一轮的 mid,直至最后一轮的 return mid + func4(SrcV, st, ed);递归返回。
func4 分析完了,我们可以回到 phase_4 上,去判断程序是如何检验 func4 的返回值的:
7). 在 8048cfd 的一条指令,就是之前分析的在 phase_4 中调用 func4 的指令。
由于调用 func4 准备了3个参数并为了数据对齐减小了一次栈指针,esp + 16 用于恢复堆栈指针。
8048cfd: call 8048c5e
8048d02: add $0x10,%esp
8). 将func4 函数返回值与立即数 0xa (十进制的10 )进行比较,如果不相等则跳转到0x8048d11 处,即调用explode_bomb 使得炸弹爆炸。
8048d05: cmp $0xa,%eax
8048d08: jne 8048d11
9). 接着将输入的第二个数和立即数 10 比较,如果相等,则跳转到0x8048d16 处继续执行,如果不相等,则向下执行explode_bomb,引爆炸弹。
8048d0a: cmpl $0xa,0x8(%esp)
8048d0f: je 8048d16
8048d11: call 804923b
10). 检查缓冲区是否溢出,没有溢出则执行 add $0x1c,%esp 清理现场并使得phase_4函数返回。
8048d16: mov 0xc(%esp),%eax
8048d1a: xor %gs:0x14,%eax
8048d21: je 8048d28
8048d23: call 8048790 <__stack_chk_fail@plt>
8048d28: add $0x1c,%esp
8048d2b: ret
综合上述对 phase_4 的分析,此关卡要求输入两个数,第二个数必须是 10,第一个数x 需要满足如下条件:
1. x使得func4的返回值等于 10;
2. x在 0~14之间。
由于 func4 是 x 等于中位数时才递归返回,返回值为每一次搜索的中位数的累加和,由于第一轮是从[0, 14]搜索,中位数是 7,10 – 7 = 3, 不难看出第二轮的中位数是3,由于只有当目标值等于中位数才返回,所以目标数 x 必须等于中位数 3。也就是说,本关卡答案应该是 “3 10”。
下面给出概念验证代码(C++):
#include
using namespace std;
// 折半查找
int func4(int x, int st, int ed) {
int mid;
mid = (ed + st) >> 1;
if (mid > x)
return mid + func4(x, st, mid - 1);
if (mid < x)
return mid + func4(x, mid + 1, ed);
//if (mid == x):递归返回条件
return mid;
}
int main() {
for (int i = 0; i <= 14; i++) {
if (func4(i, 0, 14) == 10)
cout << i << endl;
}
return 0;
}
运行得到如下结果:
这和我们计算得到的第一个数一致,即第一个数的正解是 3。
将结果“3 10”带入炸弹,能够成功通过本关:
(1)相关汇编代码
08048d2c
: 8048d2c: 83 ec 1c sub $0x1c,%esp
8048d2f: 65 a1 14 00 00 00 mov %gs:0x14,%eax
8048d35: 89 44 24 0c mov %eax,0xc(%esp)
8048d39: 31 c0 xor %eax,%eax
8048d3b: 8d 44 24 08 lea 0x8(%esp),%eax
8048d3f: 50 push %eax
8048d40: 8d 44 24 08 lea 0x8(%esp),%eax
8048d44: 50 push %eax
8048d45: 68 11 a4 04 08 push $0x804a411
8048d4a: ff 74 24 2c pushl 0x2c(%esp)
8048d4e: e8 bd fa ff ff call 8048810 <__isoc99_sscanf@plt>
8048d53: 83 c4 10 add $0x10,%esp
8048d56: 83 f8 01 cmp $0x1,%eax
8048d59: 7f 05 jg 8048d60
8048d5b: e8 db 04 00 00 call 804923b
8048d60: 8b 44 24 04 mov 0x4(%esp),%eax
8048d64: 83 e0 0f and $0xf,%eax
8048d67: 89 44 24 04 mov %eax,0x4(%esp)
8048d6b: 83 f8 0f cmp $0xf,%eax
8048d6e: 74 2e je 8048d9e
8048d70: b9 00 00 00 00 mov $0x0,%ecx
8048d75: ba 00 00 00 00 mov $0x0,%edx
8048d7a: 83 c2 01 add $0x1,%edx
8048d7d: 8b 04 85 00 a2 04 08 mov 0x804a200(,%eax,4),%eax
8048d84: 01 c1 add %eax,%ecx
8048d86: 83 f8 0f cmp $0xf,%eax
8048d89: 75 ef jne 8048d7a
8048d8b: c7 44 24 04 0f 00 00 movl $0xf,0x4(%esp)
8048d92: 00
8048d93: 83 fa 0f cmp $0xf,%edx
8048d96: 75 06 jne 8048d9e
8048d98: 3b 4c 24 08 cmp 0x8(%esp),%ecx
8048d9c: 74 05 je 8048da3
8048d9e: e8 98 04 00 00 call 804923b
8048da3: 8b 44 24 0c mov 0xc(%esp),%eax
8048da7: 65 33 05 14 00 00 00 xor %gs:0x14,%eax
8048dae: 74 05 je 8048db5
8048db0: e8 db f9 ff ff call 8048790 <__stack_chk_fail@plt>
8048db5: 83 c4 1c add $0x1c,%esp
8048db8: c3 ret
(2)破解过程与结果
1.首先,是数据的格式化输入,这里简单分析一下即可,0x804a411处的字符串是"%d %d",说明格式化输入两个整数。
现在我们继续分析:
2.
8048d60: mov 0x4(%esp),%eax
8048d64: and $0xf,%eax
8048d67: mov %eax,0x4(%esp)
这三条指令实现了将第一个读入的数(放到 eax上,作为局部变量)和十进制的 15 按位与,并将按位与的结果再赋值给第一个参数,所以第一个参数是它自身与 15 按位与之后的结果。
在“数据的机器级表示和处理”实验中,我们已经认识到按位与有一个特殊性质,就是按位掩码的性质:a & 1111,如果a数值比全一掩码小,则结果是它本身,如果比全一掩码大,则结果为 0。
比如 13对应的机器数是 1101,而15对应的机器数是 1111,那么按位与的结果就是
1101,也就是保持13不变,而如果数 a 比较大,比如16是 0001 0000 它和15 的机器数 0000 1111 按位与,结果是 0。
3. 下面的指令实现将 eax 中的值(按位与的结果)与 15 进行比较,后面的条件转移指令是 je,满足 %eax == 15 || %eax == 0 条件就发生跳转。(尤其注意这里的eax 为 0 也是跳转的),给出的跳转地址是:0x8048d9e。
8048d6b: 83 f8 0f cmp $0xf,%eax
8048d6e: 74 2e je 8048d9e
我们转到0x8048d9e 发现它是一条 call 指令,调用了 explode_bomb 使得炸弹爆炸。所以,当且仅当第一个数为 [1, 14] 之间的整数时,才能使得炸弹不会立即爆炸。
4. 接下来的指令需要结合上下文去分析:
8048d70: mov $0x0,%ecx
8048d75: mov $0x0,%edx
8048d7a: add $0x1,%edx
初步猜测这三条指令用到的 ecx 和 edx 寄存器上的值都是局部变量。且 ecx 和edx 都初始化为 0 ,然后 edx 自增1。
5. 8048d7d: mov 0x804a200(,%eax,4),%eax:这应该是一条计算数组元素地址,并将其数据放到 eax 上的指令,也就是说,eax 变成了数组对应位置上的数据。
6.
8048d84: add %eax,%ecx
8048d86: cmp $0xf,%eax
8048d89: jne 8048d7a
首先将 eax 的值累加到 ecx 中,然后单独判断 eax 的值是否不等于 15,如果不等于15,则跳转到0x8048d7a处继续执行,我们展开上下文去分析:
不难发现这里是一个循环结构,while 循环的条件是数组取出的数值不等于 15。并且可以总结出局部变量在寄存器中的含义:
%ecx : 变量Sum,用于存储每一趟取出的数组元素的累加和;
%edx : 变量 Count,用作 while 循环的计数器;
%eax : 当前取出的数值Index,初始化为用户输入的第一个数,后面每一轮更新为数组元素的值,用作下一轮取出数组元素的索引。
7. 现在如果取出的数是15,不满足循环的条件,此时不再跳转而是继续向下执行。这条指令将第一个输入的数变为15,个人认为是无效指令,因为循环已经结束了,它不影响循环或者下文内容。
8048d8b: c7 44 24 04 0f 00 00 movl $0xf,0x4(%esp)
8048d92: 00
8.
8048d93: cmp $0xf,%edx
8048d96: jne 8048d9e
前面已经分析过 edx 存放的是循环的次数,这里比较循环次数是否不等于 15,如果循环次数不等于 15,则跳转到 0x8048d9e。我们已经知道该处是调用炸弹爆炸指令,所以这段指令本意是检查循环次数是不是 15 次,如果不是则爆炸。
9.
8048d98: cmp 0x8(%esp),%ecx
8048d9c: je 8048da3
8048d9e: call 804923b
这段代码将用户输入的第二个数和 ecx 寄存器的值进行比较,而 ecx 寄存器是循环的累加和 Sum,只有当第二个数和累加和相等的时候,才利用跳转越过爆炸指令。
10. 最后这段指令序列的功能和前面的关卡一致,就是检查是否存在缓冲区溢出,以及进行函数的返回过程。
8048da3: mov 0xc(%esp),%eax
8048da7: xor %gs:0x14,%eax
8048dae: je 8048db5
8048db0: call 8048790 <__stack_chk_fail@plt>
8048db5: add $0x1c,%esp
8048db8: ret
【通关方法】
由上面的分析我们知道,循环是对一个首地址为0x804a200 的数组进行循环调用,我们需要获得数组的具体信息才能够进一步破解该关卡,可以有多种方法获取数组的上下文。
第一种,通过 dbg 动态调试获取:
我们在上文中分析时,汇编指令检查了循环结束时计数器是否记录了15次读数结果,由于结束循环的一次并没有递增循环计数器,所以,我们实际上读取了16个数,并根据尝试地址之后的20个4字节间隔的地址上的数据,会发现超过16个偏移之后的数据明显和之前的不同,进一步印证了该数组的大小为 16。
第二种,直接用 readelf 获取整个 rodata 节的数据,然后找到这个偏移:
整理后,可以得到数组的具体内容如下:
i | a[i] | i | a[i] |
0 | 10 | 8 | 0 |
1 | 2 | 9 | 4 |
2 | 14 | 10 | 1 |
3 | 7 | 11 | 13 |
4 | 8 | 12 | 3 |
5 | 12 | 13 | 9 |
6 | 15 | 14 | 6 |
7 | 11 | 15 | 5 |
根据对函数的分析可知,我们结束循环的条件是 15, 也就是最后取出来的数是15,要求取16次数,也就是把每个数都取一次。每一轮取出的数都是表示下一个数在数组中位置的索引。为了得出输入的第一个数,我们可以通过“逆推”思想逐个分析出前一个数是多少。
最后一个数是15,它的索引是 6 ,所以15 的前一个数是 6,再看6 的索引是14,所以6前面一个数是 14 ,依此类推可以得到逆序序列:15 >> 6 >> 14 >> 2 >> 1 >> 10 >> 0 >> 8 >> 4 >> 4 >> 9 >> 13 >> 11 >> 7 >> 3 >> 12 >> 5。所以第一个数是数字 5 。而第二个数则要等于累加和(不包括第一次输入的数),也就是 12 + 3 + 7 + 11 + 13 + 9 + 4 + 8 + 0 + 10 + 1 + 2 + 14 + 6 + 15 = 115 。
所以本题答案是 “5 115”,到测试环境下验证通过:
下面为了总结汇编代码的功能,给出还原出来的C语言代码。
概念验证代码(C++):
#include
int array_3250[] = {
10, 2, 14, 7, 8, 12, 15, 11,
0, 4, 1, 13, 3, 9, 6, 5
};
int d_phase_5(int a) {
int count = 0, sum = 0;
int index = a;
do {
count++;
index = array_3250[index];
sum += index;
} while ( index != 15 );
if ( count != 15)// 判断是否满足返回后不爆炸的条件,如果爆炸,则返回 -1
return -1;
else
return sum;
}
int main() {
for (int i = 1; i < 15; i++) {
printf("a = %d, b = %d\n", i, d_phase_5(i));
}
return 0;
}
编译运行结果如下:
(1)相关汇编代码
08048db9
: 8048db9: 56 push %esi ; 准备环节
8048dba: 53 push %ebx
8048dbb: 83 ec 4c sub $0x4c,%esp
8048dbe: 65 a1 14 00 00 00 mov %gs:0x14,%eax
8048dc4: 89 44 24 44 mov %eax,0x44(%esp)
8048dc8: 31 c0 xor %eax,%eax
8048dca: 8d 44 24 14 lea 0x14(%esp),%eax
8048dce: 50 push %eax
8048dcf: ff 74 24 5c pushl 0x5c(%esp)
8048dd3: e8 a0 04 00 00 call 8049278
8048dd8: 83 c4 10 add $0x10,%esp ; 第一个部分
8048ddb: be 00 00 00 00 mov $0x0,%esi
8048de0: 8b 44 b4 0c mov 0xc(%esp,%esi,4),%eax
8048de4: 83 e8 01 sub $0x1,%eax
8048de7: 83 f8 05 cmp $0x5,%eax
8048dea: 76 05 jbe 8048df1
8048dec: e8 4a 04 00 00 call 804923b
8048df1: 83 c6 01 add $0x1,%esi
8048df4: 83 fe 06 cmp $0x6,%esi
8048df7: 74 1b je 8048e14
8048df9: 89 f3 mov %esi,%ebx
8048dfb: 8b 44 9c 0c mov 0xc(%esp,%ebx,4),%eax
8048dff: 39 44 b4 08 cmp %eax,0x8(%esp,%esi,4)
8048e03: 75 05 jne 8048e0a
8048e05: e8 31 04 00 00 call 804923b
8048e0a: 83 c3 01 add $0x1,%ebx
8048e0d: 83 fb 05 cmp $0x5,%ebx
8048e10: 7e e9 jle 8048dfb
8048e12: eb cc jmp 8048de0
8048e14: 8d 44 24 0c lea 0xc(%esp),%eax ; 第二个部分
8048e18: 8d 5c 24 24 lea 0x24(%esp),%ebx
8048e1c: b9 07 00 00 00 mov $0x7,%ecx
8048e21: 89 ca mov %ecx,%edx
8048e23: 2b 10 sub (%eax),%edx
8048e25: 89 10 mov %edx,(%eax)
8048e27: 83 c0 04 add $0x4,%eax
8048e2a: 39 c3 cmp %eax,%ebx
8048e2c: 75 f3 jne 8048e21
8048e2e: bb 00 00 00 00 mov $0x0,%ebx ; 第三个部分
8048e33: eb 16 jmp 8048e4b
8048e35: 8b 52 08 mov 0x8(%edx),%edx
8048e38: 83 c0 01 add $0x1,%eax
8048e3b: 39 c8 cmp %ecx,%eax
8048e3d: 75 f6 jne 8048e35
8048e3f: 89 54 b4 24 mov %edx,0x24(%esp,%esi,4)
8048e43: 83 c3 01 add $0x1,%ebx
8048e46: 83 fb 06 cmp $0x6,%ebx
8048e49: 74 17 je 8048e62
8048e4b: 89 de mov %ebx,%esi
8048e4d: 8b 4c 9c 0c mov 0xc(%esp,%ebx,4),%ecx
8048e51: b8 01 00 00 00 mov $0x1,%eax
8048e56: ba 54 d1 04 08 mov $0x804d154,%edx ; 结点的首地址
8048e5b: 83 f9 01 cmp $0x1,%ecx
8048e5e: 7f d5 jg 8048e35
8048e60: eb dd jmp 8048e3f
8048e62: 8b 5c 24 24 mov 0x24(%esp),%ebx ; 第四个部分
8048e66: 8d 44 24 24 lea 0x24(%esp),%eax
8048e6a: 8d 74 24 38 lea 0x38(%esp),%esi
8048e6e: 89 d9 mov %ebx,%ecx
8048e70: 8b 50 04 mov 0x4(%eax),%edx
8048e73: 89 51 08 mov %edx,0x8(%ecx)
8048e76: 83 c0 04 add $0x4,%eax
8048e79: 89 d1 mov %edx,%ecx
8048e7b: 39 c6 cmp %eax,%esi
8048e7d: 75 f1 jne 8048e70
8048e7f: c7 42 08 00 00 00 00 movl $0x0,0x8(%edx)
8048e86: be 05 00 00 00 mov $0x5,%esi ; 最后一个部分
8048e8b: 8b 43 08 mov 0x8(%ebx),%eax
8048e8e: 8b 00 mov (%eax),%eax
8048e90: 39 03 cmp %eax,(%ebx)
8048e92: 7d 05 jge 8048e99
8048e94: e8 a2 03 00 00 call 804923b
8048e99: 8b 5b 08 mov 0x8(%ebx),%ebx
8048e9c: 83 ee 01 sub $0x1,%esi
8048e9f: 75 ea jne 8048e8b
8048ea1: 8b 44 24 3c mov 0x3c(%esp),%eax
8048ea5: 65 33 05 14 00 00 00 xor %gs:0x14,%eax
8048eac: 74 05 je 8048eb3
8048eae: e8 dd f8 ff ff call 8048790 <__stack_chk_fail@plt>
8048eb3: 83 c4 44 add $0x44,%esp
8048eb6: 5b pop %ebx
8048eb7: 5e pop %esi
8048eb8: c3 ret
(2)破解过程与结果
1.下面这段指令序列开辟栈空间并为堆栈检测准备数据(和前面第二关卡类似):
8048db9: push %esi
8048dba: push %ebx
8048dbb: sub $0x4c,%esp
8048dbe: mov %gs:0x14,%eax
8048dc4: mov %eax,0x44(%esp)
8048dc8: xor %eax,%eax
2.这段指令序列实现读入六个数字
8048dca: lea 0x14(%esp),%eax
8048dce: push %eax ; 数组的首地址
8048dcf: pushl 0x5c(%esp) ; 输入字符串的首地址
8048dd3: call 8049278
8048dd8: add $0x10,%esp ; 恢复栈指针位置
我们也可以通过 gdb 来分析 read_six_numbers 具体做了什么(这种方法是我们前面没详细提过的):
首先在终端中使用 gdb bomb 启动调试实例:
然后依次打下几个关键断点:
b phase_6 # 在本关卡入口断下
b read_six_numbers # 断下读入数字函数(可选)
b __isoc99_sscanf@plt # 在 sscanf 函数处断下(用于知道输入格式)
b explode_bomb # 防止炸弹爆炸
然后,填入前几关的Code 到文本文件,并使用 r 指令运行炸弹程序。
由于每一关都会调用 sscanf 函数,程序运行到断点处就会被断下,所以需要手动输入 continue 继续运行,直到第六关开始,我们首先输入一串数字,并在随后触发断点sscanf ,此时就不需要继续过断点了。而是使用stepi 单步步入指令,可以让我们知道 sscanf 的参数:
显然,sscanf 函数读取了6个整形数字,即 int 类型的数据,这和前面第二关静态分析的结果一致。
3. 这段指令序列首先利用 esi 存储数组的索引变量 i ,并初始化为 0 。然后利用 (%esp + 0xc) + 4 * i 计算索引为 i 的数组元素的地址,并利用 mov 指令将该地址上的数据传递给 eax 寄存器,据此,%eax = a[i]。然后将 eax 自减 1 ,并将结果和立即数 5 进行比较,jbe 是无符号比较不大于时跳转,即 a[i] – 1 <= 5 就跳转至0x8048df1 处。否则向下执行爆炸过程。总的来说,这段指令实现取出数组索引为 i 的元素,并要求该数值不超过 6,当比6大的时候,就会发生爆炸。
8048ddb: mov $0x0,%esi
8048de0: mov 0xc(%esp,%esi,4),%eax
8048de4: sub $0x1,%eax
8048de7: cmp $0x5,%eax
8048dea: jbe 8048df1
8048dec: call 804923b
4. 这段指令序列实现将前面的索引 i (esi) 自增1,并将新的值与 6进行比较,如果等于 6 就会跳转到 0x8048e14 处[ if (++i == 6): goto 0x8048e14 ],否则向下执行。
8048df1: add $0x1,%esi
8048df4: cmp $0x6,%esi
8048df7: je 8048e14
首先看看向下执行的情况[ if (++i < 6) ]:
【TODO】
首先是将 esi 的值拷贝到 edx 寄存器,也就是 %edx = %esi = i。我们把edx 的索引称为 j。
8048df9: mov %esi,%ebx
然后,利用索引 edx 取出数组元素并赋值给 eax 作为临时变量,然后将其与它前一个数组元素进行比较,如果相等,则会向下执行爆炸过程,如果不相等,则会跳过爆炸过程继续执行。
可以看出,这段指令序列将索引j 首先递增1,然后判断j 是否小于等于 5,小于等于5就会跳转回到0x8048dfb。这是这段指令的起始部分(绿色标记)。显然,这是一个循环,所以我们可以总结为j从1开始每次取前一个数和这个数比较,要求输入的6个数每个数都不相等,否则爆炸。
接下来,当j大于5的时候,跳出这个循环,并向下执行。下一条指令,是无条件跳转至 0x8048de0,说明esi 控制的也是一个循环,结合上下文不难看出这是一个双重循环:
我们再回到上面第3点分析的汇编代码:
8048ddb: mov $0x0,%esi
8048de0: mov 0xc(%esp,%esi,4),%eax ; 根据索引 i 取出目标数
8048de4: sub $0x1,%eax
8048de7: cmp $0x5,%eax
8048dea: jbe 8048df1
8048dec: call 804923b
8048df1: add $0x1,%esi ; 自增索引 i
8048df4: cmp $0x6,%esi ; 索引值小于 6
8048df7: je 8048e14
; 跳出外循环
这段指令的含义是每轮取出一个数,并自增索引i ,也就是把数组中每个数都遍历一遍。外层循环的终止条件前面已经分析过,就是数组索引自增后如果等于6则不再向下执行内循环,而是跳转到0x8048e14,该地址是位于外循环外面的位置(内循环跳转回头指令的下一条指令)。下图展示了不满足条件时,指令跳转的大致位置:
总结一下,这整段指令序列的含义就是输入六个数,要求每一个数都不大于6,并且6个数个个都不相等;最后当遍历完成时,跳出内外循环。
5.下面这段指令和前面数组遍历类似,eax 现在存储的是六个数的首地址,ebx 是末尾元素的地址,看来接下来第二部分的过程还是和六个数的遍历有关系。
8048e14: lea 0xc(%esp),%eax
8048e18: lea 0x24(%esp),%ebx
6. 这段指令序列的功能是遍历数组的每一个元素,将其修改为 7 – 原始值之后的结果。
7. 当上一段指令对修改完成数组元素后,就会结束循环而继续向下执行,下面这一段也是一个循环的过程:
下面我们来结合上面的图逐步分析:
1.
mov $0x0,%ebx
实现了将 ebx 作为局部变量,并初始化为 0 ;
2.
jmp 8048e4b
无条件跳转到指定位置。(图中第三块区域);
3.
mov %ebx,%esi
将 ebx 的值备份到 esi;
4.
mov 0xc(%esp,%ebx,4),%ecx
由于 esp + 0xc 是用户输入的六个数(数组)的首地址,而 ebx 初始化为 0,所以第一轮就是取出第一个数放到 ecx 上,并由此推测这是每轮取出第 i 个数字,i 就是 ebx 寄存器上的值;
5.
mov $0x1,%eax
将 eax 初始化为 1,分析到这里应该还不清楚 eax 具体有什么意义;
6. 将立即数 0x804d154 放到 edx 上,这里的数据是放在静态数据区的,分析到这应该还不清楚 edx 的具体含义,所以继续分析;
7.
cmp $0x1,%ecx
将第4步 a[i] 和立即数 1 进行比较,下一步条件跳转为 jg ,表明,当 a[i] 大于 1 的时候,发生跳转;
8.
mov 0x8(%edx),%edx
edx + 8 处的值又重新赋值给edx,我们知道 edx
其实是一个地址,但是为什么不是取 edx + 4 的偏移,而是取 edx + 8 呢?我先存留疑问,继续往下看:
9.
add $0x1,%eax
这个指令实现将 eax 寄存器的值增加 1,继续往下看;
10.
cmp %ecx,%eax
jne 8048e35
这段指令序列实现了判断 eax 的值是否不等于 ecx 的值,根据上文,我们知道 ecx 是用户输入的数中第 i 个数的值,0x8048e35 将下一条指令又指向了mov 0x8(%edx),%edx这条指令,显然从 0x8048e35至0x8048e3d 指令之间是一个循环:
这部分涉及到的寄存器比较多,也就是有很多局部变量的定义和使用,且代码的结构中有大量跳转,需要仔细分析。现在,我们重新梳理一下上文分析过程中存在疑问的部分:
<1> :%eax 表示什么?
%eax 就是0x8048e35至0x8048e3d 指令之间循环(下图中 {5} – {6} 途径)需要用到的循环引用计数。eax被初始化为1,当循环执行次数达到一定限制,就会结束循环。
<2>:%ebx和 %ecx 分别表示什么?
在刚进入 0x8048e2e 这段指令序列的时候,我们直接无条件跳转到了下图的蓝色标记{2}指令处,ebx 在跳转前已经被初始化定义为 0,在后续过程中,我们发现只要 (%ecx) 大于 1,则进入内循环(红色箭头),否则通过无条件跳转(灰色箭头)直接绕过内循环,而无论是否执行内循环,外循环都是要进行的,即需要执行内循环就走 {3} – {4} 途径,否则走 {7} – {3} 途径。
在图中,我在 0x8048e49 和它后一条指令之间划了一条分割线,因为我认为第二部分是循环准备下一个数据(数组中后一个数)的过程,而上面第一部分则主要是判断循环维持的条件以及对满足条件的数进行处理的过程。那么,%ebx 到底表示什么?
我们知道 *(%esp + 0xc) 是用户输入的六个数组成的数组的首地址,数据传送指令 mov 0xc(%esp,%ebx,4),%ecx 可以理解为,取出数组中索引为 %ebx 的数值,并把它保存到 ecx 寄存器上。第一轮循环的时候的 %ebx = 0,那么此时执行完这条指令后,%ecx = a[0]。在执行外循环时,会逐级递增 ebx 寄存器的值(黑色框线演示了 edx 增加的关键帧)。所以,我们可以确定 %ebx 表示遍历素组时所用的索引,%ecx 则是每轮外循环数组对应索引 %ebx 的数值。并且,我们发现,外层循环结束条件就是当索引 %ebx 等于 6 ,此时循环自天蓝色箭头部分跳出表示循环的橙色框线之外。进一步印证了外循环是在遍历用户输入的大小为六个整数的数组。
<3> :%esi 表示什么?
观察下图,esi 是用作循环中将计算好的edx 的值复制到堆栈上的偏移量,而ebx索引则是用于顺序读入用户输入的6个数,他们作为数组存在堆栈上,推测这里 esi 索引控制的 mov 指令应该也是向一个数组写入数据,而这个数组的更新顺序应该是和用户输入的数组的读入顺序是一致的,即在写入数据前始终保证 $ebx = $esi。即两个其实是都是索引,并且索引值是同步的,只是编译器把他们当作两个索引来看。
<4> :%edx 表示什么?
%edx 其实和内层循环有关系,我们单独框选一下和 %edx 有关的指令,如下图所示:
对于该指令,其实到目前为止都比较疑惑,为什么取到的新地址是当前地址偏移 8 的地址上的数值?难道是一个结构体?为了验证想法,我准备通过 gdb 动态调试来分析,下面是我的分析过程:
首先,我们需要启动调试实例,并设置好断点,关于必须设置的断点的解释如下:
b phase_6:这是第六关的入口点,如果断点被命中,则说明我们进入了第六关;
b explode_bomb:这是炸弹爆炸的关键函数,在该函数上下断点可以防止触发爆炸时通知你的导师(不想让老师那边记录你炸弹爆炸次数就一定断下它,当然你也可以把该函数的入口点的汇编直接改成jmp返回值地址然后平衡堆栈,就可以绕过爆炸处理,类似于内联挂钩技术,相对复杂就不谈了,一般只要调试的时候一步步来,不要过了就行);
b *0x8048e2e:这是进入这段双层循环后的第一条指令,我们可以将程序在循环开始前中断下来,以便于后面单步调试这一段;
b *0x8048e62:这是跳出这段双层循环继续执行时的一条指令,也就是说双层循环已经结束,我们断下来,可以防止程序继续向下执行;
(注意:不应该在判断是否跳出循环的地方下断,而是在跳出后第一条指令处下断,不然每次循环都会被断下。)
然后,我们 run anx.txt 运行一下:
可以看到程序在等待第六关的输入,这里我们输入合适的值(例如:“6 5 4 3 2 1”),使得前面部分的校验能够通过:
现在“启用在调试时显示下一条要执行的汇编指令”的设置(set disassemble-next-line on),并使用 continue 继续执行程序:
继续执行后结果如下图,程序触发了 *0x8048e2e 断点,也就是循环开始处的断点,说明已经进入目标循环。并且会显示每一步的下一条即将执行的指令,在这里我们使用单步不进入(函数)指令 ni 一步步对循环进行调试,基本方式如下:
下面我们就可以一直单步,并在但不的过程中一边对照着我们的完整汇编指令,逐行去关注当前执行到哪了。当我们看到下一条指令是 mov $0x804d154,%edx0,就要注意了,这条指令就是我们要研究的它始终在每轮循环恢复 edx 这个基址值 0x804d154。
在前面,我们曾对后面内循环反复把“基址 + 8”的操作比较迷惑,因为这里它是取出偏移8后地址上的数据作为后一次循环的新地址啊,也就是说偏移8的位置上其实是存放的一个地址值?那偏移4的地方是什么,基址处又是什么?
带着这个疑问,我们使用 p 指令来打印寄存器和寻址后的数据,我们先后执行了这3条命令:
p /x $edx # 查看寄存器 edx 当前的值
p /x *$edx # 查看地址值指向的数据
p /x *(%edx)@3 # 查看一直到偏移 8 处的数据(一定要加括号)
调试器给出的返回如下图所示:
我们看到 edx 寄存器指向的地址上有一个数值数据 0x279 ,并且他后面间隔一个元素是一个地址 0x80ed160 仅仅和当前ebx 表示的地址相差12个字节,这恰好和3个int 型数据占用的字节宽度相等。为了进一步确定我的想法,我决定多调试几个循环。
这里,我们查看一下当前第一轮循环数组的值(%ecx)。我们发现数组的值为1,而不是6,这是为什么?还记得我们在第二部分说过的吗,我们对数组的每一个数用 7 – 本身作为新的值,而 7 – 6 = 1 。借助IDA伪代码看一下第二部分的操作:
IDA 是个好用的工具,但不能太依赖它的F5功能,往往他的伪代码会错误解析。
接下来,我们注意到一个条件跳转指令,顺便复习一下条件跳转,在跳转前,我们查看标志寄存器(EFLAGS)的值:
指令对比ecx = a[i] 与 立即数 1 的大小,当 ecx 比 1 大则发生跳转,否则不发生跳转。
EFLAGS标志位只有 ZF = 1,而没有显示SF和OF 标志位,这说明 SF = OF = 0,表示两数之差为正数(SF = 0),并且结果未溢出(OF = 0)。jg 转移条件是 SF = OF AND ZF = 0,而实际的ZF = 1 ,表示两数相等,不满足跳转条件。从下一步看出来,确实没有发生跳转:
之前分析过,如果数组中的数大于1,才进入内循环,否则通过无条件跳转绕过内循环,如下图路径{7}:(备注:红色横线以上是内循环)
此时 edx 的值会通过mov 指令复制给 $esp + 0x24 + $esi * 4 的栈地址上,上文已知 $esi = $ebx 并且此时 $ebx = 0,而且我们知道 $esp + 0xc 是输入的6个数的基址,按照数组大小计算偏移可知,$esp + 0x24 = ($exp + 0xc) + (4 * 5) + 4。我们知道 BP + (size – 1) 已经是 int 数组末尾元素的地址,再加上 4 字节,则不在原来的数组上了,所以这里就是再在栈上利用空间存放数据到新的数组。
用一个局部栈的结构直观展示上面的分析结果:
然后就是研究内循环了,这需要反复多看几轮循环,以第四轮进入内循环为例,此时 ecx 的值是用户输入的第三个数与7相差的值,即 ecx = 4 ,而 eax 要进行递增,直至与 eax 相等,递增前的指令 mov 0x8(%edx),%edx 会将基址偏移 8 处作为一个地址去解析上面的数据,作为内循环下一次的基址或运算的结果,而偏移 8 的地方也是地址值。
需要注意的是:寄存器用 () 括起来表示内容,不用 () 括起来表示地址,除了lea指令以外,在lea指令中,源地址用 () 括起来表示地址而非内容。 mov (edx), edx 表示 edx 所指向的地址内的内容赋值给 edx,而单独的edx 的值表示一个地址值。除了lea 指令,其他传数据指令对于源操作数带括号的是会读取地址上的数据的,既然这么写 mov 指令,那么偏移计算后的地址一定是有效的。并且根据循环来看,更新后的值也是地址,edx 始终是地址。
同时我们仔细观察会发现 edx + 4 处的数值和当前的 eax 应该是对应的。所以不难猜测 edx + 4 是编号,并且从 1 开始顺序编排。
那么,似乎 edx + 0 处的数据变化没有明显数学联系,所以,第一个数就是事先写好的数据。综上,我们可以确认这是一个链表的数据结构,对于每一个结点有如下结构:
typedef struct node{
int data;
int index;
node* next;
}node;
接下来,我们可以通过计算偏移,手动获取该链表(结构体数组)的所有数据:
整理一下这个结构体数组就是:
node StaticList[MAX_SIZE]{
{0x0279, 1, (&(StaticList[1]))},
{0x031c, 2, (&(StaticList[2]))},
{0x016a, 3, (&(StaticList[3]))},
{0x01ef, 4, (&(StaticList[4]))},
{0x0123, 5, (&(StaticList[5]))},
{0x0252, 6, NULL}
};
下面我们再来看,我们会发现内循环做的事情就是根据数组的元素值的1~6 的顺序对链表的结点地址进行重新排序,并将结点地址写入到数组 b里面(结点指针数组)。如下图所示,已经更新了前四个结点的地址:
接下来是第四部分的汇编指令,显然是一个单循环:
首先要知道 esp + 0x24 是结点指针数组的首地址。
这里我们梳理一下相关寄存器的作用:
<1> %ebx:结点指针数组的第一个元素值,为以后起作用,本循环暂未用到;
<2> %eax:初始化为结点指针数组的首地址,用作循环时每次偏移的基址;
<3> %esi:保存结点指针数组最后一个元素的地址,用作限制指针的移动;
<4> %edx:%eax表示的数组元素 i 后面一个元素的值,即 i + 1 的值;
<5> %ecx:%ecx 初始化为指针数组的首地址,并且用于寻址修改后继结点;
这个循环的功能就是每次递增数组的指针,获取数组的数据,根据数组的数据,利用 0x8(%ecx) 即 (%ecx) + 0x8 寻址,定位到每个结点数据结构中,那个指向后继节点的指针,所在栈上的地址,并利用 mov 修改后继结点的值为数组中保存的值。
这段功能的伪代码如下所示:
FirstNodePtr = b[0]; // 结点指针数组的第一个地址值,用于备份
currentListPtr = b; // 指针地址数组的基址(第一个元素对应的地址)
ptrThisNode = b[0]; // 结点指针数组的第一个地址值,
// 作为遍历结构体中结点的基址
endListPtr = &(b[5]); // 最后一个元素的地址
do
{
ptrNextNode = currentListPtr[1]; // 数组中下一个元素的值,
// 也就是新的后继结点的地址
*(unsigned int *)(ptrThisNode + 8) =
ptrNextNode; // 修正结构体中结点的后驱指针的值
++ currentNodePtr; // 基址指针的增加,相当于偏移4,int *v9
ptrThisNode = ptrNextNode; // 结点基址更新为下一个结点地址
}while(endListPtr != currentListPtr) // 未达到终止条件就继续遍历
*(unsigned int *)(ptrNextNode + 8) = 0; // 最后一个结点的后继指针指向NULL
下面是第五部分的汇编序列:
综上所述,本关破解要点是:
1. 输入 6 个数,要求在 1~6之间,并且不能重复,表示顺序编号;
2. 数字 7 减去这 6 个数的结果作为新的编号顺序;
3. 要求结构体数组的结点编号和这里的新编号顺序一致,于是对结构体数组进行遍历;
4.要求重排后的结构体数组,对于每一个结点的数据域上的值不能小于它下一个结点。
初始状态下结构体数组的结构:
node StaticList[MAX_SIZE]{
{0x0279, 1, (&(StaticList[1]))},
{0x031c, 2, (&(StaticList[2]))},
{0x016a, 3, (&(StaticList[3]))},
{0x01ef, 4, (&(StaticList[4]))},
{0x0123, 5, (&(StaticList[5]))},
{0x0252, 6, NULL}
};
第一个值就是数据域,第二个就是对应编号。
我们只要对结构体的数据按从大到小排序一下即可:
2 1 6 4 3 5
然后,再和7作差,得到本关的正确密码:
5 6 1 3 4 2
重新打开终端,输入程序验证,成功通关:
(1)相关汇编代码
08048f0a
: 8048f0a: 53 push %ebx
8048f0b: 83 ec 08 sub $0x8,%esp
8048f0e: e8 a0 03 00 00 call 80492b3
8048f13: 83 ec 04 sub $0x4,%esp
8048f16: 6a 0a push $0xa
8048f18: 6a 00 push $0x0
8048f1a: 50 push %eax
8048f1b: e8 60 f9 ff ff call 8048880
8048f20: 89 c3 mov %eax,%ebx
8048f22: 8d 40 ff lea -0x1(%eax),%eax
8048f25: 83 c4 10 add $0x10,%esp
8048f28: 3d e8 03 00 00 cmp $0x3e8,%eax
8048f2d: 76 05 jbe 8048f34
8048f2f: e8 07 03 00 00 call 804923b
8048f34: 83 ec 08 sub $0x8,%esp
8048f37: 53 push %ebx
8048f38: 68 a0 d0 04 08 push $0x804d0a0
8048f3d: e8 77 ff ff ff call 8048eb9
8048f42: 83 c4 10 add $0x10,%esp
8048f45: 83 f8 07 cmp $0x7,%eax
8048f48: 74 05 je 8048f4f
8048f4a: e8 ec 02 00 00 call 804923b
8048f4f: 83 ec 0c sub $0xc,%esp
8048f52: 68 b4 a1 04 08 push $0x804a1b4
8048f57: e8 64 f8 ff ff call 80487c0
8048f5c: e8 4b 04 00 00 call 80493ac
8048f61: 83 c4 18 add $0x18,%esp
8048f64: 5b pop %ebx
8048f65: c3 ret
(2)破解过程与结果
为什么会想到有隐藏关卡,看主函数的汇编代码和原函数就知道了。关卡通关后都会调用phase_defused 函数,并且还会加上进一步的关卡提示,而第六关却在调用了 phase_defused 后就结束了。
/* Wow, they got it! But isn't something... missing? Perhaps
* something they overlooked? Mua ha ha ha ha! */
这段在源码中的提示告诉我们,我们遗漏了一些细节,那么细节会在哪呢?
首先比较可疑的就是 phase_defused 函数,现在重新启动调试实例:
我们在 phase_defused 这里设置了断点,然后仔细地单步调试。
经过两轮试错,我们发现果然 phase_defused 函数有古怪:
(1)比较特定地址上的值
函数通过比较 0x804d7ec 处是否为 0x6 来判断是否要给出正常的通关提示,我们通过修改这个值来绕过条件跳转:
(2)接下来程序尝试读取三个数字:
(3)由于我们现在并不知道要输入什么,而且也没按照要求输入,这里对 sscanf 返回值进行了检查,我们修改 eax 的值以便于绕过条件跳转:
(4)这里 strings_not_equal 比较了第三个读入的参数,我们直接改写 eax 的值,来绕过 test %eax, %eax + jne 的字符串检查:
(5)继续 ni,会发现程序进入了一个函数,并给出这是秘密关卡的提示:
(6)再次调试,并对输入参数内容进行深入挖掘,首先是 sscanf 格式化读入三个参数,第三个为字符串:
(7)其次,strings_not_equal 比较了读入的字符串是否为“DrEvil”:
继续分析会发现,前两个参数作为下一关卡的参数传递,“DrEvil”字样会触发 call secret_phase 指令,并进入所谓的秘密关卡。
隐藏关卡和第四关类似都是递归,但是递归的是类似于第六关的结构体数组,时间原因,给出调试过程的部分数据和还原出来的概念验证代码,直接用 Dev C++ 编译运行就可以出结果。
GDB 调试代码 1 :
(gdb) p/x *0x804D154@3
$1 = {0x279, 0x1, 0x804d160}
(gdb) p/x 0x804D154@18
Only values in memory can be extended with '@'.
(gdb) p/x *0x804D154@18
$2 = {0x279, 0x1, 0x804d160, 0x31c, 0x2, 0x804d16c, 0x16a, 0x3, 0x804d178,
0x1ef, 0x4, 0x804d184, 0x123, 0x5, 0x804d190, 0x252, 0x6, 0x0}
GDB 调试代码 2 :
(gdb) x /3wx 0x804d0a0
0x804d0a0
: 0x00000024 0x0804d0ac 0x0804d0b8 (gdb)
0x804d0ac
: 0x00000008 0x0804d0dc 0x0804d0c4 (gdb)
0x804d0b8
: 0x00000032 0x0804d0d0 0x0804d0e8 (gdb)
0x804d0c4
: 0x00000016 0x0804d130 0x0804d118 (gdb)
0x804d0d0
: 0x0000002d 0x0804d0f4 0x0804d13c (gdb)
0x804d0dc
: 0x00000006 0x0804d100 0x0804d124 (gdb)
0x804d0e8
: 0x0000006b 0x0804d10c 0x0804d148 (gdb)
0x804d0f4
: 0x00000028 0x00000000 0x00000000 (gdb)
0x804d100
: 0x00000001 0x00000000 0x00000000 (gdb)
0x804d10c
: 0x00000063 0x00000000 0x00000000 (gdb)
0x804d118
: 0x00000023 0x00000000 0x00000000 (gdb)
0x804d124
: 0x00000007 0x00000000 0x00000000 (gdb)
0x804d130
: 0x00000014 0x00000000 0x00000000 (gdb)
0x804d13c
: 0x0000002f 0x00000000 0x00000000 (gdb)
0x804d148
: 0x000003e9 0x00000000 0x00000000 (gdb)
概念验证代码(C++ 语言):
#include
#define NUM_SECRET_LINKLIST_NODE 15
// 链表结点
typedef struct KNode {
int data; // 数据域
KNode *pp1; // 下一个地址
KNode *pp2; // 下下个地址
} KNode;
// 结构体数组,全局变量
KNode a[NUM_SECRET_LINKLIST_NODE] {
{0x24, (&a[1]), (&a[2])},
{0x08, (&a[5]), (&a[3])},
{0x32, (&a[4]), (&a[6])},
{0x16, (&a[12]), (&a[10])},
{0x2d, (&a[7]), (&a[13])},
{0x06, (&a[8]), (&a[11])},
{0x6b, (&a[9]), (&a[14])},
{0x28, 0 , 0},
{0x01, 0 , 0},
{0x63, 0 , 0},
{0x23, 0 , 0},
{0x07, 0 , 0},
{0x14, 0 , 0},
{0x2f, 0 , 0},
{0x03e9, 0 , 0}
};
int fun7(KNode *node, int dwValue) {
int result = 0; // eax
if ( !node ) // 递归终止条件:a1 指向的地址为 NULL
return -1; // 终止时,返回值为-1
if ( (*node).data > dwValue ) // 如果 a1.data 的数值比
// 我们输入的数值 a2 大,则进入 A 分支
return 2 * fun7(node->pp1,
dwValue);
// A 分支:本轮返回 2 * fun7(a[1]), a2),
// a[1] 指的是调用结构体中的地址值的第一个
// (每个结点包含一个数据,两个存放下一个地址的指针)
// result = 0;
if ( (*node).data < dwValue ) // a1.data 的值小于 a2,进入分支 B
return 2 * fun7(node->pp2, dwValue) + 1;
// B 分支:2 * fun7(a1[2], a2) + 1;,递归第二个地址
return result; // 递归返回,返回条件:a1.data == a2
}
// 0-1001
int main() {
int i;
for (i = 0; i <= 1001; i++) {
if (fun7(a, i) == 7) {
printf("%d\n", i);
}
}
return 0;
}
编译运行,答案是 1001:
综上,全部的通关Code 为:
I was trying to give Tina Fey more material.
1 2 4 7 11 16
0 161
3 10 DrEvil
5 115
5 6 1 3 4 2
1001
喜欢的话请关注我的博客: 涟幽516https://blog.csdn.net/qq_59075481更新于:2023.12.5