【系列:CCG Crypto CrackMe 逆向全解析 · 第 5 篇】

发布时间:2026/8/3 14:18:05

【系列:CCG Crypto CrackMe 逆向全解析 · 第 5 篇】 导读上一篇里BF2000 的解压缩器把花指令、SEH 劫持和 ICEBP 叠在一起继续在 Unicorn 里补异常机制已经不是最划算的路。这一篇换个角度既然样本能在真实 Windows 中自己完成解密和解压就让它自己跑完再用ReadProcessMemory把内存里已经还原的代码读出来。重点不是“读到一段字节”而是用代码形态、算法指纹和数据资源三层证据确认这份 dump 值得继续分析。这篇文章讲的是一次逆向路线的转向不再和壳的执行细节死磕而是把注意力放到它最终留下的内存结果上。文中的地址、命中次数和字符串位置都来自本地独立验证脚本仅用于隔离、授权样本的只读观察。逆向时最容易陷入的一种执念是觉得“没把壳的每条指令走完就不算脱壳”。但上一章分析 BF2000 时问题已经很清楚花指令让静态反汇编错位SEH 劫持把关键控制流塞进异常链ICEBP 又会让调试环境与正常环境出现分叉。Windows 的 SEH 本身就处理硬件与软件异常并与调试器协作在模拟器里补这部分行为成本很快会失控。Microsoft LearnStructured Exception Handling当脚本一次次卡在同一段异常路径里值得换个问题我们真正需要的是复现壳的过程还是拿到壳完成后的结果如果样本能在隔离环境中正常运行它终究要把后续代码放入自己的虚拟地址空间。我们不必替它完成解密、解压和异常分发让真实系统处理这些事情分析脚本只负责观察结果。这就是本文说的“不脱壳的脱壳法”。把分析目标从“执行过程”改成“可观察结果”传统路线没有错。对普通壳而言跟踪入口、识别 OEP、转储内存是一条成熟而直接的工作流。问题在于BF2000 的设计目标之一就是放大这种跟踪的成本。它把正常执行依赖的异常处理、调试状态和栈布局混在一起。模拟器即使能执行大部分 x86 指令也不会天然拥有完整的 Windows 线程环境、SEH 分发规则和调试异常处理顺序。因此本文重新划分责任真实 Windows负责运行样本自身的解密、解压与异常处理逻辑 分析脚本负责只读获取内存并验证读取结果是否可信壳最终必须产出能继续执行的代码。那份代码一旦在内存中稳定出现就比混淆的解压流程更接近我们真正要分析的对象。当然这个方法只适用于自己拥有、明确获授权分析或隔离研究环境中的样本。本文只讨论读取不向目标进程写入内容、不注入代码也不修改它的控制流。第一步先定位目标进程不要手填 PIDReadProcessMemory的前提是一个可读取的进程句柄。这个样本启动后会创建标题为CCG Crypto Crackme v1.0 --- by blowfish的窗口因此可以从窗口句柄取得 PID再申请读取与查询权限。importctypesfromctypesimportwintypes kernel32ctypes.WinDLL(kernel32,use_last_errorTrue)user32ctypes.WinDLL(user32,use_last_errorTrue)PROCESS_VM_READ0x0010PROCESS_QUERY_INFORMATION0x0400hwnduser32.FindWindowW(None,CCG Crypto Crackme v1.0 --- by blowfish)ifnothwnd:raiseRuntimeError(样本尚未启动或窗口标题不匹配)pidwintypes.DWORD()user32.GetWindowThreadProcessId(hwnd,ctypes.byref(pid))processkernel32.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION,False,pid.value,)ifnotprocess:raisectypes.WinError(ctypes.get_last_error())这里要守住权限边界PROCESS_VM_READ用于读取目标虚拟内存PROCESS_QUERY_INFORMATION用于查询进程相关信息。这里没有WriteProcessMemory没有远程线程也没有改变目标状态的步骤。但句柄打开成功并不等于已经到了正确的读取时机。窗口刚出现时壳可能还在初始化过晚读取时目标也可能已经释放或覆盖某些页面。本例能够读取固定范围是因为此前已经完成 PE 布局分析并在样本稳定启动后取得快照。面对陌生样本必须重新确认模块基址、内存页状态或执行时机不能照抄地址。第二步读取内存时检查返回长度不少脚本只判断ReadProcessMemory是否返回成功。做演示或许可行接下来要反汇编时就不够严谨了。官方文档说明调用方需要带PROCESS_VM_READ权限的句柄读取区间不可访问时调用会失败lpNumberOfBytesRead则用于接收实际传输的字节数。Microsoft LearnReadProcessMemory如果请求跨越不可访问页面或者内存保护发生变化读取不完整会让后续字节整体错位。少几个字节反汇编器就可能从错误位置开始解码地址、基本块和函数边界都会被带偏。defread_exact(process,address,size):bufferctypes.create_string_buffer(size)bytes_readctypes.c_size_t()okkernel32.ReadProcessMemory(process,ctypes.c_void_p(address),buffer,size,ctypes.byref(bytes_read),)ifnotok:raisectypes.WinError(ctypes.get_last_error())ifbytes_read.value!size:raiseRuntimeError(f读取不完整请求{size:#x}实际{bytes_read.value:#x})returnbuffer.raw本样本是 32 位 PEImageBase 为0x400000。用于后续静态分析的代码快照从0x401000开始读取0x12000字节字符串数据快照则从0x41E000读取。text_coderead_exact(process,0x401000,0x12000)data_stringsread_exact(process,0x41E000,0x2000)这里故意把结果分为text_code.bin和data_strings.bin。这不只是便于管理文件更是在提醒自己代码、立即数、字符串和查找表并不遵循同一种分布规律。把它们混在一片数据里搜索很容易得出“没找到就是不存在”的错误结论。第一层验证这片内存是否呈现正常代码形态读出一段字节后不能因为反汇编器能把它显示成指令就立刻认定“这是真代码”。随机字节同样能被解释成合法指令。我们需要先找结构性信号。在 32 位 x86 程序中常见的函数序言是push ebp ; 55 mov ebp, esp ; 8B EC扫描这段模式可以快速判断内存区域是否具有正常编译代码的特征prologueb\x55\x8B\xECcountsum(text_code[i:ilen(prologue)]prologueforiinrange(len(text_code)-len(prologue)1))print(count)# 96这次快照中55 8B EC命中96处。这个数字绝不能写成“样本有 96 个函数”优化、内联和不同的栈帧建立方式都会影响计数。它真正说明的是这片区域中存在大量符合普通函数布局的结构信号和未解密的高熵数据、或只承担引导职责的壳代码相比呈现出明显不同的形态。第一层验收通过后我们才有理由继续寻找算法实现。第二层验证MD5 常数要按指令编码来找MD5 初始化会用到四个典型的 32 位常数0x67452301 0xEFCDAB89 0x98BADCFE 0x10325476很多人在这里会犯一个很自然的错误把四个数按小端序拼成连续 16 字节然后做一次find()。在这个样本里这种方法会失败。原因是常数不是作为连续数据表出现的而是分别嵌入四条mov dword ptr [mem], imm32指令。每个立即数之间隔着指令自身的编码字节。正确方式是把四个 32 位值逐个按小端序搜索importstruct ivs[0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476]forvalueinivs:offsettext_code.find(struct.pack(I,value))print(hex(0x401000offset))四个命中地址依次为0x40831E 0x408325 0x40832C 0x408333相邻地址间隔正好是7 字节符合“3 字节指令字段 4 字节立即数”的布局。再看周边反汇编就能把它们放回MD5_Init的初始化写入逻辑。这里真正值得记住的不是四个 MD5 IV 本身而是先分清它们是“数据表里的值”还是“指令立即数里的值”。存储形式不同搜索方法就不同。第三层验证字符串和字母表要去数据区找算法指纹存在于代码区不代表所有证据都在那里。Base64 字母表、成功提示、失败提示和 RSA 参数字符串通常属于数据资源应当在数据区验证。alphabetbABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/print(text_code.find(alphabet))# -1print(data_strings.find(alphabet))# 0结果很明确Base64 字母表在data_strings.bin的偏移0x0也就是 VA0x41E000它在text_code.bin中是NOT FOUND。同一数据区还命中了Great、Successfully registered!、65537与B80A90BF53C6C979。这些文本既与程序的 UI 行为相符也与后续 RSA 分析所需的参数相符。这一层验收纠正的不是一个小细节而是一种分析惯性不要因为自己正在研究“代码”就只在代码区找一切。数据在哪里取决于它在程序里扮演什么角色而不是取决于我们当前正在分析哪一部分。三层证据同时成立dump 才值得交给后续分析把三项结果放在一起证据链才完整验证层本次观察排除的误判代码形态55 8B EC命中 96 处把壳残留或随机字节误判为原始代码算法指纹四个 MD5 IV 按 7 字节间隔嵌入指令流用连续 16 字节搜索失败后误判“没有 MD5”数据资源Base64 表与关键字符串位于0x41E000数据区在错误段搜索后得出“目标不存在”任何一项单独命中都不足以证明“这就是完整原始程序”。但三项分别从代码结构、指令级算法特征与数据资源分布出发又指向同一结论我们获得的是已经可供静态分析的代码和数据快照。这也解释了为什么后续章节可以建立在text_code.bin与data_strings.bin上而不必反复启动样本、更不必再次穿过 BF2000 的反调试层。方法有边界别把快照当万能答案ReadProcessMemory 解决的是“观察已经运行的样本”不是自动完成所有脱壳工作。首先时机依然需要证据。太早读取可能拿到尚未还原的页面太晚读取目标可能已二次加密、释放或覆盖内容。其次地址不能照抄。本文的0x401000和0x41E000来自已解析、已验证的映像布局。遇到 ASLR、手工映射或多阶段加载时必须重新定位模块基址和内存页。最后内存快照能帮助静态分析不等于已经得到可直接运行的重建 PE。导入表、重定位、节权限和运行时修补仍然属于另一组问题。小结面对反调试壳完整模拟并不总是最优解。只要目标是获得可分析的后续代码让样本在真实环境中自行完成初始化再以只读方式观察内存往往更直接、更可验证。如果下一次又被壳的异常路径拖住可以先停一下读到内存不等于拿到可信代码常数也不一定连续躺在字节流里。把验证做在前面后面的反汇编会少走很多弯路。下一篇我们就从这两份可信快照出发沿着 MD5、RC4 和 Base64 的痕迹建立算法地图。你在逆向时遇到过“继续模拟不如直接观察”的转折点吗参考文献与引用Microsoft Learn. ReadProcessMemory function (memoryapi.h)函数参数、PROCESS_VM_READ权限与读取失败条件。Microsoft Learn. Structured Exception HandlingWindows 对硬件与软件异常的结构化处理机制。Intel. Intel® 64 and IA-32 Architectures Software Developer Manualsx86 指令编码、异常与调试相关的体系结构参考。觉得有用点个关注持续获取优质内容。

相关新闻