从零逆向一个商业安装器:IDM Crackme 完整实战教程
一个真实的逆向分析全流程:拿到一个 12MB 的
creakmes.exe,最终还原其注册算法、写出 keygen、并完成一次”后门排查”。
适合有基础编程能力、想系统学习逆向方法论的同学。所有步骤可复现,所有代码可直接使用。
读者约定与免责声明
- 你需要:Linux 环境(WSL 也行)、Python 3、能看懂 x86 汇编的 basics(看不懂也行,每段都有解释)
- 样本是真实的商业软件安装器。本文用于方法论教学,请勿将产出用于盗版分发
- 每章末尾有【练习】,建议先自己想再看答案
全景图:这次分析走过的路
creakmes.exe (12.5MB)
│
├─ 第1章 识别:PE 只有 95KB → 发现 12.4MB overlay → dropper 特征
├─ 第2章 解包:zlib 流雕刻 + DEST manifest → 重建 191 个文件
│ └─ 坑:流顺序与文件名错位一位
├─ 第3章 定位:字符串 → 全局 CString 槽位 → 交叉引用 → 校验函数
├─ 第4章 算法:23字符格式 / 36字符字母表 / 37进制 / 四组模数约束
│ └─ 知识点:编译器除法魔数
├─ 第5章 Keygen:约束求解 + 自校验
├─ 第6章 验证:Ghidra headless 反编译交叉确认(手读 vs 反编译)
└─ 第7章 后门排查:PE 画像 / 网络指标 / 扩展审计 / 无签名文件
└─ 第8章 讨论:供应链信任(oldjsproxy.dll)与 ACL 加固
第 1 章 初识样本:先看”是什么”,再看”怎么做”
1.1 永远从这三条命令开始
$ file creakmes.exe
creakmes.exe: PE32 executable (GUI) Intel 80386, for MS Windows, 5 sections
$ md5sum creakmes.exe
f3b070d8ac98453d84a52a27d4c1342c
$ ls -la creakmes.exe
-rw-rw-r-- 1 jinxu jinxu 12537704 creakmes.exe
第一个疑点:PE 程序却只有 5 个 section、导入表极小,但文件 12.5MB。
x86 GUI 程序本体一般几十 KB 到几 MB,12MB 说明PE 后面挂了非 PE 数据(overlay)。
1.2 用 objdump 看 section 布局,算出 overlay 起点
$ objdump -h creakmes.exe
Idx Name Size VMA File off
0 .text 0000b6a7 00401000 00000400
1 .rdata 000036ca 0040d000 0000bc00
2 .data 00002000 00411000 00000f400
3 .rsrc 00004758 00414000 00011400
4 .reloc 0001652 00419000 00015c00
最后一个 section 在文件中结束于 0x15c00 + 0x1652 ≈ 0x17252(约 95KB),
而文件是 12.5MB——中间的 12.4MB 都是 overlay。
1.3 导入表:dropper 的典型指纹
$ objdump -p creakmes.exe | grep -A2 "DLL Name"
KERNEL32.dll: CreateFileW, WriteFile, SetFilePointer, CreateProcessW,
GetTempPathW, CreateDirectoryW, MapViewOfFile ...
USER32.dll: MessageBoxA, CreateDialogParamA ...
ADVAPI32.dll: RegOpenKeyExA, RegQueryValueExW ...
读自身 + 写临时目录 + 创建进程 三件套 = 自解压安装器(dropper)。
再看字符串确认身份:
$ strings creakmes.exe | grep -iE "IDM|setup|unpacker"
IDM_Setup_Mutex_st23nZ7a
Internet Download Manager
Could not find temp path in unpacker
结论:这是 Internet Download Manager (IDM) 的官方安装器。真正的”crackme”——注册校验——
在被它释放的 IDMan.exe 里。
【方法论】拿到样本的前 10 分钟只回答三个问题:是什么(file/字符串)、多大(section 布局)、
行为角色(导入表)。不要一上来就扔进 IDA。
【练习 1】 Security Directory(数据目录第 4 项)指向文件尾部 0xbf25c0、大小 0x29a8。
这说明什么?(答案:文件带 Authenticode 数字签名,是判断”原版 or 被改”的关键证据)
第 2 章 解包:自定义格式不可怕,先找”压缩单元”
2.1 定位压缩格式
对 overlay 做熵分析 + 魔数扫描:
ov = f[0x17252:0xbf25c0] # 跳过开头的对齐零
print(ov[0x1ae:0x1ae+32].hex())
# ... 78da 8d5a5d6fdb56 ... ← 0x78da 是 zlib 的典型头!
熵 7.99(近随机)+ 78 9c/78 da 魔数 = zlib 流。而且不止一条——扫描全部:
import zlib
streams, i = [], 0
while i < len(ov) - 2:
if ov[i] == 0x78 and ov[i+1] in (0x01, 0x5e, 0x9c, 0xda):
d = zlib.decompressobj()
try:
out = d.decompress(ov[i:])
if len(out) >= 16:
streams.append(out)
i += len(ov[i:]) - len(d.unused_data) # 关键:跳过已消费部分
continue
except Exception:
pass
i += 1
解出 192 条流。第一条是 XML:
<DEST>
<LNK1=PROG,N="Uninstall IDM"<P1="Uninstall.exe"><SETUP>
<LNK5=PROG,N="Internet Download Manager"<P5="IDMan.exe"><RUN>
...
这是安装清单:P1..P191 按序号对应第 1..191 个文件流(第 0 条流是清单本身)。
2.2 踩坑实录:文件名错位一位
第一次配对 zip(streams, names) 把清单流也配给了 P1,导致 Uninstall.exe
内容其实是 XML、IDMan.exe 其实是 CHM 帮助文件——file 命令立刻露馅:
$ file idm/IDMan.exe
MS Windows HtmlHelp Data ← 不对!应该是 PE32
修正为 zip(streams[1:], names) 后 191 个文件全部正确。重建后永远用
file / 魔数抽查,别信文件名。
【方法论】自定义打包格式 = 压缩单元 + 目录表。找到单元魔数(zlib/zip/lzma)就能”雕刻”,
目录表(如本例的 DEST XML)用来恢复文件名。这套思路同样适用于恶意样本的 payload 提取。
【练习 2】 为什么扫描时 0x78 0x9c 要用 try/except 而不是严格校验?
(答案:数据区里可能碰巧出现合法魔数前缀但不构成完整流;反之有些真流会被误伤,
所以用”能解出 ≥N 字节”做启发式过滤)
第 3 章 定位注册逻辑:字符串是路标
3.1 从提示信息入手
$ strings -td idm/IDMan.exe | grep -iE "serial|register" | grep -i -E "invalid|incorrect"
2852496 ...registered with a counterfeit Serial Number...
2852736 You have entered incorrect Serial Number.
2853060 Invalid registration number entered.
把文件偏移换算成 VA(虚拟地址):
def off2va(off): # 遍历 sections,落在哪个 section 就用哪个的基址
for s in pe.sections:
if s.PointerToRawData <= off < s.PointerToRawData + s.SizeOfRawData:
return ImageBase + s.VirtualAddress + (off - s.PointerToRawData)
得到 0x6b96c4 等三个地址。
3.2 交叉引用:字符串 → 全局槽 → 使用点
在 .text 里搜索这些 VA 的 4 字节小端形式(即 push imm32 的操作数):
pat = struct.pack('<I', 0x6b96c4)
# 在 .text 里 find → 命中 0x4ed906
反汇编发现 0x4ed700 区域只是初始化表(把字符串地址填进 0x787f88 等全局 CString 槽位)。
真正的使用者引用的是槽位地址——再搜一次 0x787f88,得到 0x522b77 等引用点,
全部落在 0x5227e0 这个函数里。它就是注册对话框的校验函数。
【方法论】MFC/MSVC 程序常见”字符串 → 全局 CString 槽 → 间接引用”的两级结构,
直接搜字符串地址找不到使用点时,先找谁把地址存进了哪个全局变量,再搜那个变量的地址。
没有交叉引用功能时的穷举法:4 字节 pattern match 就是最原始的 xref。
【练习 3】 为什么要换算 VA 再搜索,而不是直接搜文件偏移?
(答案:代码里出现的是运行时地址;文件偏移和 VA 只在同一 section 内才保持线性关系)
第 4 章 算法还原:逐条翻译汇编
4.1 输入采集
0x5227e0 函数开头连续四个 GetDlgItemText:
| 控件 ID | 缓冲区 | 字段 |
|---|---|---|
| 0x4b0 | [ebp+0x130] | 名 |
| 0x413 | [ebp+0xfc] | 姓 |
| 0x4a5 | [ebp+0x164] | 邮箱(需含 @ 且其后有 .) |
| 0x4aa | [ebp+0x1a4] | 序列号 |
4.2 格式检查
522be9 cmp eax, 0x17 ; strlen(serial) == 23
522bf2 cmp byte [ebp+0x1a9], 0x2d ; s[5] == '-'
522bfa cmp byte [ebp+0x1af], 0x2d ; s[11] == '-'
522c02 cmp byte [ebp+0x1b5], 0x2d ; s[17] == '-'
→ 格式为 XXXXX-XXXXX-XXXXX-XXXXX(5+1+5+1+5+1+5 = 23 ✓)
4.3 字符转换函数 0x521800
521817 cmp byte [eax + 0x787848], dl ; 在 36 字节表中找当前字符
521831 imul edx, edx, 0x25 ; v = v * 37
521834 add edx, eax ; + 下标
521836 inc ecx
52183a cmp ecx, 5 ; 正好 5 个字符
→ 37 进制(注意 0x25=37,但字符表只有 36 个符号——这是后面 keygen 的关键约束)。
字符表在 0x787848,但静态文件里是空的——它在 0x522824 被逐字节写入:
mov byte ptr [0x787848], '2'
mov byte ptr [0x787849], 'Y'
mov byte ptr [0x78784a], 'O'
...
提取全部 36 条得到字母表:
2YOPB3AQCVUXMNRS97WE0IZD4KLFGHJ8165T
【方法论】运行时初始化的数据(IDA 里显示为未初始化全局)要去找写入代码,
mov byte/dword ptr [addr], imm的序列就是数据本身。
4.4 知识点:除法魔数(magic number)
四组数值的约束检查长这样:
522cf7 mov eax, 0x2fa0be83
522cfc imul ecx ; edx:eax = v * 0x2fa0be83
522cfe sar edx, 3 ; edx = 高位算术右移
522d08 imul eax, eax, 0x2b ; 商 × 43
522d0d sub edx, eax ; v - (v/43)*43 = v % 43
522d0f jne 失败 ; 余数非零 → 错
522d11 test ecx, ecx
522d13 jne 通过 ; v==0 也不行
编译器没有 div imm32 的除法优化,而是乘以 (2^(32+s))/d 的倒数魔数再右移。
识别方法:imul + sar + 乘回一个常数再 sub——乘回的常数就是除数:
| 魔数 | 乘回 | 约束 |
|---|---|---|
| 0x2FA0BE83 | 0x2b=43 | v1 % 43 == 0 && v1 ≠ 0 |
| 0xB21642C9 | 0x17=23 | v2 % 23 == 0 && v2 ≠ 0 |
| 0x78787879 | ×17(shl 4 + add 还原) | v3 % 17 == 0 && v3 ≠ 0 |
| 0x4D4873ED | 0x35=53 | v4 % 53 == 0 && v4 ≠ 0 |
(经验:看到 0x78787879 可以直接条件反射——这是除以 17 的经典魔数)
4.5 完整算法
serial = "XXXXX-XXXXX-XXXXX-XXXXX"
alphabet = "2YOPB3AQCVUXMNRS97WE0IZD4KLFGHJ8165T" # 36 字符,值=下标
每组: v = c0·37⁴ + c1·37³ + c2·37² + c3·37 + c4
约束: v1%43==0∧v1≠0, v2%23==0∧v2≠0, v3%17==0∧v3≠0, v4%53==0∧v4≠0
通过后: 序列号前 8 字符/姓名/邮箱写入 HKLM\SOFTWARE\Internet Download Manager
注意: 姓名邮箱只查非空/格式,不参与序列号计算 → 无绑定
【练习 4】 0x521800 全程序只有 4 个调用者意味着什么?
(答案:客户端校验就这一处;没有其他隐藏的序列号解析路径)
第 5 章 Keygen:从”能看懂”到”能生成”
关键点:37 进制的数字 0..36,但字母表只有 36 个符号(下标 0..35),
所以数值转回字符时,某位若等于 36 则不可编码,需要跳过:
ALPHA = '2YOPB3AQCVUXMNRS97WE0IZD4KLFGHJ8165T'
IDX = {c: i for i, c in enumerate(ALPHA)}
def encode(v):
ds = []
for _ in range(5):
ds.append(v % 37); v //= 37
if v or any(d > 35 for d in ds): # 溢出或数字 36 → 无对应字符
return None
return ''.join(ALPHA[d] for d in reversed(ds))
def gen_group(d, k=1): # 第 k 个可编码的 d 的倍数
n, m = 0, 1
while True:
g = encode(m * d)
if g:
n += 1
if n == k: return g
m += 1
serial = '-'.join(gen_group(d) for d in (43, 23, 17, 53))
永远写验证器(把逆向出的判断逻辑原样实现),生成后自测:
def check(s):
s = s.lstrip()
if len(s) != 23: return False
if s[5] != '-' or s[11] != '-' or s[17] != '-': return False
vals = []
for g in (s[0:5], s[6:11], s[12:17], s[18:23]):
v = 0
for ch in g:
if ch not in IDX: return False
v = v * 37 + IDX[ch]
vals.append(v)
return all(v != 0 and v % d == 0 for v, d in zip(vals, (43,23,17,53)))
输出示例(每组取各自除数的 1 倍再组合):
222YA-2222D-22227-222Y9 ✓
【方法论】keygen 的本质是把”校验的逆”写出来。校验是
编码→判断,
生成就是选满足判断的值→解码。卡住时问自己:哪些值域覆盖不到?(本例:数字 36)
【练习 5】 为什么 v != 0 的检查反而让 keygen 更简单?
(答案:排除的只是”五个 ‘2’”(下标 0)一种组合)
第 6 章 交叉验证:手读汇编 ≠ 真相,用反编译器对答案
用 Ghidra headless(无需 GUI,适合服务器/CI)对目标函数反编译:
analyzeHeadless proj idman -import IDMan.exe -processor x86:LE:32:default
# 分析完成后定向反编译 0x5227e0 / 0x521800
反编译结果与手读逐条吻合(Ghidra 把模数判断还原成了除法形式):
if ((int)pcVar9 - (int)(acStack_5d + 2) == 0x17) { // 23 字符
if (((cStack_57 != '-') || (cStack_51 != '-')) || (cStack_4b != '-'))
bVar20 = true; // 3 个 '-'
...
if ((pHStack_230 != (HKEY)(((int)pHStack_230 / 0x2b) * 0x2b)) || // %43
(pHStack_230 == (HKEY)0x0))
cVar16 = '\x01';
if ((iStack_21c != (iStack_21c / 0x17) * 0x17) || (iStack_21c == 0)) // %23
...
}
【方法论】两条独立路径(手读汇编 / 反编译器)得出相同结论,才算”验证过”。
反编译器会做它自己的简化(如把%写成v/d*d != v),翻译回语义时注意等价性。
【练习 6】 反编译输出里 0x521800 被命名为 sub_0x521800 而调用点显示为
func_0x00521800,为什么函数没有符号名?如何利用导出表/字符串给函数命名加速后续阅读?
第 7 章 后门排查:当你怀疑包里藏了东西
场景:题目说”藏了疑似后门”。排查框架(从便宜到昂贵):
7.1 网络指标面
# 全包 URL/域名/IP 提取(宽+窄字符串都要)
grep -raoE "https?://..." . | sort -u
归类后找”不认识”的域名。本例全部可归因:官方域 / 证书 OCSP / 帮助文档链接 /
defexclist.txt(站点排除表)。
7.2 PE 画像(找”长得不一样”的文件)
对全部 44 个 PE 输出:链接时间戳、编译器版本、签名有无、节熵:
rows.append((pe.FILE_HEADER.TimeDateStamp, # 编译时间
linker_version, # VS2008? VS2015?
'SIG' if security_dir.Size else 'nosig', # 有无 Authenticode
max_entropy)) # 有无加壳嫌疑
异常立刻浮现:oldjsproxy.dll——全包唯一 nosig。
7.3 深挖嫌疑文件
- 导入表:无 CreateThread/WriteFile/CreateProcess/注册表 API → 不具备独立作恶能力
- 导出表:标准 WinInet autoproxy(PAC)接口
- 字符串:无 C2 域名、无硬编码代理;
jscript9/jscript是 COM 引擎加载(正常) - 版本资源:冒充微软(Microsoft Corporation / jsproxy.dll / 11.00.19041.3031)
- Ghidra 全量反编译 206 函数:dnsResolve 等均为教科书实现
7.4 别忘了浏览器扩展
.crx/.xpi/.nex 都是 ZIP,解开审 JS。本例扩展的 WebSocket 只连
127.0.0.1:1001 和 0.1.0.1:1001——后者是 Windows 0.x.x.x 段的回环别名,
是 IDM 为绕过浏览器对 ws://127.0.0.1 限制的已知合法设计(知识点!)。
【方法论】排查顺序:先全量画像缩小范围(O(文件数)),再对嫌疑文件深挖(O(代码量))。
上来就逐个函数读 12MB 二进制是永远排不完的。
【练习 7】 如果攻击者把 C2 域名 XOR 混淆存在 .data 里,7.1 的字符串扫描会漏掉。
设计一个检测”栈上构造字符串”的静态特征。(提示:连续 mov byte ptr [ebp-x], imm8
且 imm8 落在可打印区间)
第 8 章 讨论:供应链信任与”疑似后门”的定性
8.1 oldjsproxy.dll:干净的文件,错误的信任
事实链:
- 全包 44 个 PE 中唯一无签名
- 版本资源自称微软系统组件(jsproxy.dll,Win10 19041)
- 被宿主进程加载执行 PAC 代理决策(等于参与所有 HTTP 流量路径)
它本身是忠实的微软 jsproxy 克隆(206 函数审计无异常),但这个部署形态构成
供应链信任缺陷:任何人以中间人/植入方式替换这个”不验签的微软组件”,
都会被静默接受。这就是”疑似后门”的正确定性——不是”藏了后门”,
而是”后门的完美插槽”。
8.2 注册表 ACL 加固(顺带的知识点)
注册校验通过后,程序用 SetNamedSecurityInfo 重写
HKLM\SOFTWARE\Internet Download Manager 的 DACL,限制谁可以改/删这些值。
效果:破解者想重置试用/改序列号,reg delete 直接 ACCESS DENIED,
必须先取得所有权(take ownership)再重建 DACL。
定位:软反篡改,抬门槛而非防线——所有者隐含 WRITE_DAC 权利,
管理员/SYSTEM 永远可以夺回;而且校验逻辑本身在客户端,可 patch。
8.3 写报告的三段式
排查类分析的报告结构建议:
- 事实(可复现的命令与输出,本文全部保留)
- 定性(干净/恶意/信任缺陷,给出判断依据链)
- 影响与建议(谁能利用、怎么加固——例如:给 oldjsproxy.dll 校验微软签名再加载)
附录 A 工具清单
| 工具 | 用途 | 本教程用量 |
|---|---|---|
| file / objdump / strings | 识别与初筛 | ★★★ |
| Python + pefile + capstone | 脚本化分析(雕刻/xref/反汇编) | ★★★ |
| Ghidra 12 (headless + DecompInterface) | 反编译交叉验证 | ★★ |
| zlib (Python) | 解包 | ★★★ |
| unzip | 扩展包审计 | ★ |
附录 B 本例产出物索引
keygen.py—— 生成器 + 验证器(含反汇编地址注释)decomp/IDMan_targeted.c—— 注册函数反编译decomp/oldjsproxy_all.c—— 嫌疑 DLL 全量反编译findings.md—— 排查报告
附录 C 延伸练习(无答案)
- 把第 3 章的 xref 穷举封装成”虚拟地址 → 引用列表”的通用函数,处理
mov/lea等
其他寻址形式(不只是push imm32) - 用 Ghidra 脚本批量重命名:把
CString槽位初始化表中每个槽命名成其字符串内容 - 追踪”counterfeit serial”服务端核查链:从
blocked_serial_info.html字符串的
CString 槽位反查使用函数,画出注册状态机的完整转移图 - 给 7.3 的画像脚本加上:节名白名单检查、Rich header 解析、资源目录摘要