-
-
[原创] 某网游加速器程序UPX 脱壳后续,让它跑起来!
-
发表于: 2026-8-15 20:01 284
-
接上篇《某网游加速器程序 UPX 加壳分析与手工脱壳》。
上篇按 stub 逐指令复刻,把 52MB 的 Go 镜像解压、反过滤、恢复导入/重定位、重建 PE,静态验证全过。
结果一运行就 0xc0000005。这篇记录从"静态对、运行崩"到定位到解码器一个字节的初始化错误的全过程。
声明:本文仅用于安全研究与学习交流,请勿用于非法用途。
0x0 静态全对,一跑就崩
上篇做出来的镜像(ImageBase 0x400000,SizeOfImage 0x36bf000),file 和 objdump 都正常:
$ file unpacked.exe
PE32 executable (console) Intel 80386 (stripped to external PDB), for MS Windows
$ objdump -h unpacked.exe # 10 个节、导入表、.reloc 全部合法
pefile 校验 PE 头、节表、数据目录全部通过,节无重叠。但双击直接崩溃:
0xc0000005 (Access Violation),发生在加载器初始化阶段
注意,崩的是"加载器初始化",不是程序自己 —— 说明问题出在镜像里加载器要读的那几块元数据(TLS、.CRT、重定位结果),而不是 Go 运行时本身的逻辑。这个判断基本决定了后面的排查方向。
0x1 四层崩溃,一层一层剥
每修一层,崩溃就往前推一层,一路剥了四层:
- 启动即 AV:ntdll
LdrpInitializeProcess读 TLS 目录时 READ fault @0x81fc5c03 - 报缺
nfapi.dll(目录中的一个 netfilter 库),拷过来就好 —— 这个只是环境问题 - 又 AV:msvcrt
initterm遍历 .CRT 表,表内指针被重定位成了 2.9GB 的垃圾地址 - 又 AV,EIP 停在 RVA 0x11d9:RVA 0x13bf 处一条
jmp的目标差 1 字节,落进了指令中间
到第四层就明白了:这是系统性 bug,点修一处下一处必崩。
TLS 目录是垃圾
TLS 目录 DD[9] 指向 VA 0x310b9a4 / Size 0x18,结构体内容:
StartAddressOfRawData = 0x9f300001
EndAddressOfRawData = 0x9f300403
AddressOfIndex = 0x9efc5c03 <- 注意高字节 0x9
AddressOfCallBacks = 0x9f201c03
SizeOfZeroFill = 3
全是垃圾。ntdll 初始化线程局部存储时按 AddressOfIndex 去读,高字节 0x9 直接指向未映射地址,READ fault。
先清零 DD[9] 顶着(Go 运行时用的是 Win32 TlsAlloc,不依赖 PE TLS 目录,安全)。
.CRT 表被错位重定位
过了 TLS 又崩在 initterm。.CRT 节(VA 0x35f2000 / VSize 0x30)本应是 __xi_a/__xi_z、__xc_a/__xc_z 的函数指针表,文件里却是:
+0x04 = 0x40112000 +0x10 = 0x40101000
+0x1c = 0x27976000 +0x20 = 0x27971001
运行时被重定位成 0xAD112000 / 0xAD101000 / 0x94976000 / 0x94971001。delta=0x6D000000,是镜像 delta 0x6D0000 的 256 倍,明显不对。
查 .reloc 表发现这 4 条 HIGHLOW 条目全部错位 +1 字节:条目落在 0x35f2005 / 0x35f2011 / 0x35f201d / 0x35f2021,而指针在 -1 处。加完 delta,本该是 0x401120 的指针变成了 2.9GB,initterm 顺着它 call 过去就炸了。先清零 4 个指针 + 4 条错位重定位改 ABSOLUTE。
jmp 位移差 1,警报拉响
又崩,EIP=RVA 0x11d9。反汇编看现场:
4011d0: c7 05 08 00 51 03 01 00 00 00 mov dword ptr [0x03510008], 1 ; 10 字节, 0x11d0-0x11d9
4013bf: e9 15 fe ff ff jmp -0x1eb ; -> 0x4011D9 !!
jmp 目标 0x4011D9 正好落在上面那条 mov 的最后一个字节 00 上,从那儿执行到 add [ecx+...], ah 写非法地址 → AV。
差 1 字节。而且同一条代码路径里 call 全对,就这一条 jmp 错 1 —— 这已经不是某一处坏了,是整体有问题。点修到此为止。
0x2 转机:拿 running 的 packed 进程当 ground truth
原版 packed exe 是能跑的,产品本身没坏。stub 运行时把 Go 镜像解压进内存,这份内存就是权威解压结果。
用调试器把 packed exe 跑起来,6 秒后 dump 完整镜像(54MB),跟脱壳文件逐字节对:
| 位置 | 内存(权威) | 脱壳文件 |
|---|---|---|
| RVA 0x13bf | e9 16 fe ff ff → 0x4011DA |
e9 15 fe ff ff → 0x4011D9 |
| .text 起始 | c3 8d b4 26 00 00 00 00 8d b4 26 00 00 00 00 90 |
c3 8d b4 26 00 00 00 00 00 b4 26 00 00 00 00 00 |
看到 .text 起始那两行,问题基本就锁定了。
0x3 首分歧:不是 E8/E9,是第一个 match 多吐了一个字节
逐字节对齐后,第一个分歧根本不在任何 call/jmp 上,而在 .text 最开头:
RVA 0x1000..0x1007 rec == gt (c3 8d b4 26 00 00 00 00) 前 8 字节完全一致
RVA 0x1008..0x100f rec = 00 b4 26 00 00 00 00 00 (8 字节!)
gt = 8d b4 26 00 00 00 00 (7 字节)
RVA 0x1010 起 rec == gt (90 83 ec 1c ...) 从此全部错位 1 字节
解码出来是这样的:
- 正确:
c3(ret) + 两个 7 字节 lea-padding8d b4 26 00 00 00 00(第二个是回引第一个的 match) - 错误:
c3+8d b4 26 00 00 00 00+00 b4 26 00 00 00 00+ 多余一个00
也就是解压器在第一个 match 处多插了 1 个字节,且首字节 00 应为 8d。这一个字节让之后所有字节位置偏移 +1 —— E8/E9 位移、TLS/.CRT 指针"普遍差 1"全是它的连锁反应,0x0/0x1 里看到的三个症状同源。
复盘一下,之前全量 diff 得出"9.2M 个 4 字节不匹配"、"shift=-1 最优对齐",都是被这个单字节插入骗了。那不是恒定偏移,而是从插入点之后整体漂移 1 字节。一开始就该先找首分歧,而不是看统计数字。
0x4 根因:初始 ebp 少了 or ebp, -1
对照 stub 反汇编把解码器的每个分支(m_off / m_len / getbit / cmp ebp,-0x500; adc ecx,2)逐条核了一遍,全都对得上,最后盯住了寄存器初值:
037274ac: 83 cd ff or ebp, -1 ; <- stub 入口把 ebp 置成 -1
而我解码器里的初始化是:
ecx = eax = ebp = edi = edx = 0 # ebp = 0,错!
ebp 是"上一次匹配偏移"。当某个 match 走 reuse-offset 路径(m_off 解出 eax < 3,即小偏移复用上一次值)时,用的就是初始 ebp。第一个 match 恰好是小偏移:
| 初始 ebp | 行为 | 输出 | |
|---|---|---|---|
| 我 | 0 | 源偏移 0 → 非法、走 copy4、len=4 | 00 00 00 00(多 1 字节) |
| stub | -1 (0xFFFFFFFF) | 源偏移 1 → 指回上一字节 00、走 copy1、len=3 | 00 00 00(正确) |
差这 1 字节的原因:ebp=-1 时源指针 edx = edi + ebp = 输出位置 - 1,正好回指到刚写入的最后一个字节(00),len=3 恰好补出 00 00 00;ebp=0 时 edx = 输出位置(还没写数据),copy4 读出一串 0 还多写 1 字节。另外 cmp ebp,-0x500 对 -1 和 0 的 CF 也不同(-1 的 0xFFFFFFFF 不比 0xFFFFFB00 小 → ecx+=2;0 则 ecx+=3),长度也跟着差 1。两个症状同一个错误初值产生,全对上了。
0x5 修复与验证
改一行:
ecx = eax = edi = edx = 0
ebp = 0xFFFFFFFF # stub: or ebp, -1
重跑整条流水线,后面全部变正常:
| 项 | 修复前 | 修复后 |
|---|---|---|
| 解压输出 | 57,824,366 B | 57,824,365 B(少 1 字节 = 插入的那字节) |
| extra_info PE 签名 | 0x37251d5(错位) |
0x37251d4(正确) |
| .text 起始 16 B | c3 8d b4 26 00 00 00 00 00 b4 26 00 00 00 00 00 |
与内存一致 |
| E8/E9 位移 | 大量差 +1 | 反汇编 325,995 条真实 call/jmp,0 条不一致 |
| TLS 目录 | 垃圾(AddressOfIndex=0x9efc5c03) | 正常(0x039efc5c,.reloc 4 字段全有 HIGHLOW 覆盖) |
| .CRT 表 | 4 指针 2.9GB 垃圾 | 正常(+04=0x401120 等合法函数指针) |
验证时有个比 diff 更干净的判据:E8/E9 的相对位移不随加载基址变化,逐条比对是纯代码正确性的最强信号。
跑起来:
$ ./unpacked_fixed.exe & # 52 MB
$ ps # 进程存活, 多线程(Go runtime 特征), 无 0xc0000005
再附加调试器确认了一下:主线程 RIP 停在 WOW64 的 syscall 跳板(正常的等待点),不是异常现场。
0x6 总结
| 组件 | 上篇结论 | 本篇补充 |
|---|---|---|
| NRV2B 常量 | cmp -0x500; adc 2 |
初始 ebp = -1(or ebp,-1),转写时漏设 |
| 运行 | 能否运行存疑 | 修复后可直接运行 |
几点教训:
- "静态验证全过"不等于"能运行"。导入表、重定位、PE 头全对,只要加载器要读的元数据(TLS/.CRT)差一字节就崩;
- 逐指令复刻解码器时,寄存器初值也是指令。
or ebp,-1这种初始化最容易漏,而且只在首条 reuse-offset 匹配时才暴露; - 拿运行中的 packed 进程当 ground truth 是验证后面是不是全修好的捷径,相对位移(E8/E9)是不受基址影响的纯代码正确性判据;
- 单字节插入会伪装成"全局 shift=-1"。先找首分歧再下结论,别被全量 diff 的统计数字带偏。
修复只动了解码器一处初始化(ebp = 0xFFFFFFFF,对应 stub 的 or ebp,-1),重跑一遍后镜像、导入表、重定位、TLS/.CRT 就都正常了。