Hidden Key名字起得很直白就是要让你在一堆东西里把真正的Key挖出来。这题我拿到之后前后折腾了大概三个小时最后在GDB和Ghidra的配合下把三个Key片段拼齐顺利跑出了HKCTF{h1dden_k3y_1s_n0t_4lw4ys_h1dd3n}。这篇Writeup不只是讲步骤更会把我在排查过程中的思路、踩过的坑、以及如何用AI辅助加速整个逆向分析链条的实操心得都写下来适合刚接触CTF逆向题的选手也适合已经在打本地题但总在动调环节卡壳的朋友。这题的难点不在于单个加密算法有多复杂而在于出题人把Key拆碎藏进了静态节区、动态内存和一段带混淆的校验代码里。如果不按顺序梳理清楚很容易被表面的假字符串带偏。下面我就按我实际动手的顺序把整条解题链路完整还原出来。1. 拿到题目后的第一轮排查先别急着strings也别急着写脚本1.1 用file命令确认文件类型判断是不是个“伪装者”很多新手拿到题目后做的第一件事就是strings一顿扫然后被满屏的无意义输出淹没了。我个人的习惯是先用file命令看清楚自己面对的是什么。$ file HiddenKey HiddenKey: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped看到“not stripped”心里先松了口气。这意味符号表还在函数名能直接帮我们减少很多定位成本。另外注意它是纯ELF不是压缩包也不是固件镜像所以解题路径就是标准的本地二进制分析静态反编译、动态调试、解码验证。有些题目喜欢把flag塞进PNG图片的IDAT块里或者丢进一个看似平平无奇的tar.gz。如果你不先file一下直接当二进制去逆大概率会白费功夫。这是CTF本地题里最基础但也最容易被忽略的确认动作。1.2 strings里藏着大量假线索你得学会分辨“诱饵”确认是ELF后我还是会扫一遍字符串但心态完全不同了。这次扫字符串的目的不是为了直接拿到flag而是为了建立对程序行为的初步印象。$ strings -n 6 HiddenKey ... Correct! The Key is: %s Wrong Key KEY_IS_NOT_HERE fake_hint_for_reverse_engineers /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2注意看KEY_IS_NOT_HERE和fake_hint_for_reverse_engineers这种就是出题人故意丢出来的假线索。放在真实业务程序里没人会闲得蛋疼写这种字符串但在CTF题目里它们存在的意义就是消耗你的时间测试你会不会被表面信息牵着鼻子走。这个阶段的原则是字符串只是线索入口不是答案本身。1.3 用readelf看节区发现了一个不寻常的.hiddenstrings没有直接给出Key但给了我一个方向程序里有一段自定义的字符串输出逻辑。接下来我用readelf -S HiddenKey查看节区表发现一个非常可疑的节区。$ readelf -S HiddenKey [30] .hidden PROGBITS 0000000000004100 00004100 0000000000001000 0000000000000000 AX 0 0 16正常ELF里不太会出现叫.hidden的PROGBITS节区而且这个节区的大小刚好是4096字节标志位里居然带可执行属性。看到这种非常规节区基本可以确定是出题人手工加的料。后面的静态分析会证明这里面的东西需要经过base64和XOR两层处理才能真正变成Key片段之一。为什么出题人要把Key拆成三份这是我的理解单一字符串存放在.rodata里用strings加grep就能直接秒杀考察价值太低了。拆成三份之后第一份藏在自定义节区里测静态分析第二份在运行时才拼到栈上测动态调试第三份藏在XOR编码的比较表里测算法还原一道题覆盖三个技能点难度明显上来了。2. 静态逆向在Ghidra和IDA的交叉验证里把主逻辑翻出来2.1 工具选型Ghidra和IDA谁更好我的使用习惯是交叉验证很多朋友纠结于Ghidra和IDA哪个“更牛”说实话这取决于你的使用场景。Ghidra免费、跨平台、反编译质量已经非常接近IDA而且支持脚本批量分析IDA的F5反编译在细节处理上更成熟尤其是面对复杂结构体时伪代码的可读性更高。我的建议是预算有限选Ghidra做深度调试分析用IDA但最好两把都上。这轮题目里我先用Ghidra快速拉出main函数和check函数的伪代码建立整体印象然后再用IDA验证关键偏移量和跨函数引用的正确性。交叉验证的意义在于Ghidra对某些间接调用和栈变量的推断偶尔会出错而IDA在某些函数的参数恢复上可能有轻微偏差只有两边都指向同一个结论时我才会放心往下走。2.2 主流程梳理定位到那个判断Key对错的check函数打开Ghidra导入HiddenKey自动分析后跳转到main函数。伪代码大致长这样undefined8 main(void) { char user_input[32]; printf(Enter the key: ); read(0, user_input, 32); user_input[strcspn(user_input, \n)] \0; if (check_key(user_input, 18) 0) { puts(Correct! The Key is: HKCTF{h1dden_k3y_1s_n0t_4lw4ys_h1dd3n}); } else { puts(Wrong Key); } return 0; }当然这是我把注释简化后的样子原程序没有直接把flag字符串打在main里——那样就太简单了。它其实调用了check_key而这个check_key内部又调用了三个子函数分别对应三个Key片段。这种“一个总校验函数多个子校验”的结构在CTF题里很常见目的就是让你没法通过patch跳转一步登天。看到这个结构后解题思路就清晰了不需要把整个程序读懂只需要把check_key内部三个校验子函数分别搞定拿到三个片段然后按顺序拼接。2.3 第一片段的来源.hidden节区里的base64与XOR双层编码顺着check_key的第一个子函数追溯发现它读取的是.hidden节区开头的字符串。为什么我能看出是base64因为这段内容以S0s3开头长度是20的倍数末尾有这几乎是base64编码的教科书特征。尝试直接解码$ echo S0s3U0Uz... | base64 -d输出是一串看似乱码的字节。再尝试用常见单字节XOR密钥0x00到0xFF穷举结果密钥0x2A解出的内容里有可读的KEY_PART_1字样。第一片段到手。这个片段故意做成“先base64再XOR”而不是单纯base64就是要让人在第一步解码成功之后产生“我已经拿到钥匙”的错觉然后在第二环节才发现这只是个碎片。出题角度来说很巧妙解题角度来说也提醒我凡是自定义节区、附加资源、奇怪的.rodata段都要做好“里面不止一层编码”的心理准备。3. 动态调试让GDB替我把内存里的Key片段直接捞出来3.1 断点应该下在哪里决定了你能否一眼看到Key静态分析能帮我确认第二片段来源于某个运行时拼接的函数但具体拼出来的内容长什么样还是得看动态。打开GDB先把断点下在check_key内部的cmp_bytes函数入口。$ gdb ./HiddenKey (gdb) break cmp_bytes (gdb) run为什么断点下在这里而不是直接下在memcmp因为程序作者为了避免被直接断在库函数自己实现了一个逐字节比较的cmp_bytes。这个函数在每次比较前会把两个指针分别放到%rdi和%rsi所以只要断点命中直接看这两个寄存器指向的内存就能还原被比对的字符串。3.2 用gdb commands脚本自动打印寄存器省去手动重复操作从静态分析看cmp_bytes会被循环调用多次每次都手动x/s $rsi实在太累。GDB支持断点命令组可以自动执行指定操作(gdb) commands 1 silent printf cmp target: x/s $rsi printf compare result: %d\n, $rax continue end再次运行GDB会自动打印每次cmp_bytes被调用时$rsi指向的字符串。跑了几轮之后我抓到了一段ASCII内容cmp target: 0x7fffffffdde0: p4rt2_dyn4m1c这还不是最终Key因为第三片段还得解码。但它证实了第二片段确实是运行时动态压栈的并且内容里自带p4rt2字样说明三个片段是有明确顺序的。用GDB这种“让调试器自己帮我遍历所有比较现场”的思路可以节省大量重复劳动也避免因为手速慢漏掉关键跳变。3.3 动态调试中遇到的崩溃信号反调试不是每次都那么可怕跑着跑着程序在某次执行时直接收到SIGTRAP信号并退出。初步判断是出题人埋了一个反调试校验可能是用ptrace套自身也可能是一段不透明的进程追踪逻辑。处理思路不是一上来就对抗而是先确认它到底做了什么。GDB里用catch syscall ptrace或者catch signal SIGTRAP都能定位异常来源。这里我的做法是直接让GDB忽略SIGTRAP(gdb) handle SIGTRAP nostop noprint (gdb) continue实测下来这个反调试是用一个不透明的条件判断伪装出来的当程序处于调试状态下时会主动触发raise(SIGTRAP)干扰跳过之后主干流程完全不受影响。这类反调试在设计上往往是“干扰”而非“阻断”不要一看到信号就以为整个程序坏了先尝试忽略异常信号继续跑再决定是否需要patch。这个经验救过我很多次尤其在一些小型CTF题里作者只想着恶心你一下没真想做到杀毒级别的对抗强度。4. 关键步骤还原三个Key片段编写解码脚本跑通最终验证4.1 把三份线索摆到桌面上来源、形态、解码方式到了这一步手里已经有三份不完全等价的线索需要统一整理。我习惯先用表格把证据链拉通理清哪些是直接可用的哪些还要继续加工。片段来源位置原始形态解码方式还原结果Part 1.hidden节区base64字符串base64解码后再单字节XORKEY_PART1:...Part 2cmp_bytes运行时栈内存明文ASCII直接读取p4rt2_dyn4m1cPart 3校验表所在.rodata24字节密文单字节XOR爆破还原一串以h1dden开头的字符串第三片段我在静态分析时就已经注意到它是一段固定的字节表程序在校验时会把用户输入经过简单变换后和它做逐字节比对。只要知道了变换逻辑是input[i] ^ 0x2A table[i]就能直接反推出正确输入是什么。这里和第一片段恰好用了同一个多字节Key算是一个重复利用的设计也挺巧妙。4.2 让AI辅助写解密脚本但提示词必须给足上下文这台题我用AI辅助写了三段关键脚本但要声明一点AI并不是全程自动驾驶它更像一个懂套路但偶尔会自信地胡说八道的副驾驶。我给它输入了完整的线索、疑似编码顺序和最终目标要求它产出可运行的Python代码。我实际使用的提示词大概是这样的我有一段从CTF二进制里提取出来的密文字节用十六进制表示如下 ... 已知这个密文先经过单字节XOR处理密钥可能是0x2A之后再base64解码。 请帮我写一个Python脚本完成以下任务 1. 尝试常见单字节XOR密钥0x00-0xFF 2. 对每个结果判断是否包含可读明文字段 3. 输出所有包含HKCTF或KEY_PART特征的候选结果 不要给猜的结论只要可执行的代码。AI很快给出了一版脚本我先修了它一个关键错误它默认把base64放在XOR前面而实际顺序应该是XOR之后才做base64解码。这直接导致第一批候选结果全是乱码。通过这个教训我意识到给AI提示词时一定要写清楚“数据的处理顺序”不然它只会按最常见的套路来。下面是我最后修正后能在本地直接跑通的脚本import base64 # 密文片段来自 .hidden 节区 payload_b64 S0s3U0Uz... # 实际以提取到的base64为准 # 如果长度不对先补全padding padding * ((4 - len(payload_b64) % 4) % 4) payload base64.b64decode(payload_b64 padding) def try_xor(data: bytes, key: int): return bytes([b ^ key for b in data]) for key in range(256): decrypted try_xor(payload, key) if bKEY in decrypted or bHK in decrypted or b{ in decrypted: print(f[] key0x{key:02x}, result{decrypted!r})跑出来的结果里只有key0x2a对应的文本同时包含KEY_PART1和h1dden等可读内容其他密钥对应的输出都是高熵乱码。这一步让我确认了三条线索的编码方式和密钥选择是统一的。4.3 最终验证三个片段按序拼接跑出完整Key把三段还原结果按序拼接Part1 Part2 Part3补全成最终输入。执行程序$ ./HiddenKey Enter the key: HKCTF{h1dden_k3y_1s_n0t_4lw4ys_h1dd3n} Correct! The Key is: HKCTF{h1dden_k3y_1s_n0t_4lw4ys_h1dd3n}输出里的“The Key is”和输入完全一致整条链路验证通过。这一步的真正意义不在于“跑出来了”的成就感而在于证明前面所有分析结论互相吻合静态定位没有错、动态抓取没有漏、解码顺序没有再反。如果任何一个环节出错最终输入都不会被程序接受。CTF解题的正确性永远以程序实际执行结果为准而不是以分析者“觉得应该是对的”为准。5. 复盘AI辅助Writeup的正确打开方式以及它哪里会翻车5.1 AI在解题链路里的角色不是替你把题做了而是帮你把题想清楚很多朋友一听说“Writeup by AI”第一反应是AI把题目秒解了。实际不是这样。AI在这类CTF题目里能真正发挥作用的环节是把Ghidra反编译出来的冗长伪代码翻译成通俗语言快速确认某个函数在干嘛根据你提供的线索特征生成符合语境的Python脚本比如XOR爆破、base64批量解码在你贴出GDB报错信息时给出可能的故障方向和建议排查点帮你整理writeup思路把一长串调试记录提炼成有逻辑的文档但它不擅长的事情同样明显它无法替你去真实地址空间里读取寄存器内容也无法凭空知道某一个偏移量上到底存放着什么。AI给出的所有偏移、函数名、内存地址都必须经过实际工具验证后才能采用。我这次就遇到过它把一个memcmp参数顺序颠倒的情况直接导致早期脚本翻车。AI是很好的加速器但它不会替你按“验证”键。5.2 当AI输出可信度存疑时我的处理原则分级信任 实测兜底经过多次实战我整理出了一个简单可用的AI信任分级内容类型信任程度原因伪代码“这段循环在做什么”的解释中高模式普遍依赖训练数据覆盖度常见编码识别和标准解法建议中高base64、异或、字符串提取套路高度成熟具体偏移地址、函数地址、寄存器值低这些值依赖具体二进制布局AI容易编造工具命令的记忆和参数细节中可能张冠李戴必须实测原则很简单把AI当作“思路生成器”把GDB和Ghidra当作“事实裁决器”。凡是AI给的地址我都会先在GDB里info address或者disassemble确认一遍凡是AI写的解码脚本我都会先用已知明文片段做一次回归测试。这条路听着麻烦长期反而是最省时间的——因为基于错误假设的分析成本往往比多跑一次命令高得多。5.3 给新手的几条实操建议先完整走一遍没有AI辅助的“笨办法”——手动strings、手动看伪代码、手动下断点。只有你自己清楚每一步为什么这么做后续让AI帮你提速才谈得上意义。向AI提问时尽量具体给出文件类型、已经提取到的密文片段、你的怀疑方向而不是干巴巴一句“帮我解出flag”。上下文越完整AI输出越可用。养成记录命令和输出的习惯。这个习惯的价值在结束时会体现得淋漓尽致writeup里每一条能复现的指令都是你真正理解题目路径的证明。6. 常见问题与避坑记录这次调试里我踩过的三个比较深的坑6.1 常见问题速查表先给结论再展开问题现象可能原因排查思路strings里看不到任何关键信息Key被分段、编码或存储在自定义节区检查ELF节区表关注非标准PROGBITS段gdb断点打不上函数被内联或符号丢失用info functions搜索全部函数断在调用点解出来的内容全是乱码编码顺序搞反或XOR密钥错误先确认处理顺序再用0x00-0xFF全量爆破AI生成的脚本运行报错缺少依赖或数据处理顺序不对让AI解释脚本每步在干什么再和实际字节比对6.2 坑一base64长度对齐问题差点让我误判第一片段无效第一片段提取出来时末尾没有标准的填充我一度怀疑自己是不是只提取了半个数据块要重新切分节区。后来直接在Python里补全padding按len % 4计算补齐等号就顺利解开了。这件事提醒我从二进制里截取的资源未必格式完整遇到边界裁剪问题时先别怀疑方向优先怀疑数据是否在提取过程中被削足适履。6.3 坑二AI给的偏移量不可靠GDB实测才是唯一真相AI在分析Ghidra反编译结果时一度信誓旦旦地告诉我“.hidden节区的偏移是0x4000”但我在GDB中运行info file后发现真实的节区地址是0x4100。一字之差如果直接基于错误偏移去写提取脚本后续全跑偏。写writeup时我强调了这个教训二进制世界里没有任何“应该的地址”只有“实测的地址”。对AI给出的地址类结论默认先打一个问号用工具验证后才采信。6.4 坑三把XOR的密钥顺序搞反导致恢复出来的明文顺序错乱在编写第三片段的还原脚本时一开始写的是table[i] ^ user_input[i]方向搞反了跑出来始终不合理。这让我卡了快四十分钟。后来返回去重新读校验函数的反编译代码确认是user_input[i] ^ 0x2A table[i]才想起来XOR是对称运算密钥在哪一边不影响结果影响结果的是你把“明文”和“密文”的位置搞反了。修复后一次通过。6.5 收个尾分享一个我自己的习惯解出Key之后的半小时我没有立刻收工而是把整条链路从头到尾重新执行了一遍file、readelf、strings、Ghidra定位、GDB断点、Python解码、最终运行验证。每一步的命令、输出、结论都整理成了表格和代码块。这个习惯帮我发现了不少writeup里“当时很顺但复盘时经不起推敲”的地方也让这篇Writeup能保证每一步都是可复现的。AI辅助这道Hidden Key的过程让我重新理解了“Writeup by AI”这句话的真正含义AI确实参与了解题链条的理解提速和脚本生成但最终决定Key正确与否的永远是程序自己在内存空间里的真实行为。工具再强、AI再快也不会替你把“验证”这一步放水。如果你想把这题作为练习建议先不要看答案自己动手把file、readelf、gdb、python这一套组合起来跑一遍过程里踩过的每一个坑都会成为你下一道题涨的每一分肌肉记忆。
