刷某平台的时候看到一道题名字叫 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 --filehurdles结果大概是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输入一个临时字符串比如0123456789abcdef0123456789abcdef32 字节程序会停在断点上然后查看寄存器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(fx{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 这名字起得挺好跨过一个栏杆不是目的保持节奏把所有栏杆都跨过去才是这道题真正想教会你的东西。