ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CTF逆向Hidden Key实战:AI辅助破解控制流平坦化与信号处理

CTF逆向Hidden Key实战:AI辅助破解控制流平坦化与信号处理 上周末打了一场线上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和PIERELRO也是Full但这题不需要走漏洞利用路线所以保护机制对解题方向影响不大。运行行为也要摸清$ ./hidden_key Usage: ./hidden_key key $ ./hidden_key test Wrong key退出码也是1。用ltrace看一眼库函数调用发现程序调用了signal()和strlen()没看到strcmp之类的直接比较函数。这几乎是在明示校验逻辑不是简单的字符串比对很可能做了变换或者用了查表法。到这里我基本放弃了“搜字符串直接拿flag”的幻想老老实实开Ghidra反汇编。1.2 main函数很短但藏着信号处理器Ghidra打开hidden_keymain函数反编译出来不算复杂核心逻辑可以概括成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代码丢给AI2.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_key30分钟高控制流平坦化交给AI归纳人工复核动态验证与dump内存20分钟没有GDB下断点触发信号确认g_magic编写还原脚本15分钟中AI先写我发现大小端和移位问题后修正提交flag1分钟没有运行程序验证总耗时大概两小时出头。如果没有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对你有用。
返回列表