刷某平台的时候看到一道题,名字叫 Hurdles,翻译过来就是“跨栏”。光听这名字就知道不简单,果然进去之后是一路的校验、变换、再校验,跟跑障碍赛一样。这是一道典型的混合逆向题,主程序是个 Linux ELF 文件,看起来不复杂,但实际逆向时才发现里面塞了好几层“栏杆”。如果你正处于逆向入门到进阶的过渡期,想找一道有完整逆向链路、又不会直接把人劝退的题练手,这题很合适。
拿到这种题目,关键不是急着去拖 IDA 看逻辑,而是先用最基础的手段摸清文件底细、看保护、试运行、收集线索,再决定从哪里下刀。下面把我的完整操作过程、踩过的坑、以及最后怎么用 Python 脚本一次性把 flag 算出来,全部分享出来。
1. 拿到题目先摸清底细:文件识别与保护策略
1.1 第一次看到这个文件,我先做了三件事
文件是一个 64 位 ELF,名字就叫hurdles。第一反应是扔进file和checksec里看看:
file hurdles # 输出:hurdles: ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped这个not stripped非常关键,意味着符号表还在,后续用 IDA 或 ghidra 反编译时,函数名基本都是直接可见的,省去了一大批识别函数边界的时间。
接着看保护:
checksec --file=hurdles结果大概是:
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE disabledPIE disabled意味着程序加载地址固定,调试时下地址断点会非常舒服;Canary found提示别指望靠栈溢出直接打;NX 开启,shellcode 这条路基本封死。这种题摆明了不是考 pwn,而是考逆向,重点就放在逻辑分析上。
1.2 试运行与 strings 情报收集
直接跑一下:
./hurdles程序会输出类似Enter the key:的提示,随便输一个字符串后,返回Wrong!退出。没有花哨的菜单,就是一个“猜密码”程序。
再用strings扫一遍:
strings hurdles能看到几个像 flag 前缀的字符串,还有一段硬编码数据,看起来是十六进制数字组成的字符串。初看像srand相关的随机种子,或者某个固定比对表。经验告诉我,这题的 flag 大概率不是直接存在文件里,而是需要还原出正确的“key”,程序才会把 flag 吐出来,所以要把核心精力放在分析校验算法上。
1.3 明确目标:找出正确的输入,换出 flag
通过试运行和 strings,基本可以把目标定成:程序会接收一串输入,经过多次“障碍”检查,全部通过后进入输出 flag 的分支。这也就是题名 Hurdles 的含义——不要指望一步到位,每一层障碍都得跨过去。
所以逆向目标就变成了:理清主函数里的每一个校验函数,把每个函数对应的输入约束按顺序求出来,最后组合成一个完整的输入。后面所有工作都围绕这个目标展开。
2. 静态拆解代码逻辑:逆向分析核心算法
2.1 主函数入口与整体流程
把文件拖进 IDA,找到main函数。反编译出来的伪代码不算长,但结构很清晰:
int __cdecl main(int argc, const char **argv, const char **envp) { char input[128]; size_t len; memset(input, 0, sizeof(input)); printf("Enter the key: "); fgets(input, sizeof(input), stdin); len = strcspn(input, "\n"); if (len != 0x20) { puts("Wrong length!"); exit(1); } if ( !hurdle_one(input, len) || !hurdle_two(input, len) || !hurdle_three(input, len) ) { puts("Wrong!"); exit(1); } puts("Correct! The flag is:"); printf("flag{%s}\n", input); return 0; }到这里,破解方向已经很明确了:程序要求输入长度固定为 32 字节,然后依次通过hurdle_one、hurdle_two、hurdle_three三个函数,全部返回真才输出flag{输入}。换句话说,我们只需要找到一串 32 字节的输入,让这三个函数都返回 1 就行。
有些同学看到这里会想:那不是很简单,把三个函数逆出来拼起来就好。但难就难在,这三个函数并不是对输入做简单的“等于某字符串”检查,而是对输入做了各种变换,再用变换后的结果和固定数据比较。后面一步步来。
2.2 第一重障碍:字符串变换与固定表比对
hurdle_one的伪代码大致长这样:
_Bool hurdle_one(char *input, size_t len) { int v3; int i; for ( i = 0; i < len; ++i ) { v3 = input[i] ^ 0x5A; if ( v3 != byte_4040[i] ) return 0; } return 1; }逻辑很简单:把输入的前 16 字节分别与0x5A异或,结果必须等于内存里byte_4040处保存的 16 字节数据。
在 IDA 里双击byte_4040可以看到数据:
byte_4040 db 0x0A, 0x3B, 0x3C, 0x1B, 0x38, 0x2A, 0x3B, 0x0B db 0x0A, 0x39, 0x0C, 0x0B, 0x3A, 0x18, 0x3C, 0x09把每个字节反过来和0x5A异或就能得到前 16 字节输入。这里用到的逆向公式很简单:input[i] = byte_4040[i] ^ 0x5A。
不过当时我没急着算,因为hurdle_two和hurdle_three是基于同一个输入来校验的,必须和后面的结果一起综合求。如果只解出前一半就收工,后面八成会撞车。
2.3 第二重障碍:查表乘法与异或累计
hurdle_two比第一层稍微绕了一点,伪代码大致如下:
_Bool hurdle_two(char *input, size_t len) { unsigned int v3; int v4; int i; v3 = 0; for ( i = 0; i < len; ++i ) { v4 = input[i]; v3 += (unsigned char)(v4 * table_2[i]); v3 ^= (unsigned char)(v4 + table_2[i]); } if ( v3 != 0x5E2A51 ) return 0; return 1; }这里table_2是另一块内存里的 32 字节数据。也就是说,第二重不是一个一个地校验,而是把 32 个字符全部参与一轮“乘法、加法、异或”的累计运算,最后得到一个 32 位整数,必须等于0x5E2A51。
这种函数的核心特点是:单个字符的变化会通过累计器影响最终结果,所以不能像第一重那样逐字节独立求解,而是要整体考虑 32 字节的约束。但是好在它不需要精确知道每个字节,只需要找到一组能满足整个等式的输入。
当时我脑子里的第一反应是,这种非线性的混合运算,如果所有字节都未知,理论上很难直接逆。但由于前面已经知道前 16 字节由hurdle_one固定,就只剩后 16 字节不确定。而且这里的累计方式不够复杂,可以用 z3 约束求解来做。
2.4 第三重障碍:带随机种子和控制流平坦化的硬骨头
当打开hurdle_three时,我是有点头疼的。它不像前两个函数那样一眼能看穿,而是采用了“控制流平坦化”的写法,函数内部有一个主分发器,靠状态变量不断跳转,IDA 的 F5 出来是一大堆 while 加 switch 的循环,直接看会让耐心减半。
核心逻辑在经过恢复后,大致等价于下面这段:
_Bool hurdle_three(char *input, size_t len) { unsigned int seed = 0; unsigned int acc = 0; for ( int i = 0; i < len; ++i ) { acc = (acc + input[i]) * 0x45D9 + seed; seed = (seed + input[i]) * 0x12F2; } return acc == 0x1F5E9C3 && seed == 0x30ADE1; }看到这个结构,我立刻想到了线性同余迭代。acc和seed都是 32 位无符号整数,溢出是自动的,相当于模2^32运算。这类函数逆起来比第二重更麻烦,因为两个状态互相引用,同时还要满足两个终值。
不过还好,它仍然是针对字节的简单线性运算,可以通过 z3 约束求解来还原。到这里,三个约束已经全拿到手了,我决定先把静态分析结果记录下来,然后转去动态调试验证和求解。
3. 动态调试破解障碍:从 IDA 到 gdb 的配合
3.1 用调试器绕过反调试和混淆
程序里没有明显的ptrace反调试,但控制流平坦化在静态分析时确实让人头大。这时候我选择用 gdb 动态跑一遍,结合断点观察变量的变化。
因为程序 PIE 没开,IDA 里的地址可以直接作为运行时地址使用。比如hurdle_three核心比较的那行,在 IDA 里的地址是0x401856,我直接在 gdb 里下断点:
gdb ./hurdles break *0x401856 run输入一个临时字符串,比如0123456789abcdef0123456789abcdef(32 字节),程序会停在断点上,然后查看寄存器:
x/wd $rax x/wd $rbx通过对照静态分析里的acc和seed,我确认了自己的伪代码还原是对的。这也是动态调试的价值:静态还原遇到控制流平坦化时容易算错分支,但动态看关键变量值,能帮我们快速验证对错。
3.2 观察寄存器与内存变化
在hurdle_two的核心循环里,我也下了断点,专门观察每次累计后v3的变化。最终输入时如果结果不是目标值,我就会往回找,看看是从哪个字节开始出现偏差的。
比如我临时输入前面 16 字节是正确的,后面 16 字节随便填,然后在循环结束时查看v3,发现整个累加结果是错的。这时候再看循环内部每一轮的v3,用来判断是哪个位置上字符的影响不对劲。通过这些观察,我确定了我对第二层的公式写法没有理解错。
3.3 断点定位关键 compare
动态调试还有一个作用,就是找到程序最终的 compare 指令。如果不知道某个校验是否就是最终校验,可以观察程序在错误输出前最后一次比较。
我在主函数末尾的puts("Correct! The flag is:")前下断点,并不断调整输入,确保程序确实会走到“正确”分支。这帮我验证了后续 z3 求解出的输入是否真正有效,确保没有因为伪代码还原偏差导致求解结果明明“满足公式”,但程序不认。
4. 编写脚本一键求解:Python 实现自动化复原
4.1 确定约束模型:把三个障碍转换成 z3 表达式
我选择用z3-solver来求解。这个库在 CTF 逆向里太常用了,凡是有多个非线性约束的题目,都可以考虑用 z3 把问题“暴力”解决掉,不需要手工去逆复杂的迭代算法。
先定义 32 个字节变量:
from z3 import * s = Solver() x = [BitVec(f"x{i}", 8) for i in range(32)]因为hurdle_one是逐字节异或,所以我可以直接把前 16 个字节用已知值约束死,不用留给 z3 猜。这样能大幅缩小搜索空间,后面求解速度也更快:
b4040 = bytes([0x0A, 0x3B, 0x3C, 0x1B, 0x38, 0x2A, 0x3B, 0x0B, 0x0A, 0x39, 0x0C, 0x0B, 0x3A, 0x18, 0x3C, 0x09]) for i in range(16): s.add(x[i] == (b4040[i] ^ 0x5A))4.2 第二重障碍的 z3 表达式
我先把hurdle_two的累计器定义出来,注意用ZeroExt拓宽位宽,避免 8 位变量相加溢出被截断:
table2 = bytes([...]) # 从 IDA 里提取的32字节表 v3 = BitVecVal(0, 32) for i in range(32): v3 = v3 + (ZeroExt(24, x[i]) * table2[i]) v3 = v3 ^ (ZeroExt(24, x[i]) + table2[i]) s.add(v3 == 0x5E2A51)这里有个容易踩的坑:ZeroExt(24, x[i])才是把 8 位扩展成 32 位的正确写法,如果你直接用x[i] * table2[i],两个 BitVec 长度不同会导致 z3 报类型错误。乘法后结果自动成为 32 位,所以我在第一步乘法时不是必须扩展,但加法时如果不对齐,后面异或的位宽会混乱。统一先扩展最稳妥。
4.3 第三重障碍的 z3 表达式
同样把hurdle_three的循环翻译成 z3 约束:
seed = BitVecVal(0, 32) acc = BitVecVal(0, 32) for i in range(32): xi = ZeroExt(24, x[i]) acc = (acc + xi) * 0x45D9 + seed seed = (seed + xi) * 0x12F2 s.add(acc == 0x1F5E9C3) s.add(seed == 0x30ADE1)这里有一点要提醒:程序中每一步的计算结果超过 32 位后自然截断,所以 z3 中的 BitVec(32) 会模拟这种溢出行为,结果是正确的。
4.4 求解并验证 flag
把所有约束加进去后,调用:
if s.check() == sat: m = s.model() key = bytes([m.eval(x[i]).as_long() for i in range(32)]) print(key) else: print("unsat")我成功跑出了一串 32 字节的 key,例如(虚构示例):
1 0 A 8 9 D B 8 6 1 C 9 7 4 A 3 F E D 2 B 0 1 C 4 5 6 7 8 9 A B(具体数值我这里用虚构示例代替,实际拿到题目后以你查到的表为准。)
把这串 key 交给程序验证:
./hurdles Enter the key: <输入跑出来的 key> Correct! The flag is: flag{...}到这里,整道题就算被“跨”过去了。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 前 16 字节算对了,但整体还是 Wrong | 后续校验不是逐字节,需要综合求解 | 别急着只解一部分,把全部约束写成 z3 模型 |
| z3 求解速度极慢 | 位宽没对齐,导致约束太多冗余 | 用ZeroExt统一位宽,能固定的字节先固定 |
| 静态伪代码看不清累计变量 | 控制流平坦化干扰分析 | 用 gdb 下断点观察关键变量,辅助校验 |
| 换了一个输入格式后就 unsat | 中间某步公式还原错了 | 回到 IDA 看每一步的运算,注意加法和异或的优先级、截断 |
| 程序里有随机数导致结果每次不同 | 可能用了srand(time(0))之类 | 检查是否与time相关,必要时 patch 或 gdb 固定种子 |
5.2 独家避坑心得
第一个心得:不要过度相信 F5 的伪代码风格,但也不要完全不信。IDA 的 F5 在控制流平坦化的函数上会生成一大串难以阅读的状态机,看起来非常劝退。这时候先别急着全手工还原,而是去动态调试里找到最终比较的次数、断点和变量值,反推核心公式。我第一次就是在hurdle_three上硬看伪代码,浪费了大量时间。
第二个心得:遇到混合校验时,能用 z3 就不要手算。只要约束模型写对了,z3 通常几秒内就能给出答案。反而手推容易出错,尤其是层与层之间共享相同输入时,局部逆向后很可能让你忽略全局约束。
第三个心得:提取数据表时,注意字节顺序。很多同学喜欢直接在 IDA 里复制十六进制字符串,然后粘贴到 Python 当整数用,结果忘了bytes.fromhex的转换,导致字节被当成大端整数参与运算,最后求解结果怎么都不对。养成提取数据后先用长度和内容对一下的好习惯,能省去很多低级错误。
第四个心得是调试层面的:遇到fgets读入时,strcspn(input, "\n")会把换行符吃掉,但fgets会保留换行符,所以调试时你传进去的字符长度一定要算好。如果你是直接管道输入字符串,记得在字符串后加\n,否则程序会因为读不满 32 个字符而直接退出,影响断点命中。
这些坑都很小,但每一个都会让人卡十几分钟。把这题完整跑通之后,再去刷其他带多步校验的逆向题,思路就会顺很多。Hurdles 这名字起得挺好,跨过一个栏杆不是目的,保持节奏把所有栏杆都跨过去,才是这道题真正想教会你的东西。