目标文件:C:\Program Files (x86)\Thunder Network\xlacc\...\lib\accservice.exe
大小 15.4 MB,PE32 Console,i386,带外部 PDB 符号
声明:本文仅用于安全研究与学习交流,请勿用于非法用途。
在分析该加速器程序 accservice.exe 时,发现其 file 命令识别结果为 UPX compressed。正常来说 UPX 的壳直接 upx -d 一把梭即可,但实际却并非如此 —— 标准 UPX 工具直接拒绝脱壳。经过逆向发现,这是一个魔改过的 UPX 变种:解压 stub 是标准 NRV2B,但头部字段被篡改、过滤器与重定位逻辑都被改动。
本文记录完整的逆向分析与手工脱壳过程,并附上可复用的脱壳脚本。
先用 file 和 objdump 看一下基本面貌:
典型的 UPX 特征:节区名 UPX0/UPX1/UPX2,UPX0 文件偏移处 raw size = 0(全部运行时解压),整体熵约 7.93 bits/byte,接近随机。
upx -d 通过查找 UPX! magic 来校验头部,报错说明 magic 被移除或加密了。把 UPX! 手工补到 UPX1 起始处(文件偏移 0x400)再试:
错误从 NotPackedException 变成 header corrupted 4,说明 magic 校验已通过,但头部其余字段也被改过。对照 UPX 源码(src/packhead.cpp)的 decodePackHeaderFromBuf,header corrupted 4 对应 c_len < 2 || u_len < 2 等字段校验失败。标准工具这条路走不通,只能手工脱。
入口点 EP = 0x03727490(VA),位于 UPX1 段尾部。dump 出来反汇编:
一眼可辨:这是 UPX 的 NRV2B 解压器,位读取方式是经典的 add ebx,ebx / mov (%esi),%ebx / sub esi,-4 / adc ebx,ebx 结构。但继续往下读时发现两处与官方 stub 不同:
即偏移阈值 -0xd00 -> -0x500、附加常数 1 -> 2 都被改了。这说明压缩流是用同参数魔改的压缩器生成的,标准 UCL/NRV2B 解码器无法直接解。必须照着这个 stub 逐指令实现。
stub 的 getbit 语义(理解这点是解码成败的关键):
也就是说每次装载 4 字节后,最低位补了个"哨兵 1",下一次取出的位是新 dword 的 bit31。翻译成 Python:
对照反汇编把匹配长度/偏移的解码逻辑逐指令转写(把 CF/ZF 的传播显式写出来):
这里有一个重要的魔改点:官方 stub 在路径 A 计算完偏移后是 getbit 1; adc ecx,ecx 再继续,而这个 stub 直接 jmp 0x752e,用 sar 移出的最低位当作控制位(CF)。因此解码时必须严格复刻这一行为,否则位流对不上。
解压起点:stub 中 mov esi, 0x02c7b015 指向 UPX1+0x15,换算成文件偏移就是 0x400 + 0x15 = 0x415。
解压后拿到的镜像代码里,E8/E9 调用指令的目标被以绝对地址大端存储了(官方 cto 过滤器还会加 addvalue 和 cto8 标记字节,这里都去掉了)。stub 里的反变换循环:
翻译成 Python(遍历 filter_length = 0x53bfc 个调用点):
处理了 343,036 个调用后,入口点代码从 e9 00 00 01 5c(目标被替换到"空旷地址空间")还原成正常的 jmp 0x401160。
stub 在反过滤之后执行导入处理:
即:记录区在 dest+0x36be000,DLL 名字基址在 dest+0x3727000 = 0x3b28000(正好是 UPX2 节 VA,名字字符串存在里面)。记录格式为:
解析脚本(节选):
恢复出完整导入表:
其中 nfapi.dll 正是程序目录中的网络过滤库,印证了该服务的作用。
按标准 PE 布局重建 IMAGE_IMPORT_DESCRIPTOR + IAT + Hint/Name 表,写入 .idata 节:
小坑:Python 的 bytearray 切片返回的是 bytearray 而非 bytes,isinstance(f, bytes) 为 False,会导致全部被误判成按序号导入(0x800000xx)。需要显式 bytes(...) 转换。
重定位数据紧随导入记录之后(找 null 记录 dllname_off==0,再 +4)。解压算法与官方 UPX 相同,用 (byte & 0x0f)<<16 | le16 做扩展偏移:
每个重定位点的回写是 bswap(value) + esi,即指针以大端存储、减去基址,运行时加回基址还原成绝对地址。
最初我按官方 stub 语义 lea -4(%esi),%ebx 处理,结果入口点代码被"修坏"了:
而把基准改为 esi-3 后,入口点还原成完全合理的代码:
且防篡改代码中的 MZ 校验也还原成功:
cmp [0xf0ffff] 被修复成 cmp [0x400000](检查镜像基址处 MZ 签名),语义完全说得通。因此该魔改版的 delta 编码基准是 esi-3。自动化脱壳时,可以对候选基准逐一尝试,选取"修复后指针落在镜像内"比例最高的一组:
最终 395,510 个重定位点,基准 -3 时 395,440 个修复值落在镜像内(命中率 99.98%),可靠收敛。
解压镜像末尾是 extra_info(skip = le32(img[-4:])),内含完整原始 PE 头(PE\0\0 + COFF + 可选头 + 节表)。按以下步骤写出文件:
最终验证:
导入表、入口点代码、重定位块全部验证通过,与手工推演结果字节级一致(52 MB)。
本次逆向对象是一种 UPX 变种,魔改点共四处:
关键经验:
完整脱壳脚本已整理为 unpack_upx.py(纯 Python 标准库),自动提取 stub 常量、解压、反过滤、恢复导入/重定位并重建 PE,已在本样本上验证通过。
| DLL |
函数数 |
代表函数 |
| KERNEL32.DLL |
59 |
AddVectoredExceptionHandler, CreateThread... |
| fwpuclnt.dll |
7 |
FwpmEngineOpen0, FwpmFilterEnum0... |
| msvcrt.dll |
41 |
__getmainargs, _beginthread... |
| nfapi.dll |
15 |
nf_getPoolStatus, nf_setIPEventHandler... |
| ole32.dll |
3 |
CoCreateInstance, CoInitialize... |
| WS2_32.dll |
2 |
inet_ntop, inet_pton |
| WSOCK32.DLL |
3 |
htons, inet_addr, ntohs |
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 2026-8-15 20:16
被cendart编辑
,原因: 去掉目标程序商业名称