【系列:CCG Crypto CrackMe 逆向全解析 · 第 8 篇】
导读前七篇我们通过侦察、脱壳、算法指纹识别和反汇编重构出了 Verify() 的完整校验逻辑Base64 解码 → MD5 → RC4 比较 → RSA 模幂比较。但这一切都是静态分析的结论——字节序对吗参数约定猜对了吗自定义的大数库和标准实现一致吗静态分析无法回答这些问题。本篇开始搭建预言机直接调用目标进程内部的 Verify()把我们的猜测喂给它读它的真实返回值。这条路先踩了CreateRemoteThread的坑——进程直接崩溃改进为线程劫持后又撞上了 32/64 位的WOW64兼容问题。这两个坑和它们的解法就是本文的主角。一、为什么要预言机静态分析的可信度边界反汇编告诉我们的只是CPU 会执行什么指令。但有几个问题它回答不了疑问静态分析的局限LIP 大数库是大端还是小端需要实际读内存才能确认zexpmod的参数入栈顺序猜错了整个结果都会错自定义 RC4 和标准实现一致吗需要逐字节比对Verify() 的行为真的只由参数决定吗第 7 篇已证明Name 部分根本不读参数预言机的思路很简单把 Verify() 当成黑盒直接在真实运行的进程里调用它传入我们的输入读它真实的返回值。这样我们拿到的永远是标准答案不存在我以为它是这样算的问题。第 7 篇已经预告了前提Verify() 的 Name 部分走硬编码全局地址0x417180。所以预言机调用前必须先把测试 Name 写进这个全局地址——这正是后面所有调试的前提。二、第一次尝试CreateRemoteThread进程直接崩了2.1 思路CreateRemoteThread可以在目标进程里创建一个全新线程从指定地址开始执行。计划分三步用VirtualAllocEx在目标进程申请一块内存用WriteProcessMemory把 shellcode 写进去——这段代码负责压栈参数、调用 Verify()、保存返回值用CreateRemoteThread让目标进程从 shellcode 开始执行# shellcode调用 Verify() 并保存返回值shellcodebshellcodeb\x68struct.pack(I,serial_buf)# push serial_bufshellcodeb\x68struct.pack(I,name_buf)# push name_bufshellcodeb\xB8struct.pack(I,0x401610)# mov eax, 0x401610shellcodeb\xFF\xD0# call eaxshellcodeb\xB9struct.pack(I,result_buf)# mov ecx, result_bufshellcodeb\x89\x01# mov [ecx], eaxshellcodeb\xEB\xFE# jmp $ (自旋等待)逐字节拆解这段 shellcode机器码汇编作用68 xx xx xx xxpush imm32压栈一个参数B8 xx xx xx xxmov eax, imm32把 Verify() 地址装入 eaxFF D0call eax间接调用 Verify()B9 xx xx xx xxmov ecx, imm32结果缓冲区地址装入 ecx89 01mov [ecx], eax把返回值存到结果缓冲区EB FEjmp $原地自旋等调试脚本读取2.2 结果进程崩溃Exit code: 0xC0000005 ← ACCESS_VIOLATION窗口直接消失。2.3 根因CRT/TLS 未初始化CreateRemoteThread创建的是一个全新的、从未被 C 运行时初始化过的线程。而 Verify() 内部调用的大数运算库lip.c其源码里嵌着这样一个字符串unexpected multithread lock error这说明 LIP 库内部使用了线程同步机制依赖线程本地存储TLS。在一个未初始化 TLS 的新线程里调用它访问到空指针 → ACCESS_VIOLATION → 进程崩溃。教训往一个从没设计给你随便调用的进程里插入执行环境时新建线程的干扰面太大——尤其当目标函数依赖 CRT/TLS 初始化时。三、改进方案线程劫持Thread Hijacking与其新建一个身份不明的线程不如借用程序本来就有的、已经正常初始化好的主线程——让它临时执行我们的代码用完之后恢复原状。就像借同事的电脑点一下鼠标点完把界面切回去他毫无察觉。3.1 完整流程① SuspendThread(hThread) # 暂停目标线程 ② GetThreadContext(hThread, ctx) # 备份所有寄存器重点 EIP、ESP ③ 修改 CONTEXT: EIP → shellcode 地址 ESP → 我们准备的临时栈 ④ SetThreadContext(hThread, ctx) # 写回修改后的寄存器 ⑤ ResumeThread(hThread) # 线程从新 EIP 开始执行我们的代码 ⑥ 轮询 result_buf直到值不再是哨兵值 ⑦ SuspendThread(hThread) # 再次暂停 ⑧ SetThreadContext(原始 ctx) # 恢复原始寄存器状态 ⑨ ResumeThread(hThread) # 线程从原暂停点继续像什么都没发生 3.2 核心代码# 1. 暂停线程kernel32.SuspendThread(hThread)# 2. 获取并备份上下文ctxCONTEXT()ctx.ContextFlags0x10007# CONTEXT_FULLkernel32.GetThreadContext(hThread,ctypes.byref(ctx))originalCONTEXT()ctypes.memmove(ctypes.byref(original),ctypes.byref(ctx),ctypes.sizeof(CONTEXT))# 3. 劫持EIP 指向 shellcodeESP 指向临时栈ctx.Eipcode_addr ctx.Espscratch_stack0x1000kernel32.SetThreadContext(hThread,ctypes.byref(ctx))# 4. 恢复执行线程开始跑我们的代码kernel32.ResumeThread(hThread)# 5. 轮询结果whileread_int(result_buf)0xDEADBEEF:# 哨兵值time.sleep(0.01)# 6. 暂停并恢复原始上下文线程失忆kernel32.SuspendThread(hThread)kernel32.SetThreadContext(hThread,ctypes.byref(original))kernel32.ResumeThread(hThread)这次成功了——进程没有崩溃。四、大坑WOW6432 位程序跑在 64 位系统上4.1 现象劫持代码看起来运行正常但结果始终不对。排查发现连设置寄存器这一步似乎都没真正生效。4.2 根因CONTEXT 结构错位这个 CrackMe 是 2001 年编译的32 位程序。在 64 位 Windows 上它靠WOW64Windows 32-bit on Windows 64-bit兼容层运行。而我们的调试脚本Python本身是64 位进程。当一个 64 位进程去操作一个 32 位WOW64目标进程的线程上下文时API返回的 CONTEXT寄存器字段问题GetThreadContext64 位结构Rip/Rsp64位没有Eip/EspWow64GetThreadContext32 位结构Eip/Esp32位✅ 正确也就是说我们ctx.Eip code_addr这行代码在 64 位 CONTEXT 里修改的其实是一个不存在的字段或错位的内存——操作看起来成功实际毫无作用。4.3 验证与修复先确认猜测方向is_wow64ctypes.c_int(0)kernel32.IsWow64Process(hProcess,ctypes.byref(is_wow64))print(fTarget is WOW64:{bool(is_wow64.value)})# 输出: Target is WOW64: True ✅ 猜测正确然后动态选择正确的 APIifis_wow64.value:GetCtxkernel32.Wow64GetThreadContext SetCtxkernel32.Wow64SetThreadContextelse:GetCtxkernel32.GetThreadContext SetCtxkernel32.SetThreadContext替换后一切正常。心法32/64 位不匹配是操作目标进程内存和寄存器时最容易被忽略的陷阱。当代码逻辑看起来完全正确但操作不生效时永远先检查我操作的目标和我的位数是否一致五、预言机架构总览把前面所有环节拼起来完整的预言机调用链路┌─────────────────────────────────────────────────────┐ │ 我们的 Python 调试进程64位 │ │ │ │ ① WriteProcessMemory(GLOBAL_NAME_BUF, KCTF) │ │ ② WriteProcessMemory(GLOBAL_SERIAL_BUF, serial) │ │ ③ SuspendThread(UI线程) │ │ ④ Wow64GetThreadContext → 备份 │ │ ⑤ EIPshellcode, ESP临时栈 → Wow64SetThreadContext│ │ ⑥ ResumeThread → 轮询结果 │ │ ⑦ SuspendThread → 恢复上下文 → ResumeThread │ │ │ │ ┌───────────────────────────────────────────────┐ │ │ │ crackme_crypto.exe32位 WOW64 进程 │ │ │ │ │ │ │ │ 全局数据段: │ │ │ │ 0x417180: Name 缓冲区 (Verify 硬编码读取) │ │ │ │ 0x417100: Serial 缓冲区 │ │ │ │ │ │ │ │ shellcode在 UI 线程里执行: │ │ │ │ push serial_buf; push name_buf │ │ │ │ call 0x401610 (Verify) │ │ │ │ mov [result], eax; jmp $ │ │ │ └───────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ 注意第 ① 步——往全局地址0x417180写 Name。这是第 7 篇参数陷阱的直接后果Verify() 的 Name 部分硬编码读这个地址不写它函数就会去哈希空字符串。小结CreateRemoteThread 失败根因新线程无 CRT/TLS 初始化lip.c 大数库访问 TLS 空指针崩溃线程劫持五步Suspend → 备份上下文 → 改 EIP/ESP → Resume → 恢复上下文WOW64 大坑64 位调试器操作 32 位目标时GetThreadContext返回 64 位 CONTEXT无 Eip 字段必须用Wow64GetThreadContext/Wow64SetThreadContext参数陷阱联动调用 Verify() 前必须先写全局地址0x417180Name和0x417100Serial下一篇预告《预言机调试下栈帧窥视与全局变量陷阱》——有了能调用任意函数的能力后先逐个验证 MD5、Base64、RC4 子组件全部与标准实现逐字节一致再拼装完整链路——却失败了。排查过程用上了时序侧信道踩了冷启动的坑和栈帧窥视函数返回后栈上残留中间结果最终用偷看 MD5 摘要抓到了真凶程序根本没对我们传入的 Name 做哈希。参考文献与引用Microsoft Docs - CreateRemoteThreadlearn.microsoft.com/windows/win32/api/processthreadsapiMicrosoft Docs - WOW64learn.microsoft.com/windows/win32/winprog64/running-32-bit-applications——WOW64 兼容层与Wow64GetThreadContext说明Microsoft Docs - GetThreadContext / SetThreadContext32/64 位 CONTEXT 结构差异的权威定义Matt Pietrek, “Under The Hood” (MSJ)进程内线程与 TLS 初始化的经典文章觉得有用点个关注持续获取优质内容。