其他资源
点击放大

免责声明 本文仅为逆向工程与 PE 文件格式的技术交流,用于学习壳与自解压加载器的实现原理。 不提供任何成品、补丁、注册机或软件本体,不提供下载链接,不鼓励任何商业用途。 文中涉及的作者信息(QQ、官网、群号)已全部打码,软件著作权归原作者所有。 如果你喜欢这个软件,请支持正版。如有侵权,请联系删帖。
事情的开头特别朴素。
朋友丢给我一个文件夹,说里面是个邮件群发器,标题栏写着"某某制作",他想把标题换成自己工作室的名字。我当时的心态是:改标题嘛,资源编辑器打开、改字符串、保存,五分钟。
结果这个"五分钟的活"花了我一整个晚上,还顺手把人家三层壳扒了个干净。
先看目录,第一眼就有信息量:e2ee.fne、iext.fne/fnr、HPSocket4C.dll、jmail.dll、三个版本的 miniblink、打码 SDK 和代理 IP SDK。
.fne / .fnr 是易语言支持库后缀。看到 .fne 就能确定:易语言 5.x,独立编译(运行时 krnln 静态链进去)。 于是我从不靠谱的"改资源"切换到了"看 PE"。
纯 struct 手撸一段读 PE 头(不依赖任何库):
import struct
data = open(path, 'rb').read()
pe = struct.unpack_from('<I', data, 0x3C)[0]
nsec = struct.unpack_from('<H', data, pe + 6)[0]
opt = pe + 0x18
ep = struct.unpack_from('<I', data, opt + 0x10)[0]
st = opt + struct.unpack_from('<H', data, pe + 0x14)[0]
for i in range(nsec):
o = st + i * 40
nm = data[o:o+8].rstrip(b'\0').decode('latin1')
vs, va, rs, rp = struct.unpack_from('<IIII', data, o + 8)
print('%-10s VA=0x%-8X VSize=0x%-8X RawPtr=0x%-8X RawSize=0x%-8X' % (nm, va, vs, rp, rs))
L_4qpD VA=0x1000 VSize=0x101000 RawPtr=0x400 RawSize=0x0
L_L1EZ VA=0x102000 VSize=0xD9000 RawPtr=0x400 RawSize=0xD8600
.rsrc VA=0x1DB000 VSize=0x18000 RawPtr=0xD8A00 RawSize=0x17600
EP = 0x1D98A0
三个信号一个比一个刺眼:节名随机(L_4qpD/L_L1EZ);第一个节 RawSize=0 却有 16MB 虚拟空间(壳在内存里现造空间);入口落在第二节末尾,那段熵值 7.9996——和随机数没有统计差别,压缩或加密(正常 x86 代码一般 6.3~7.0)。
再看导入表我差点笑出来:
KERNEL32.DLL : LoadLibraryA, ExitProcess, GetProcAddress, VirtualProtect
USER32.dll : wsprintfA
TOTAL = 5 个函数
一个 10MB 的邮件群发器,导入表只剩 5 个函数——自解压壳的标准配置:自己什么都不干,只留 LoadLibraryA + GetProcAddress 运行时取 API,其余全藏起来。
60 pushad
BE 00205000 mov esi, 0x502000 ; 压缩数据在 VA 0x502000
8D BE 00F0EFFF lea edi, [esi-0x100000] ; 解压目标 0x402000
8D 9C24 80C1FFFF lea ebx, [esp-0x3E80] ; 概率表空间
39 DC / 75 FB cmp esp,ebx / jne $-5 ; 循环清零
46 46 inc esi / inc esi ; 源指针 +2 → 0x502002
68 89741D00 push 0x1D7489 ; ← 解压输出长度!
C7 03 03000200 mov dword [ebx], 0x00020003 ; ← lc=3, lp=0, pb=2
解码器主体里出现三个"身份证号"级别的常量:
8D 88 36070000 lea ecx, [eax+0x736] ; 0x736 = LZMA_BASE_SIZE
B8 00030000 mov eax, 0x300 ; 0x300 = LZMA_LIT_SIZE
66 C7 00 0004 mov word [eax], 0x400 ; 概率模型初值 1024
0x736/0x300/0x400 放一起,基本可以断定是 LZMA SDK 的 LzmaDecode。属性一定要在代码里读,不要猜——猜属性会让你在"解码不报错、输出全乱码"里陷很久。
| 参数 | 值 | 来源 |
|---|---|---|
| 算法 | LZMA1(raw,无 .lzma 头) | 0x736 / 0x300 / 0x400 |
| lc / lp / pb | 3 / 0 / 2 | mov dword [ebx], 0x00020003 |
| 压缩流起点 | 文件偏移 0x402 |
mov esi,0x502000 + 两次 inc |
| 输出长度 | 0x1D7489 |
push 0x1D7489 |
import lzma
OUTSZ = 0x1D7489
filt = [{"id": lzma.FILTER_LZMA1, "dict_size": 1 << 26,
"lc": 3, "lp": 0, "pb": 2}] # 属性照抄代码, 不要猜
data = open(r'目标.exe', 'rb').read()
d = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=filt)
out, buf = b'', data[0x402:]
while len(out) < OUTSZ:
chunk = d.decompress(buf, max_length=OUTSZ - len(out))
buf = b''
if not chunk:
break
out += chunk
if d.eof or d.needs_input:
break
open('payload.bin', 'wb').write(out)
print('解出 %d 字节' % len(out))
两个小技巧:
0x00(range coder 初始字节)。盲扫流头时只扫 data[i]==0 的位置,效率提高 256 倍。payload.bin 到手 1,930,377 字节,我当时的心理活动是"收工"。然后我搜中文,一条都没有;搜 http,一条都没有;搜 MZ 搜到 9 个,全是巧合。
一个邮件群发器,1.9MB 正文一个汉字都没有——这不对劲。按 64KB 分块画熵值才看明白:0x10000/0x20000/0x50000… 一片 7.99,只有 0x30000 是 4.19(表格)。
而尾部最后 0x14000 字节,能直接读出导入函数名表和 PE 头模板 + 节表(.text .rdata .data .data .rsrc)。
结论反转:解包是成功的,但壳的载荷在磁盘上是"压缩 + 加密",只有元数据是明文,真正的字符串常量运行时才解密。我在这上面白耗了两小时。
静态啃不动时一句老话救我:内存是最终答案。 壳再折腾,最后总得把明文交给 CPU。于是思路变成:不去猜解密算法,让它自己解,我解完之后把内存抠出来。
class MBI(ctypes.Structure):
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
("AllocationProtect", wintypes.DWORD), ("RegionSize", ctypes.c_size_t),
("State", wintypes.DWORD), ("Protect", wintypes.DWORD),
("Type", wintypes.DWORD)]
h = k32.OpenProcess(0x1F0FFF, False, pid)
addr, mbi = 0, MBI()
while k32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State == 0x1000 and (mbi.Protect & 0xEE): # MEM_COMMIT 且可读
buf = ctypes.create_string_buffer(mbi.RegionSize)
got = ctypes.c_size_t(0)
if k32.ReadProcessMemory(h, ctypes.c_void_p(mbi.BaseAddress), buf,
mbi.RegionSize, ctypes.byref(got)):
open('region_%08X.bin' % mbi.BaseAddress, 'wb').write(buf.raw[:got.value])
addr += mbi.RegionSize
dump 完和磁盘解出的 payload.bin 对照,结果非常干净:
blob 0x000000 → VA 0x401000 匹配 0.0%
blob 0x040000 → VA 0x405000 匹配 43.4%
blob 0x1C0000 → VA 0x5C1000 匹配 100.0% ← 尾部元数据: 明文, 与磁盘一致
blob 0x1D0000 → VA 0x5D1000 匹配 100.0%
载荷在内存里的基址是 0x401000,与磁盘 blob 1:1 对齐,唯一差别是正文段在磁盘上加密、在内存里解密;尾部元数据两边完全一致——这条证据把对齐关系钉死了,之后定位任何字符串都能直接用 VA。
抽字符串:中文字符串 49,024 条、约 250KB 文本。代表性的几条(作者信息已打码):
| VA | 内容 |
|---|---|
0x42922B |
某某邮件群发器重置版-某某制作--已注册--到期时间; |
0x42918E |
某某邮件群发器重置版-某某制作--卡密已到期!请及时续费 |
0x428FF2 |
https://napi.****.cc/(卡密验证接口 1) |
0x429009 |
https://api2.****.cc/(接口 2) |
0x429020 |
https://api3.****.cc/(接口 3) |
0x429260 |
网络不给力!请更换线路,或稍后再试 |
0x40C0A8 |
免费版不提供 QQ 邮箱养号功能,请联系软件开发者或点击注册进行购买卡密 |
顺手也看清了工作原理(纯技术观察):卡密心跳每 5 秒一次 POST /apiv3/card_ping,带 center_id / timestamp / sign / card / software / needle,应答 data.type=1 表示通过;三个验证地址都是固定 22 字节常量(长度整齐,在逆向里是好消息);界面文字用 GBK 而非 UTF-16,改文字要按 GBK 编码、按原字节数对齐。
[20:42:31] PID 15380 改写#1
旧: "D:\...\小帅邮件群发器重置版4.4-小帅制作\改标题.exe" ← 我自己的控制台窗口!
[20:42:44] PID 10504 改写#4
旧: "proxy_lottery_status.php - 6564中的搜索结果" ← 用户的浏览器!
正确做法是按进程名严格匹配,并显式排除自身 PID、ConsoleWindowClass、conhost/cmd/powershell/浏览器。一行关键词匹配省下的时间,还不起这个代价。
这个软件的作者在壳上是下了功夫的:LZMA 自解压 + 载荷加密 + 导入表只留 5 个函数 + 固定基址无重定位。对一个易语言出品的工具软件来说,该做的都做了。
本文只讨论技术,不提供任何成品文件、补丁或注册机。 如果你是这款软件的用户并觉得它有用,请去支持原作者;如果你想学壳,这篇文章里的每一个坑,可能比壳本身更值得看。
工具清单:Python 3.8(
lzma/struct/re)、x64dbg、capstone、Process Hacker、csc.exe 环境:Windows 11 x64,目标为 32 位 PE(PE32,ImageBase 0x400000) 说明:文中运行时内存 dump 与标题改写数据来自本地测试靶子程序,真机验证需管理员权限,未虚构任何结果。
—— 本文为技术交流,尊重原作者版权。如需删除请联系我。
| 资源标题 | 【原创】记一次邮箱群发 LZMA 自解压壳的逆向:从「只剩 5 个导入函数」到运行时明文 |
| 所属分类 | 其他资源 |
| 发布作者 | admin |
| 发布时间 | 2026-09-11 20:57:00 |
| 下载权限 | 免费下载 |
| 附件数量 | 0 个下载链接 |
| 浏览次数 | 52 |
| 讨论数量 | 0 |
| 主题状态 | 正常讨论中 |