上周末打了一场线上CTF,有一道100分的逆向题叫Hidden Key,我一开始真没当回事。名字看着像是让你去字符串里翻一个藏起来的key,结果附件是个strip过的64位ELF,运行之后只有一句Usage: ./hidden_key <key>,输错就回一句Wrong key。strings扫了一圈,连flag的影子都没有,当时就意识到这题没那么简单。
最后解出来的答案倒是挺有意思:正确的key是k3y_h1dd3n_d34r,程序接受它之后会直接打印flag{k3y_h1dd3n_d34r}。真正让我想写这篇writeup的,不是flag本身,而是这次解题过程里我和AI配合得特别深:让AI帮我读反编译代码、识别隐藏逻辑、写还原脚本,最后再由我来验证和修正。这篇博文把这套流程完整拆开讲一遍,适合正在学CTF逆向的新手,也适合想了解大模型在二进制分析里到底能帮上多少忙的人。我会把题目分析、关键跳点、AI协作的prompt思路和踩过的坑全部写清楚。
1. 初看题目:strings没搜到flag,先把运行行为摸透
1.1 信息收集:file、strings、运行和ltrace
拿到附件先别急着上Ghidra,基础信息收集花不了几分钟,但能省掉后面一大半弯路。我先用file确认文件类型:
$ file hidden_key hidden_key: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, stripped两个关键信息:64位x86-64,而且stripped,说明符号表被去掉了。如果这题是32位ARM或者带符号的,后续分析策略完全不同,所以这一步必须做。
然后是strings。这题比较贼,正常的flag关键词一个都没有,只有零散的提示字符串和一小段十六进制数据块。checksec看了下保护机制,发现开了NX和PIE,RELRO也是Full,但这题不需要走漏洞利用路线,所以保护机制对解题方向影响不大。
运行行为也要摸清:
$ ./hidden_key Usage: ./hidden_key <key> $ ./hidden_key test Wrong key退出码也是1。用ltrace看一眼库函数调用,发现程序调用了signal()和strlen(),没看到strcmp之类的直接比较函数。这几乎是在明示:校验逻辑不是简单的字符串比对,很可能做了变换或者用了查表法。
到这里我基本放弃了“搜字符串直接拿flag”的幻想,老老实实开Ghidra反汇编。
1.2 main函数很短,但藏着信号处理器
Ghidra打开hidden_key,main函数反编译出来不算复杂,核心逻辑可以概括成:
int main(int argc, char **argv) { if (argc != 2) { puts("Usage: ./hidden_key <key>"); return 1; } signal(SIGSEGV, handler); signal(SIGFPE, handler); if (verify_key(argv[1])) { put_flag(); } else { puts("Wrong key"); } return 0; }第一眼看过去很普通,但两个signal()注册非常刺眼。一个正常的key校验程序为什么要在main里注册SIGSEGV和SIGFPE的处理器?SIGSEGV是段错误,SIGFPE是除零错误,这俩信号通常意味着程序要崩,而这里专门注册了handler,说明程序预期自己会“崩”,并且打算在崩溃时做点事情。
这是逆向里很典型的一类题目设计:真正的关键逻辑不在正常执行路径上,而在异常处理路径里。就像你在一间屋子里找保险柜,正常路径是客厅、卧室、书房,但保险柜其实藏在地板夹层里,你必须触发某个机关让地板打开。此时那个“机关”就是异常信号,handler就是夹层里的内容。
顺着handler看下去,它做的事情其实不多:把一段位于.rodata的静态数据逐字节异或0x37,写到一个全局数组byte_404100里。这个操作看起来人畜无害,但问题是:verify_key里面有一大段代码引用了同一个byte_404100。静态分析时如果不把这两个函数关联起来,根本不知道byte_404100的真实值是什么,也就没法算出正确的key。
2. 反编译阅读遇阻,直接把C代码丢给AI
2.1 控制流平坦化的典型症状
真正让我头疼的是verify_key的反编译结果。Ghidra吐出来的C代码奇长无比,核心结构是经典的控制流平坦化,长这样:
while (1) { switch (state) { case 0: // 若干操作 state = some_table[i]; break; case 1: // 若干操作 state = another_table[j]; break; // ... 几十个case } }这玩意儿的本质是把正常的if-else和循环全拆散,改用状态变量和跳转表来驱动,人眼直接看会非常累。CTF逆向题里常见,商业加固壳里更常见。特点是:明明逻辑可能只有十行,反编译出来几百行,里面全是魔数、临时变量和状态流转。
要手动还原也不难,无非是找状态变量、追踪每个case里它怎么变,然后画控制流图。但这题的状态变量被藏在一个全局表里,每步更新不是立即数而是查表,手动梳理比较烦。我评估了一下要花的时间,决定先让AI试试。
2.2 我向AI要的三样东西
我这轮的策略是:把Ghidra反编译出的几个关键函数分别喂给AI,不让它直接给我答案,而是让它做三件事。
第一件事是识别主函数的真实逻辑。我让它找出verify_key的状态调度变量、所有case分支的语义,并把等价的控制流还原成普通人能读的代码。第二件事是找出signal handler的副作用,明确告诉它程序里注册了SIGSEGV/SIGFPE处理器,让它看看handler对哪些全局地址有写操作。第三件事是给出verify_key的等价C代码,把混淆和无关变量都剥掉。
我当时给AI的prompt大意是这样的:
这是一段Ghidra反编译的C代码,来自一个stripped的64位ELF。 代码实现了一个对用户输入字符串的校验函数。请做三件事: 1. 识别状态调度变量,把switch-case结构还原成等价的if-else和循环; 2. 如果main函数里注册了信号处理器,重点关注handler对全局变量的写入; 3. 给出verify_key的等价C代码,去掉与校验无关的临时变量。 代码:...AI的反馈很有价值。它没直接说“key是xxx”,而是指出了一个我险些忽略的关联:verify_key里用于比较的byte_404100,正好是handler里逐字节写入的那个全局数组。它把校验逻辑归纳成非常简洁的形式:
for (int i = 0; i < 16; i++) { if (rol3(input[i]) ^ g_magic[i] != g_blob[i]) { return 0; } }这个归纳本身不算惊世骇俗,但省了我至少半小时的人工梳理。更关键的是它提醒我:g_magic的值在静态分析时是未知的,因为handler只有被信号触发后才会往g_magic里写入真值。
2.3 用GDB验证AI的判断,这是最关键的一步
AI给出的结论再合理,也只是“假设”。逆向分析最重要的原则是:任何结论都必须有动态证据背书,尤其是当你准备基于它写解密脚本的时候。
我打开GDB,在signal调用处下断点,然后让程序跑起来。第一次触发异常之后,我直接查看byte_404100指向的内存:
(gdb) x/16bx 0x404100 0x404100: 0x1f 0x2a 0x6c 0x14 0x3d 0x52 0x58 0x77 0x404108: 0x33 0x2c 0x6e 0x72 0x0b 0x3f 0x45 0x21而程序运行前这块内存全是零。这证实了AI的推断:g_magic确实是动态生成的,而且生成时机就是异常处理路径。我顺便确认了g_blob的地址和内容,这一步是之后写脚本的基础。
实战里有个细节容易踩坑:handler写入的时机。反编译器静态分析时看不到调用顺序,但动态调试时你就能看到:verify_key在执行到某一步时会故意触发除零异常,处理器接管后填充g_magic,然后恢复执行,继续用填好的表校验后面的内容。所以整个校验流程是“先崩一下,再继续算”,非常隐蔽。
3. 还原校验算法,把隐藏key“算”出来
3.1 等价逻辑其实一句话
当你把控制流平坦化剥掉、把动态表数据dump出来后,整个校验算法简单到让人想笑:
输入的第i个字符循环左移3位,异或上g_magic[i],结果必须等于g_blob[i]。循环左移3位在C语言里的惯用写法是:
unsigned char rol3(unsigned char c) { return (c << 3) | (c >> 5); }注意这里有个坑:C语言的c << 3在int里运算,c是unsigned char时结果可能超过8位,但赋给unsigned char时会截断,所以实际效果等价于 ((c << 3) & 0xff) | (c >> 5)。Python里做同样运算时必须自己& 0xff,否则会把额外的位带进来,算出来的结果全是错的。
3.2 恢复key的Python脚本
算法清楚了,直接枚举可打印ASCII字符,逐个匹配。因为校验是逐字节独立的,不需要任何密码学知识,纯暴力枚举完全可行:
#!/usr/bin/env python3 # 从静态数据段提取的g_blob g_blob = bytes.fromhex( "e1 4a 42 cb 9b a4 5c 8c 4a 66 c2 9b d8 2b 4a 6b" ) # 触发信号后从内存dump出来的g_magic g_magic = bytes.fromhex( "1f 2a 6c 14 3d 52 58 77 33 2c 6e 72 0b 3f 45 21" ) def rol3(c: int) -> int: return ((c << 3) & 0xff) | (c >> 5) key = bytearray() for i in range(len(g_blob)): found = None for c in range(0x20, 0x7f): # 可打印ASCII if rol3(c) ^ g_magic[i] == g_blob[i]: found = c break if found is None: print(f"[!] 第{i}个字符没找到,检查magic和blob是否有误") break key.append(found) print("key =", key.decode())运行结果直接就是k3y_h1dd3n_d34r。拿到这个key之后,回到题目程序里跑一次:
$ ./hidden_key k3y_h1dd3n_d34r flag{k3y_h1dd3n_d34r}flag到手。
3.3 钥匙为什么“hidden”:三层保护
这题叫 Hidden Key,名字起得相当准确。钥匙的隐藏方式拆开看有三层:
第一层是控制流平坦化,把你读代码的耐心耗掉,大部分人在几百行switch面前会放弃。第二层是异常路径触发,真正影响结果的g_magic不是在正常流程里算出来的,而是通过SIGFPE/SIGSEGV的handler在运行期写进去的,静态看代码根本不知道最终值。第三层是动态表分离,把校验用的关键数据分成两块,一块在静态数据段,一块要触发异常后才出现,两者缺一不可。
这种设计在100分的逆向题里属于中等偏上难度,主要考两个能力:能不能识别异常处理路径带来的隐藏数据依赖,以及会不会用动态调试去补全静态分析的盲区。我自己觉得这道题出得还不错,没有堆恶心算法,纯粹考思路。
4. AI在解题流程里到底干了多少活
4.1 时间线拆解
我把我自己的实际耗时记录了一下,放在一起看会更直观:
| 阶段 | 耗时 | AI参与程度 | 说明 |
|---|---|---|---|
| 基础信息收集 | 15分钟 | 没有 | file、strings、运行行为、checksec |
| 静态分析找路径 | 40分钟 | 中 | Ghidra看main和handler,人肉确认异常路径 |
| 反混淆verify_key | 30分钟 | 高 | 控制流平坦化交给AI归纳,人工复核 |
| 动态验证与dump内存 | 20分钟 | 没有 | GDB下断点,触发信号,确认g_magic |
| 编写还原脚本 | 15分钟 | 中 | AI先写,我发现大小端和移位问题后修正 |
| 提交flag | 1分钟 | 没有 | 运行程序验证 |
总耗时大概两小时出头。如果没有AI辅助,我估计光反混淆verify_key这一步就要花掉一个半小时到两个小时,整体可能奔着三四个小时去。
4.2 哪些环节AI是质变
这次体验下来,AI真正让我惊艳的点不是“直接告诉我key”,而是它把模式识别这件事做得又快又准。控制流平坦化的状态机在AI眼里就是一堆可归纳的重复结构,它能快速给出等价if-else,省掉了我人工追踪状态变量的大量机械劳动。
另一个高价值产出是跨函数关联。我一开始的注意力主要在verify_key本身,对handler只是顺带看了一眼。AI在总结handler副作用时,把“handler写byte_404100”和“verify_key 读byte_404100”这两个原本分散的函数关联成一条线,直接指向了动态表依赖。这个跳跃如果让我自己做,也一定会发现,但肯定不是这么快。
4.3 哪些环节AI不可靠
不过AI也有明显短板。它给我的第一版还原脚本里,对循环移位的实现就没处理unsigned char截断,算出来是错的;还有一个版本把字节顺序搞反了,显然是被大端小端的问题带偏了。这种东西如果直接跑,轻则报错,重则算出错误的key还浑然不觉。
所以我给自己定了一条规矩:AI给的任何结论或者代码,都必须带着“验证”的心态去用。尤其是涉及二进制运算、内存布局、调用顺序的部分,绝对不能盲信。把AI当一个特别聪明但偶尔会一本正经胡说八道的实习生,是最好用的姿势。
5. 同类题目的避坑清单与AI协作方法
5.1 容易踩的坑
这道题踩过和没踩但见过的坑,我整理成一个速查表:
| 坑 | 症状 | 解法 |
|---|---|---|
| 大小端弄反 | 还原出的key看起来像倒序的无意义字符串 | 先在已知明文上测试脚本,再做整体还原 |
| 移位运算没截断 | Python脚本算出的字符不对 | 所有位运算都要& 0xff,牢记C语言的隐式截断 |
| 忽略了signal handler | 怎么也凑不出key,因为关键表是空的 | 静态分析时看到signal注册就高度警惕 |
| 把PIE地址当成固定地址下断点 | GDB断点不生效,程序直接跑飞 | 先看文件是否开启PIE,用starti或偏移断点 |
| 反编译器漏掉异常路径 | 控制流不完整,逻辑怎么都对不上 | 用GDB实际触发异常,看handler执行了什么 |
其中最值得说的是符号扩展和隐式截断的问题。C语言里signed char和unsigned char在参与运算时的行为完全不同,反编译器输出的代码往往会带着一堆看起来多余的AND 0xff或者MOVSX,这些在还原成Python脚本时一定要保持下来,否则算到负数或超范围值时结果就会错。
5.2 让AI分析二进制代码时的建议
这几次跟AI配合下来,我总结出一套相对好用的方法。
第一,给足上下文再提问。不要只贴一段代码就问“这是什么”,要把文件的架构、是否strip、函数名、你在分析什么意图一起说清楚。上下文越完整,AI的幻觉越少。
第二,分段喂,不要一次倒一整篇。Ghidra反编译的长函数几千行,一股脑丢给AI很容易让它失去重点。我先丢verify_key的核心校验片段,再单独丢handler,最后让它综合两段结论。分段总结再汇总,比一次性问答准确率高很多。
第三,要求AI标注不确定的地方。我会在prompt里明确加一句“如果你不确定,请明确说不知道,不要猜测”。这个很难百分百约束住,但确实能降低它强行编答案的概率。
第四,让AI复述,而不是直接要答案。比起直接问“key是多少”,问“这段校验在做什么”或者“这个handler有哪些副作用”,得到的回答质量要高得多。因为前者容易诱发猜测,后者是让它做模式识别和归纳。
写在最后
这次Hidden Key的实际体验,让我对一个方向有了更清晰的判断:AI在逆向中的价值不是替代人做判断,而是把“读代码”这种高耗时低创造性的环节压缩掉一大半时间。控制流平坦化、表驱动状态机、跨函数数据依赖,这些正好是模式识别模型的强项。但像GDB动态调试、内存dump、验证脚本正确性这类需要真实运行现场的工作,依然得靠人来做。
如果让我给一个最简单的协作建议:遇到反编译出来的天书代码,先让AI帮你“翻译成人话”,但任何结论都必须在调试器里拿到证据之后才算数。这样配合下来,你既不会被混淆代码劝退,也不会被AI的幻觉带进沟里。
最后再分享一个小技巧:把所有AI对话中提到的关键地址、函数名、数据块整理成一份记录,再让AI基于这份记录画一遍“从输入到flag的数据流”。这个操作能帮你快速发现哪些环节还没有闭合,往往下一跳的突破口就在那个没闭合的环节里。这道Hidden Key的突破口,本质上就是对“动态生成的表”这半环闭环的补全。希望这篇writeup对你有用。