ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CTF逆向实战:从反调试绕过到VM虚拟机加密链路还原

CTF逆向实战:从反调试绕过到VM虚拟机加密链路还原 最近在某场 CTF 赛事的逆向题组里碰到一道叫 HellGate 的题目。赛后看群里讨论大部分人是死在反调试上少数人绕过反调试后又栽在 VM 虚拟机里能真正打通的人基本都经历了反调试绕过 → VM 指令还原 → 加密链路逆推的完整链条。这篇文章就当一篇完整的赛后复盘来写把每一块的关键判断、工具操作、翻车点都讲清楚希望能给正在从入门往进阶走的逆向选手一点实际参考。1. 第一眼该从哪里开始拆这个二进制1.1 文件类型与环境准备拿到题目先别急着双击运行。我用的是 Ubuntu 22.04 环境习惯先做一遍基础信息收集。这类操作在 CTF 里属于体检看起来简单但能省掉后面大量的瞎猜时间$ file hellgate hellgate: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped $ checksec --filehellgate RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH No Symbolsstripped PIE NX这在 CTF 逆向题里几乎是标配了。stripped 意味着符号表全没了所有函数都是 sub_xxx 的形式分析时只能靠入口点推断 mainPIE 意味着地址随机化调试时地址每次都在变非常影响看数据。所以我在正式动手前会先把 gdb 的地址随机化关掉(gdb) set disable-randomization on这条命令建议直接写进 gdb 的启动脚本因为后续动态调试要反复下断点、看内存如果每次运行地址都变那整个调试节奏会被拖慢不少。工具方面我用的是 IDA gdb 的组合。IDA 负责静态还原函数结构、定位检测点gdb 负责动态绕反调试和 dump 内存。能用 radare2 做同样的事只是我个人的习惯是 IDA 的 F5 伪代码在看复杂函数时更快一些。1.2 第一次运行暴露的线索正常跑一下程序界面很简洁$ ./hellgate Welcome to Hell Gate Are you worthy? flag{test} Wrong answer.程序会读一行输入然后告诉我们答案不对。按常规思路下一步就是上 gdb 步进看比较逻辑在哪。但等我真正起 gdb 的时候程序表现完全变了$ gdb ./hellgate (gdb) run You are not allowed to debug me.输入的机会都不给直接退出了。这说明程序里有反调试逻辑而且它知道自己正在被调试。为了确认到底是什么检测我顺手跑了一个 strace$ strace -f ./hellgate ... ptrace(PTRACE_TRACEME, 0, 0, 0) -1 EPERM ...这就很明确了程序在最前面调用了ptrace(PTRACE_TRACEME, 0, 0, 0)。假如程序处于被调试状态连 strace 都算一种调试器那么当前进程已经被 trace再次对自己的父进程发起 PTRACE_TRACEME 就会失败返回 -1程序借此判断有人在跟踪我直接退出。这层反调试其实是整个题目里最友好的部分因为它套路固定、特征明显几乎看一眼就知道怎么绕过。麻烦的是后面还藏着两层检测后面细讲。2. 和反调试过招从 patch 到动态追踪2.1 反调试检测点定位在 IDA 里定位到主函数后一上来就能看到几个明显的反调试调用。我把它们整理成了一张表检测方式位置特征原理ptrace(PTRACE_TRACEME)程序起始阶段自身被 trace 时ptrace 返回失败读取 /proc/self/status 的 TracerPid输入前后各读一次调试状态下 TracerPid 非 0时间差检测加密循环前后取时间戳被单步调试时耗时明显偏长第一次运行只触发了第一个检测因为 gdb 本身就占用了 ptrace。后面把这一层绕过去之后第二层 TracerPid 检测又蹦了出来。逻辑很好定位程序打开/proc/self/status逐行找TracerPid:把后面的数字和 0 比较不为 0 就退出。这招在 Linux 调试场景里太常见了几乎每个认真做反调试的程序都会用。第三层时间差检测藏得比较深它在 VM 解释器内部专门针对单步调试场景。如果你一路 step 过去运行时间会明显拉长程序发现在加密循环里停留太久直接抛出一个假错误结果来迷惑你。所以这道题在反调试没处理干净之前动态调试会经常遇到莫名退出或者输出看似正常但结果全错的情况。2.2 让调试器隐形LD_PRELOAD 与 patch 对照我试了两种绕过方案各有适用场景。第一种是直接 patch 二进制。用十六进制编辑器或者 radare2 找到 ptrace 调用点把调用指令改成直接返回 0。具体做法是找到类似下面的模式call ptrace test eax, eax jz normal_path然后把call ptrace替换成xor eax, eax机器码 31 C0并 NOP 填充让 eax 恒为 0。这样程序无论如何都会走正常分支。这种方案的好处是一劳永逸坏处是如果程序里有自校验patch 会引发新的崩溃。本题倒没有自校验所以 patch 是可行的。但我实际更推荐第二种方案LD_PRELOAD 劫持 ptrace。这是逆向比赛里对付 ptrace 反调试的经典招数写一个假的 ptrace 函数提前加载进去把系统调用替换掉#define _GNU_SOURCE #include sys/ptrace.h long ptrace(void *request, void *pid, void *addr, void *data) { return 0; }编译成 so 再启动gcc -shared -fPIC -o noptrace.so noptrace.c LD_PRELOAD./noptrace.so ./hellgate这个方案的优点是不用动原始文件而且对 ptrace(PTRACE_TRACEME) 的检测是通杀。缺点是它只解决系统调用这一层对第二层 TracerPid 检测无能为力因为那是读文件判断的。所以真正的完整方案是系统调用劫持 调试器脚本双管齐下。绕第二层时我直接用 gdb 的 catch syscall 加返回值改写(gdb) catch syscall ptrace (gdb) commands silent return 0 continue end这套组合拳打完之后程序终于能顺利跑到等待输入的位置。反调试这关过了后面才有分析的余地。3. VM 入口还原当题目也写了一个解释器3.1 识别 VM 入口的关键特征反调试搞定之后我们终于能稳稳在输入后断下来。但接下来才是这道题真正的重头戏——主逻辑不是普通的函数调用链而是跑在一个自定义虚拟机里。识别 VM 入口有个非常管用的特征代码里必然存在一个取指令 → 查表 → 跳转执行的循环。在 IDA 里看到类似下面的伪代码结构时基本可以断定是 VMwhile (1) { op bytecode[pc]; handler handler_table[op]; handler(vm_state, bytecode, pc); }handler_table是一个函数指针数组每个子函数负责一条指令语义比如 mov、xor、add、push、jmp。它存放在数据段通过 IDA 交叉引用就能找到这两个关键对象的位置。HellGate 的做法是把用户输入的 flag 先拷贝到 VM 的虚拟寄存器区然后解释器逐条执行一段字节码这段字节码调用若干 handler逻辑上实现输入加密 → 与内置密文比较 → 输出结果。这里有个经验之谈我们不需要把整个 VM 都还原成高级语言那样太费时间。CTF 的 VM 题核心是数据变换与其逐条解释全部指令不如抓住四种 handler赋值、异或、比较、跳转。把这四种 handler 的行为弄明白加密逻辑的大框架基本就出来了。3.2 字节码里沉淀的加密影子我的做法是先把字节码段从内存里整段 dump 出来然后按 opcode 频率统计。HellGate 的字节码有几百字节但操作类型其实很少。我整理出的指令表如下opcodehandler 功能备注0x01setup_reg加载虚拟寄存器0x02mov_imm寄存器赋值立即数0x03xor_reg寄存器异或0x04add_reg加法0x05push_stack压栈0x06pop_stack弹栈0x07compare_data与内存数据比较0x08jmp_cond条件跳转观察 opcode 的排列能看到很有意思的模式0x02、0x01、0x03、0x04、0x05交替出现的地方往往是一个循环体。循环体里反复加载立即数、异或、加减这正是加密算法的影子。我结合后面在 gdb 里对寄存器的观察最终确认这条字节码依次干了三件事一段 XOR、一段 TEA 变体、一段 RC4 风格密钥流。从字节码里直接找不变量也是个高效方法。比如 TEA 的特征常量会以立即数的形式出现在字节码里你可以在 dump 出的二进制里直接搜索十六进制特征串。这种方法在还原 VM 题时非常快一旦命中整个加密算法的方向就明确了。3.3 dump 数据的实操细节在 VM 里跑加密有个好处数据都是连续写在堆上的。我在 compare_data handler 下断点然后查看虚拟寄存器和数据区(gdb) x/64bx $rdi 0x603200: 0x41 0x6b 0x31 0x74 ...这里$rdi指向的数据就是经过多层加密后的结果。为了方便写解密脚本我用 gdb 的 dump 命令直接把这块内存保存下来(gdb) dump binary memory enc_result.bin 0x603200 0x603260然后我换了几个不同的输入分别保存 enc_result.bin对比它们之间的差异。这样能快速判断哪些字节是固定密文、哪些字节随输入变化把动态数据和静态数据区分开。这一步做完加密链路的数据流就基本摸清了。4. 藏在虚拟机里的加密链路三套算法的串联4.1 第一层一个不起眼的 XOR第一条加密链是从 xor_reg handler 反复执行开始的。通过还原后的虚拟指令流可以看到它对输入数据做了多次循环异或异或的 key 是一段 8 字节的立即数序列从字节码里可以直接提取key [0x5C, 0x8A, 0x2F, 0x9B, 0x1D, 0xE4, 0x73, 0x06]这是整条链里最不起眼的一层实现方式就是在 VM 里反复执行 xor_reg handler。但它存在的作用很关键把用户输入和后面两套算法彻底搅在一起使得你无论单独还原哪一层都拿不到正确结果。如果不先把这一层解除后面所有参与比较的数据都会对不上号。所以在解密脚本里第一步永远是按这 8 字节 key 对数据做 XOR而且顺序不能乱。我最初就吃过这个亏先解了 RC4 再解 XOR结果输出完全不可读回头才发现应该从最后一层开始倒着解。多算法串联的题逆推顺序必须和正向加密严格相反这一条几乎适用于所有同类题目。4.2 第二层TEA 变体的识别与还原第二层比较复杂。字节码中出现了一个非常扎眼的立即数0x9E3779B9这几乎是 TEA/XTEA 系列算法绕不开的特征常量。不过它并不是标准 TEA因为在字节码里可以看到循环次数不是固定的 32 轮而是被前面 XOR 的结果动态决定的循环内部的结构更接近 XXTEA 的混合写法。标准 TEA 解密函数是这样def tea_decrypt_block(v, key): delta 0x9E3779B9 sum_ (delta * 32) 0xffffffff v0, v1 v for _ in range(32): v1 (v1 - (((v0 4) 0xffffffff) key[2] ^ (v0 sum_) ^ ((v0 5) key[3]))) 0xffffffff v0 (v0 - (((v1 4) 0xffffffff) key[0] ^ (v1 sum_) ^ ((v1 5) key[1]))) 0xffffffff sum_ (sum_ - delta) 0xffffffff return v0, v1对于变体我们需要在虚拟机指令流里找到 sum 的更新位置和 key 的来源。我在分析时留意到字节码里有一个单独存储 sum 的虚拟寄存器循环里每一步都对这个值做加减于是确认它走的还是 TEA 那套黄金分割常数 左右位移混合的骨架只是把轮数改成了动态值。识别这类变体的核心经验是先找常量再找循环骨架最后看差异点。不要一见到0x9E3779B9就套标准函数必须先确认轮数和 key 的装载方式。很多变体题故意把delta保持不变但把轮数藏到另一个寄存器里让直接套标准解的选手输出一串乱码。4.3 第三层RC4 风格密钥流的陷阱第三层加密的识别过程最有迷惑性。字节码里出现了一个从 0 到 255 的循环初始化这基本是 RC4 的 S 盒初始化特征。但如果你直接按标准 RC4 去解会发现明文后段全是乱码。原因在于这道题的RC4并不是用原始 key 直接初始化 S 盒而是用前面 XOR 和 TEA 处理后的中间结果作为密钥流种子。算法做了串联前面的输出是后面的密钥。这种陷阱只有在完整还原数据流之后才能发现。我是在对比第三层运行时 S 盒内容和标准 RC4 初始化结果时发现的两者的置换依赖完全不一致。找到这层关系后解密脚本就变成了一条清晰的逆推链正向加密阶段作用逆向对应操作XOR打乱输入XOR 还原TEA 变体分组混淆TEA 变体解密RC4 风格生成最终密钥流RC4 逆推还原正向的顺序是 XOR → TEA → RC4逆向时必须完全反过来先 RC4 逆推、再 TEA 解密、最后 XOR 还原。这个顺序如果搞反前面全白做我在这上面至少浪费了半小时。5. 从密文到 flag写一个逆推脚本5.1 提取运行时数据写脚本之前先把三样东西准备齐8 字节 XOR key从字节码立即数区提取固定值TEA 的 16 字节 key 和动态轮数从虚拟寄存器 dump 得到密文数据从 compare_data 断点处保存的 enc_result.bin。TEA 的 16 字节 key 在字节码里也是一段连续立即数我通过 gdb 在 setup_reg handler 里查看rdi指向的寄存器区时能直接看到它被装载的过程。这种在断点处观察寄存器装载的方法比纯 IDA 静态看更快因为你不需要理解整个 handler 的字节级实现只要知道哪个寄存器装了什么东西就行。动态轮数这个值尤其要注意它不是固定常量而是根据输入变化。所以在 dump 的时候我对多个不同输入都取了值。发现轮数随输入变化之后解密脚本里的轮数就不能写死必须从第一次运行时记录的虚拟寄存器值里动态读取。这一步是变体 TEA 和标准算法最大的区别。5.2 逆推脚本实现下面是我最终使用的 Python 解密脚本核心片段import struct def xor_decrypt(data, key): out bytearray() for i, b in enumerate(data): out.append(b ^ key[i % len(key)]) return bytes(out) def tea_decrypt(data, key): delta 0x9E3779B9 out bytearray() for i in range(0, len(data), 8): v0, v1 struct.unpack(2I, data[i:i8]) sum_ (delta * 32) 0xffffffff for _ in range(32): v1 (v1 - (((v0 4) 0xffffffff) key[2] ^ (v0 sum_) ^ ((v0 5) key[3]))) 0xffffffff v0 (v0 - (((v1 4) 0xffffffff) key[0] ^ (v1 sum_) ^ ((v1 5) key[1]))) 0xffffffff sum_ (sum_ - delta) 0xffffffff out struct.pack(2I, v0, v1) return bytes(out) def rc4_crypt(data, key): S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % len(key)]) 0xff S[i], S[j] S[j], S[i] i j 0 out bytearray() for b in data: i (i 1) 0xff j (j S[i]) 0xff S[i], S[j] S[j], S[i] out.append(b ^ S[(S[i] S[j]) 0xff]) return bytes(out) cipher open(enc_result.bin, rb).read() tea_key bytes.fromhex(...) xor_key bytes.fromhex(5c8a2f9b1de47306) # 正向是 XOR - TEA - RC4 # 逆推必须反过来RC4 逆推 - TEA 解密 - XOR 还原 stage2 rc4_crypt(cipher, tea_key xor_key) stage1 tea_decrypt(stage2, tea_key) flag xor_decrypt(stage1, xor_key) print(flag)实际写的时候第三层 RC4 的密钥拼接方式需要根据 dump 下来的 S 盒调整。我这里写的是TEA key 与 XOR key 拼接的常见情况如果你的题里 key 来源不同就替换成对应的提取值。如果发现解密结果中段出现乱码优先检查两个点一是密钥顺序是不是反了二是 RC4 的 key 长度到底是 8、16 还是 32这是返工最常见的原因。5.3 最后的校验脚本跑完后控制台输出的是一段可读的 ASCII 文本直接就是符合 flag 格式的字符串bflag{VM_1s_n0t_s0_h4rd_t0_r3v3rs3}看到这串结果后我没有直接宣布完成而是老老实实把它重新喂给程序确认主程序输出的是正确提示而不是 Wrong answer。这一步验证很关键。同时我又换了几个随意构造的输入跑完整个解密流程确认脚本逻辑在任意输入下都能还原出原文而不是碰巧对某一个输入成立。只有在多组输入都通过之后才能说加密链路是真的彻底打通了。这也是复盘时比较容易忽略的一点——很多时候解出一个看起来像 flag 的字符串就收工了验证不够严谨的话可能会被题目的小陷阱骗过。6. 复盘这类 VM 反调试题目以后可以怎么打6.1 时间分配与执行节奏整道题我花了大概三小时反调试半小时VM 结构分析一小时加密识别和脚本逆推一个半小时。这个节奏其实很有 CTF 实战特点前两关通过率低但真正耗时的是最后一公里的加密逆推。所以面对这类题目建议先把反调试速通不要在 patch 和动态调试的细节上反复纠缠。能 LD_PRELOAD 解决就绝不手工改字节能 catch syscall 就绝不反复重启调试器。我见过很多人在 ptrace 反调试上花了一两个小时其实这层基本就是送分的快速过掉才是正事。6.2 VM 题通用的数据流优先原则很多 VM 题吓人是因为题目把几十行的加密逻辑拆成了上千条虚拟指令让人感觉永远还原不完。但这类题实际上只考察数据变换核心动作永远是读输入、算数据、比较、跳转。我的建议是优先分析load、store、compare和xor这四类 handler把数据在虚拟寄存器里的流动方向画清楚算法识别是水到渠成的事。最好不要再陷入逐条指令解释整个 VM的坑里那就变成翻译字节码了既慢又容易错。数据流画清楚之后你会发现 VM 只是套了一层外壳里面的加密算法和普通二进制里出现的完全一样。6.3 几个容易卡住的实际问题最后列几个实战中容易卡人的细节都是我这次踩过的字节序坑TEA 的 64 位分组在内存里是低位在前Python 脚本一定要用2I解包用大端解包得到的结果会全线错位。你可能会看到前半段是对的、后半段全是乱码这种情况第一时间查字节序。S 盒偏移坑标准 RC4 把 S 盒下标从 0 计起但某些混淆过的算法会把下标整体偏移 256 或 128。识别时需要结合 S 盒初始化循环的实际起止范围判断不能只看有没有 0-255 循环就认定是标准实现。动态轮数坑TEA 变体的轮数写在虚拟寄存器里dump 的时候容易只看到当前值忽略了它随输入改变的事实。分析阶段最好对多个输入分别取值确认轮数到底是常量还是输入依赖然后再决定脚本里怎么处理。反调试残余坑TracerPid 检测在 gdb 的非交互子进程里很容易漏掉。我建议 patch 完 ptrace 之后再顺手把/proc/self/status读取点的比较逻辑也断掉省得后面分析正嗨时又被踢出来。这道题是我近期刷到的一篇很值得回味的综合逆向题单看每个点都不算难但串在一起之后的反调试 → VM → 多算法结构非常考验选手的全局编排能力。如果以后遇到类似的题目我会先花几分钟从运行行为里摸清反调试套路然后立刻切到 VM 的 handler 视角思考而不是一上来就追着单个算法满屏找密钥。数据流的整体顺序永远比局部细节优先。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进