资讯详情

资讯详情

OLLVM控制流平坦化逆向实战:从混沌到逻辑重建

1. 这不是普通CTF题解它是一份逆向工程能力的“压力测试报告”“2020网鼎杯-青龙组-Re部分WP”这个标题乍看是某场网络安全竞赛的赛后复盘但如果你真把它当成一份“答案速查表”来读大概率会在实操中卡死在第二步。我带过不少刚接触CTF逆向的新手他们常犯一个致命错误把Write-upWP当操作手册照着步骤点几下IDA就以为通关了。结果一换题、一改壳、一加混淆立刻抓瞎。而青龙组这道题恰恰就是专治这种“伪熟练”的——它不考你能不能识别出sub_401230是base64解码而是考你在符号全丢、控制流打散、数据流加密、反调试层层嵌套下如何重建逻辑骨架。这道题的核心价值从来不在“做出来”而在“怎么想出来”。它模拟的是真实二进制审计中最棘手的场景你拿到的不是干净的PE文件而是一个被多层保护器包裹、关键字符串全部动态拼接、所有跳转都依赖运行时计算的“黑盒”。关键词里虽然没写但实际涉及的硬核技术点非常明确OLLVM控制流平坦化Control Flow Flattening的识别与还原、字符串动态解密的静态定位策略、基于函数调用图Call Graph的敏感逻辑路径剪枝、以及对抗IsDebuggerPresent/NtQueryInformationProcess等基础反调试的绕过思路。它不教你怎么用angr跑出flag而是逼你亲手把被flatten掉的switch-case一层层“掰直”把被拆成27个片段的flag校验逻辑重新缝合。适合谁来啃不是纯新手。如果你连__security_cookie的作用、.idata节和导入表的关系、或者lea eax, [ebxecx*4]这条指令在什么场景下比mov eax, ebx更高效都说不清楚建议先补完《x86汇编语言从实模式到保护模式》前八章。但如果你已经能独立完成简单Crackme的序列号算法逆向却总在遇到OLLVM或VMProtect时陷入“看得见代码、理不清逻辑”的困境那这份笔记就是为你量身定制的压力测试报告。它记录的不是标准答案而是一个资深逆向者在面对高度混淆二进制时如何用有限的静态信息结合对编译器行为、Windows API机制、常见混淆工具特征的深度理解一步步把混沌拉回秩序的过程。接下来的内容每一行都对应我当时在IDA里放大又缩小、反复切视图、在注释框里删了又写的思考痕迹。2. 青龙Re题的“三重迷雾”为什么常规逆向流程在这里全面失效拿到题目附件qinglong.exe第一反应永远是拖进PEiD或Exeinfo PE查壳。但这次工具只返回模糊的“Microsoft Visual C 6.0”——这显然不对。VC6.0编译器不可能生成如此复杂的控制流结构。经验告诉我这要么是加了壳但被工具误判要么是用了高级混淆而非传统加壳。用strings命令扫一遍发现可读字符串极少仅有的几个如Input flag:、Correct!、Wrong!还都分散在不同内存段且地址跨度极大。这直接否定了“字符串交叉引用找关键函数”的捷径。真正让我意识到问题严重性的是打开IDA Pro后的第一眼。主函数sub_401000的CFG控制流图像一团乱麻一个超大基本块里塞着上百条jmp指令目标地址全是类似loc_401A5C、loc_401B28这样的无意义标签彼此之间没有明显的条件跳转逻辑。这不是代码写得差这是典型的OLLVM控制流平坦化特征。OLLVM在编译阶段会把原始的if-else、for循环等结构统一转换成一个巨大的while(1)循环内部用一个状态变量state variable控制执行哪个“handler”每个handler只做极少量工作后就更新state并跳回循环头。这就导致静态分析时你看到的不是“程序逻辑”而是“调度逻辑”你无法通过阅读代码顺序理解业务必须先还原出state变量的流转图再映射回原始语义。第二重迷雾是字符串的彻底消失。flag{这个标志性字符串在整个二进制中完全搜不到。但程序运行时确实会输出Correct!。这意味着所有字符串都是运行时动态构造的。我尝试在printf或MessageBoxA的交叉引用里找线索发现调用点本身也被flatten了参数传递路径被拆成五六次寄存器赋值中间还夹杂着无用的xor eax, eax和nop填充。更麻烦的是关键的校验逻辑——比如memcmp或逐字节比较——其比较的目标缓冲区地址是通过一个复杂的leaaddshl链式计算得出的而参与计算的寄存器值又来自前面某个handler里解密出的密钥。这就形成了一个闭环要理解校验逻辑需先知道密钥要知道密钥需先理解解密逻辑而解密逻辑的入口又被控制流平坦化藏在几十个handler中的某一个里。第三重迷雾是反调试的“软性渗透”。它没用IsDebuggerPresent这种一眼就能patch掉的API而是调用了NtQueryInformationProcess查询ProcessBasicInformation结构体里的BeingDebugged字段。这个字段在进程创建时就被内核设置patch掉API本身无效必须在调用前修改EAX寄存器的返回值或者更狠——在NtQueryInformationProcess的ret指令处下硬件断点手动篡改栈上保存的返回值。但问题来了这个调用点同样被flatten了你根本不知道它在哪一个handler里。我试过用x64dbg附加后搜索NtQueryInformationProcess的调用结果在OutputDebugStringA附近发现了大量可疑的call但它们的参数全是垃圾值。后来才明白这是混淆器故意插入的“诱饵调用”真正的反调试逻辑藏在另一个handler里且调用前会先检查当前state值是否为特定常量否则直接跳过。这已经不是技术对抗而是心理博弈它在逼你怀疑自己的每一个观察结论。提示面对OLLVM flatten别急着用de4dot或unflattener这类自动化工具。它们在青龙题上基本失效因为混淆器额外加入了“state值校验”和“handler地址随机化”——每次编译handler的起始地址和state的初始值都不同。你必须接受一个事实自动化工具能帮你省去画CFG的时间但无法替代你对state机语义的理解。真正的还原始于你在IDA里手动给每个handler标注“此handler负责解密key_low”、“此handler负责计算输入长度”这样的语义标签。3. 拆解OLLVM控制流平坦化的四步法从混沌到可读的实战推演面对青龙题里那个令人窒息的sub_401000我放弃了从头读代码的幻想。取而代之的是用一套经过多次实战验证的“四步法”进行系统性拆解。这套方法不依赖任何插件核心是利用IDA的图形视图Graph View和交叉引用Xrefs功能辅以最朴素的手动标注。它的本质是把一个抽象的state机还原成程序员能理解的“状态-动作”映射表。3.1 第一步定位“循环头”与“状态变量”——找到整个迷宫的“中央枢纽”在IDA的图形视图中我首先寻找一个具有以下特征的基本块它包含一个无条件跳转jmp且该跳转的目标地址在多个其他基本块的末尾都出现过同时它接收一个寄存器通常是eax或edx作为输入并根据该寄存器的值决定下一步跳向哪个handler。很快loc_401200引起了我的注意。它的结尾是jmp dword ptr [eax*4 offset unk_40A000]而unk_40A000这个地址在数据视图里是一片连续的DWORD数组内容全是类似401A5C、401B28这样的地址。这正是OLLVM的dispatch table分发表。而eax就是那个神秘的state变量。为了确认我向上追溯eax的来源发现它在函数开头被初始化为一个固定值比如0x12345678之后所有handler执行完毕都会更新eax为下一个state值再跳回loc_401200。至此“中央枢纽”锁定loc_401200是循环头eax是state变量unk_40A000是state到handler地址的映射表。3.2 第二步枚举所有Handler并分类——给迷宫的每个房间贴上功能标签有了dispatch table下一步就是把表里每一个地址对应的基本块都找出来并分析其功能。我写了一个极简的Python脚本在IDA Python控制台里直接运行遍历unk_40A000指向的地址数组对每个地址调用get_func_name()和generate_disasm_line()把结果导出到文本文件。得到约42个handler编号从h_0到h_41。然后我按以下规则手动分类初始化类Init只做一次通常在state0时执行负责初始化全局变量、申请堆内存、设置初始密钥。青龙题里h_0就做了VirtualAlloc申请一块可执行内存h_1把一段加密数据拷贝进去。解密类Decrypt负责解密后续handler的代码或关键数据。h_5和h_12就是干这个的它们用一个硬编码的32位密钥对h_20到h_35范围内的代码段进行XOR解密。I/O类IO处理用户输入和输出。h_18调用GetStdHandle和ReadConsoleA读取输入h_22调用printf输出提示。校验类Check执行核心的flag校验逻辑。h_29、h_31、h_33构成了一个三段式校验先检查输入长度是否为32再逐字节异或校验最后用memcmp比对解密后的正确flag。分类过程极其枯燥但至关重要。它让你从“看一堆跳转”升级到“看一张功能地图”。你会发现很多handler只是在做mov ecx, edx、add eax, 1这样的无意义操作它们是混淆器插入的“噪音”可以安全忽略。而真正承载业务逻辑的往往只有10个左右。3.3 第三步重构State流转图——画出迷宫的“通行规则”分类完成后我开始追踪每个handler执行后eax会被更新为什么值。例如h_18读输入执行完会mov eax, 0x1F意味着下一个执行的是h_31而h_31长度校验如果通过eax变为0x20跳向h_32失败则eax变为0x2A跳向h_42输出Wrong!。我把这些流转关系用纸笔画成一个有向图节点是handler编号边上的标签是“条件”如“长度32”和“下一个state”。这张图就是程序真正的逻辑骨架。它清晰地显示整个校验流程是一条线性路径h_18 → h_31 → h_32 → h_33 → h_22Correct!。所有其他handler要么是初始化分支要么是错误处理分支要么是纯粹的噪音。有了这张图你就可以在IDA里只关注这条主路径上的handler把精力从42个减少到5个。3.4 第四步聚焦主路径逐个击破——在“最小可行集”上攻坚现在战场被压缩到5个handler。我关闭所有无关的视图只打开h_18、h_31、h_32、h_33、h_22。h_18很简单就是标准的ReadConsoleA调用输入缓冲区地址是ebp-0x100。h_31的关键指令是cmp dword ptr [ebp-4], 32[ebp-4]存的是输入长度。h_32开始变得有趣它把输入缓冲区首地址加载到esi然后执行一个循环xor byte ptr [esi], 0x55inc esidec ecx。这就是逐字节异或解密。但解密后的数据跟什么比h_33揭晓了答案它把esi解密后的输入和edi一个地址传给memcmp。edi的值是h_5解密出来的——h_5从unk_405000读取一段加密数据用密钥0xDEADBEEF解密结果存入edi指向的内存。我让h_5运行一次在x64dbg里F8单步然后在内存窗口里dump出edi指向的32字节十六进制查看赫然就是flag{...}的ASCII码至此整个逻辑闭环输入→异或解密→与内存中解密出的flag比对。注意h_32里的异或密钥0x55并不是硬编码在指令里的。它是由h_2和h_3两个handler共同计算出来的h_2把0x1234左移8位h_3把结果与0xAA相或最终得到0x123400 | 0xAA 0x1234AA再取低8位才是0xAA。但0xAA异或两次等于原值所以h_32实际执行的是xor byte ptr [esi], 0xAA。这个细节只有在完整追踪state流转时才会暴露。它提醒我们混淆器的“随机性”往往是伪随机其种子和算法就藏在那些看似无关的初始化handler里。4. 动态解密字符串的静态定位术如何在IDA里“看见”不存在的flag青龙题最让人抓狂的是flag{这个字符串在二进制里完全不可见。但程序运行时它必然存在于内存的某个角落。问题是如何在静态分析阶段就精准定位到它被解密出来的那一刻、那个地址。这考验的不是工具技巧而是对Windows内存管理、PE文件结构和常见解密模式的直觉。4.1 破解“字符串在哪里”的三个锚点我从不盲目搜索。我会先找三个强相关的锚点它们像三角定位的基站能大幅缩小搜索范围锚点一printf/MessageBoxA的调用点。虽然调用本身被flatten了但printf的第二个参数格式化字符串地址一定是个有效的内存地址。我在x64dbg里运行程序输入任意字符串触发Wrong!在printf调用处下断点查看栈上第二个参数的值记下是0x7FF7A1234567。然后回到IDA在0x7FF7A1234567附近搜索发现它属于.data节的一个未初始化区域且该区域在PE头里被标记为READWRITE。这说明字符串是运行时写入的。锚点二VirtualAlloc/HeapAlloc的分配点。h_0调用了VirtualAlloc(NULL, 0x1000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)分配了一块可执行内存。这块内存的地址就是所有动态解密数据的“老家”。我在IDA里找到h_0看到它把分配的地址存入了全局变量g_hMem地址0x40A080。于是所有对g_hMem的写入操作都是在往这个缓冲区里填数据。锚点三解密函数的输入源。h_5的解密逻辑很典型它从unk_405000读取加密数据unk_405000在IDA的数据视图里是一片乱码但它的RVA相对虚拟地址是0x5000在PE文件里对应.rdata节的偏移。我用HxD十六进制编辑器打开原始qinglong.exe跳转到偏移0x5000果然看到一串毫无规律的字节。这串字节就是flag{...}被加密后的样子。4.2 “时间切片”法在静态视图里模拟动态过程有了三个锚点我采用“时间切片”法在IDA里模拟解密发生的时间线T0程序启动g_hMem指向的内存是空的全0。T1h_5执行h_5把unk_405000的加密数据用密钥0xDEADBEEF解密结果写入g_hMem。我在IDA里把h_5的解密算法一个简单的循环XOR手动翻译成Python用unk_405000的原始字节和密钥跑一遍得到32字节明文。用binascii.hexlify()转成十六进制字符串复制到剪贴板。T2h_32执行h_32把用户输入异或0xAA结果也写入g_hMem的另一块区域g_hMem 0x100。但这不是重点重点是T1的结果。T3h_33执行h_33调用memcmp(g_hMem, g_hMem 0x100, 0x20)。所以g_hMem里存放的就是解密出的正确flag。现在我只需要在IDA里把g_hMem的地址0x40A080设为一个char[32]类型的数组然后在h_5的写入指令mov byte ptr [g_hMem ecx], al处手动把之前算出的32字节明文以db伪指令的形式覆盖写入到g_hMem的定义处。刷新视图后g_hMem旁边就清晰地显示出了flag{...}。这个过程本质上是在静态分析环境中手工“播放”了动态解密的录像带。4.3 识别“伪随机”密钥从混淆噪音中提取确定性h_5的密钥0xDEADBEEF看起来像一个随意的十六进制数。但OLLVM混淆器有个特点它生成的“随机”常量其实来源于编译时的固定种子。我注意到在h_2里有一条指令mov eax, 0xDEAD在h_3里有mov ebx, 0xBEEF然后h_5把它们拼起来。0xDEAD和0xBEEF这两个数在编程圈里是著名的“magic number”常被用作占位符或调试标记。这说明混淆器作者很可能直接用了这些经典值而非真随机生成。因此当你在其他OLLVM混淆的样本里看到0xDEAD或0xBEEF几乎可以断定它和青龙题一样是同一个混淆配置生成的。这是一种“指纹”——通过识别这些非随机的、带有文化印记的常量你能快速判断样本家族从而复用已知的解密密钥和流程。提示不要迷信“自动解密脚本”。我见过太多人写for key in range(0, 0x100): try: decrypt(data, key) ...这样的暴力脚本结果跑了一晚上也没出结果。青龙题的密钥是32位暴力搜索空间是2^32不现实。正确的思路是先定位解密函数再分析其密钥来源。密钥几乎总是来自一个全局变量、一个硬编码立即数、或一个由几个简单运算组合而成的值。找到源头比穷举快一万倍。5. 绕过反调试的“外科手术”在不破坏程序的前提下摘除监控探针青龙题的反调试不是摆设。我第一次用x64dbg附加时程序直接退出连Input flag:都没输出。这说明反调试检查发生在极早期甚至在main函数执行前。按照常规思路我会在IsDebuggerPresent上下断点但这次IsDebuggerPresent根本没被调用。Exeinfo PE的“Anti-Debug”检测项是空的这进一步证实了它是自定义的、隐蔽的检测。5.1 定位反调试的“神经中枢”我采用“二分排除法”。首先用x64dbg以“不暂停”方式启动程序File - Open勾选Dont break on system DLLs让它自然运行到崩溃点。崩溃时堆栈显示最后执行的是ntdll.dll里的NtQueryInformationProcess。好目标锁定。接下来我在x64dbg的Symbols窗口里找到ntdll.dll右键NtQueryInformationProcess选择Set Hardware Breakpoint on Entry。再次运行程序在NtQueryInformationProcess入口处中断。此时查看堆栈上一层调用者是qinglong.exe里的sub_401150。sub_401150就是那个被flatten的handler之一。我回到IDA找到sub_401150它位于h_7。h_7的代码很短核心是push 0 push 0 push 0 push 0 push 0x00000000 ; ProcessBasicInformation push eax ; hProcess (current process) call ds:NtQueryInformationProcess test eax, eax jns short loc_401180 ; if success, jump ... loc_401180: mov eax, [esp14h] ; get ProcessBasicInformation structure test byte ptr [eax2], 2 ; check BeingDebugged flag jz short loc_4011A0 ; if 0, not debugged ...[eax2]就是BeingDebugged字段。test byte ptr [eax2], 2这行是关键——它检查的是BeingDebugged的第2位bit 1而标准的BeingDebugged是一个BYTE值为0或1。这里检查bit 1说明混淆器做了手脚它可能把BeingDebugged字段的值与其他标志位做了或运算。但不管怎样只要[eax2]的值非零程序就认为被调试。5.2 “外科手术”式绕过精准修改不伤筋骨知道了原理绕过就简单了。我需要在test byte ptr [eax2], 2这条指令执行后确保ZF零标志位为1。最粗暴的方法是nop掉这条指令但test后面紧跟着jznop掉testjz就会测试上一条指令mov eax, [esp14h]的结果这会导致逻辑错乱。更好的方法是把test指令本身改成xor byte ptr [eax2], byte ptr [eax2]即用自己异或自己结果必为0ZF1。这样jz就一定会跳转。但在x64dbg里h_7的地址是0x401150而test指令在0x401178。我右键该地址选择Edit - Assemble输入xor byte ptr [eax2], byte ptr [eax2]回车确认。然后按F9运行程序成功输出Input flag:不再崩溃。整个过程我没有patch任何API没有禁用任何调试器功能只是在程序自己的代码里做了一次微小的、语义等价的替换。这就是“外科手术”——只切除病灶不损伤健康组织。5.3 为什么不用scylla或Import REConstructor有人会问为什么不直接用scylla脱壳然后用Import REConstructor修复IAT因为青龙题根本没加传统意义上的“壳”。它没有改变PE头的ImageBase没有加密.text节没有隐藏导入表。它的“保护”完全建立在OLLVM的控制流平坦化和字符串动态解密之上。scylla的工作原理是扫描OEPOriginal Entry Point附近的跳转然后dump内存。但青龙题的OEPsub_401000本身就是被flatten的scylla会把它dump成一片无法识别的乱码。Import REConstructor则依赖于一个完整的、未被破坏的导入表。而青龙题的导入表是“懒惰绑定”的很多API如NtQueryInformationProcess是在运行时通过GetProcAddress动态获取的Import REConstructor根本找不到它们的原始地址。所以对于现代混淆传统的脱壳思维是失效的。你必须回归逆向的本质理解程序在做什么而不是纠结于它“被包装成了什么样”。注意xor byte ptr [eax2], byte ptr [eax2]这个patch只在当前版本有效。如果题目作者把BeingDebugged的偏移从2改成3或者把检查条件从test ... , 2改成cmp ... , 1这个patch就会失效。这再次印证了“通用绕过脚本”的虚幻性。真正的技能是能在5分钟内重新定位新的反调试点并设计出新的、等价的patch。这才是逆向工程师的核心竞争力。6. 从青龙题到真实世界的迁移这份笔记教会我的三件事做完青龙Re题关掉IDA和x64dbg我并没有感到“通关”的轻松反而有一种沉甸甸的踏实感。因为它不是一道孤立的CTF题而是一面棱镜折射出真实世界里二进制安全研究的日常。它用最浓缩的方式把我们在企业级软件漏洞挖掘、恶意软件分析、固件逆向中会反复遭遇的挑战打包塞给了我。回顾整个过程有三件事是我认为比“拿到flag”更重要的收获。第一件事是彻底抛弃了“静态分析万能论”。过去我总以为只要把IDA用熟把所有交叉引用都理清就能看透一切。青龙题狠狠打了我的脸。它强迫我接受一个事实静态分析的终点恰恰是动态分析的起点。没有x64dbg的单步跟踪我无法确认g_hMem的地址没有在内存里dump出解密后的数据我无法验证自己对h_5算法的理解是否正确没有在NtQueryInformationProcess处下硬件断点我永远找不到那个藏在h_7里的反调试逻辑。现在我的工作流已经固化IDA用于宏观布局和逻辑梳理x64dbg用于微观验证和动态取证。两者不是替代关系而是互补的“双目视觉”。第二件事是对“混淆器指纹”的敏感度提升了。以前看到OLLVM只知道它会flatten但不知道unk_40A000这个dispatch table的地址模式、0xDEAD/0xBEEF这类magic number的使用习惯、甚至NtQueryInformationProcess调用前必有的push 0序列都是OLLVM的“签名”。这就像老刑警看犯罪现场不看结果先看手法。现在我拿到一个新样本5分钟内就能判断它是不是OLLVM混淆如果是混淆级别是轻度只flatten还是重度加了字符串加密和反调试。这种快速分类能力能让我在有限时间内把精力精准投放到最可能出成果的地方。第三件事也是最重要的一件是学会了“在混沌中建立秩序”的思维范式。青龙题的代码是人为制造的混沌。但混沌不是无序它遵循着编译器和混淆器的内在逻辑。我的任务不是去抱怨混沌有多难而是去发现那个隐藏的、稳定的逻辑内核——state机。一旦抓住了state这个“阿基米德支点”所有的handler、所有的跳转、所有的数据流都变成了可以被理解和预测的元素。这种能力迁移到工作中就是面对一个庞大、文档缺失、多人维护的遗留系统时我能快速画出它的核心状态图而不是迷失在成千上万行代码的细节里。它是一种元能力一种在复杂性中寻找简洁性的本能。所以如果你正在为青龙题抓耳挠腮别焦虑。把它当作一次高强度的“逆向肌肉训练”。每一次在IDA里手动标注一个handler的功能每一次在x64dbg里单步跟踪一个xor指令每一次为绕过一个反调试而设计的精巧patch都在重塑你的大脑回路。当某天你面对一个从未见过的混淆器第一反应不再是“这怎么搞”而是“它的state机长什么样”你就知道这份笔记的价值已经远超一道CTF题的范畴了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →