逆向一个商业安装器:IDM Crackme 实战记录(新手向)
最近打 CTF 遇到一个 12MB 的 creakmes.exe,从解包到写出 keygen、再到排查”疑似后门”,整个链条走了一遍。把它整理成图文教程,每一步都是真实操作记录。样本是 IDM(Internet Download Manager)6.43 Build 10 的官方安装器,分析仅作学习用途,请勿用于盗版。
写给刚入坑的同学:你不需要任何逆向经验,遇到术语我会当场解释。需要的只是一台 Linux(WSL 也行)和 Python 3。
整体路线先看一遍,后面按顺序展开:
- 识别样本——发现 PE 本体只有 95KB,剩下 12.4MB 是 overlay,这是个安装器
- 解包 overlay——自定义 zlib 流格式,重建出 191 个文件
- 在解出来的 IDMan.exe 里定位注册校验函数
- 还原算法,写 keygen
- 用 Ghidra 反编译对答案
- 排查题目说的”疑似后门”
一、先看”是什么”,别急着扔进 IDA
很多新手的第一反应是立刻把文件拖进 IDA 然后面对几十万个函数发呆。忍住。前 10 分钟只做一件事:搞清楚”它是什么”。三条命令就够:
$ file creakmes.exe
creakmes.exe: PE32 executable (GUI) Intel 80386, for MS Windows, 5 sections
$ md5sum creakmes.exe
f3b0d8ac98453d84a52a27d4c1342c
$ ls -la creakmes.exe
-rw-rw-r-- 1 jinxu jinxu 12537704 creakmes.exe
file 告诉我们这是 PE 文件——Windows 可执行文件的标准格式(PE = Portable Executable)。”Intel 80386″ 指 32 位 x86 指令集,”GUI” 说明是图形界面程序不是命令行工具。
这里就有个不对劲的地方:一个 32 位 GUI 程序,5 个 section,凭什么 12.5MB?
section(节)是什么? PE 文件内部的分区,类似书的章节:.text 放代码、.rdata/.data 放数据、.rsrc 放资源(图标对话框)、.reloc 放重定位信息。每个节在文件里的位置和大小都记录在节表里。用 objdump 看:
$ 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

拿计算器按一下:最后一个节结束于 0x15c00 + 0x1652 ≈ 0x17252,约 95KB。而文件是 12.5MB——中间那 12.4MB 不属于任何节,这个东西叫 overlay(附加数据)。正常程序没有 overlay;有 overlay 的,一般是安装器(把要释放的文件打包挂在屁股后面)、加壳程序(加密的原始程序)、或者恶意样本(藏 payload)。哪一种?继续看。
整个文件的结构此时已经清楚了:

再看导入表确认行为角色。导入表(import table) 是 PE 的”购物清单”:程序要用哪些系统 DLL 里的哪些函数,加载器照单把地址填好。看它买什么,就知道它要干什么:
$ objdump -p creakmes.exe | grep "DLL Name" -A2
KERNEL32.dll: CreateFileW, WriteFile, SetFilePointer, CreateProcessW,
GetTempPathW, CreateDirectoryW, MapViewOfFile ...
USER32.dll: MessageBoxA, CreateDialogParamA...
ADVAPI32.dll: RegOpenKeyExA, RegQueryValueExW ...

读自己(CreateFileW + SetFilePointer)、写临时目录(GetTempPathW + WriteFile)、起进程(CreateProcessW)——标准的自解压安装器三件套。恶意软件分析里这类程序叫 dropper(投放器)。最后用字符串直接确认身份:
$ strings creakmes.exe | grep -iE "IDM|setup|unpacker"
IDM_Setup_Mutex_st23nZ7a
Internet Download Manager
Could not find temp path in unpacker
到这结论就有了:IDM 官方安装器,真正的注册校验在它释放的 IDMan.exe 里。
顺带一提:之前 objdump -p 的输出里,数据目录第 4 项(Security Directory)指向文件尾 0xbf25c0、大小 0x29a8——说明这文件带 Authenticode 数字签名(对照图 1 最下面那块绿色)。签名覆盖全文件内容,是判断”原版还是被改过”的关键证据。记着这个,后面有用。
到这里我们一行汇编都没读,但已经知道:这是什么、多大、什么角色、后面力气该往哪使。这就是”识别优先”的价值。
二、解包:自定义格式先找”压缩单元”
自定义打包格式听着吓人,拆开看无非是压缩单元 + 目录表两样。套路固定:先找到压缩单元的魔数(magic number,格式开头的特征字节),流式扫描全部单元,再找目录表恢复文件名。
先对 overlay 做熵分析和魔数扫描。熵(entropy) 衡量数据随机程度:明文低(<6),压缩或加密数据接近 8。如果一片区域熵 7.9+,它要么是压缩数据要么是加密数据:
ov = f[0x17252:0xbf25c0] # 跳过开头的对齐零
print(ov[0x1ae:0x1ae+32].hex())
# ... 78da 8d5a5d6fdb56 ...
熵 7.99;78 9c / 78 da 是 zlib 流的头(zlib 是 gzip 背后的压缩库,78 后跟校验位)。而且不止一条——流式扫描全部:
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
两个细节:为什么 try/except?因为随机数据里碰巧出现 78 9c 很常见,试着解压、失败就跳过,能解出 ≥16 字节才算数。为什么 i += len(已消费)?解压完一条流后 unused_data 里是剩余字节,直接跳到那里继续扫,不用逐字节磨。
解出 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 条流是清单自己。名字有了,流有了,重建安装目录。

配对时注意一点:第 0 条流是清单本身,要用 streams[1:] 与 P 序号配对,否则整体错位一位。重建完用 file 命令抽查几份——文件名是元数据,内容才是本体,比如 IDMan.exe 必须是 PE32 executable 而不是别的。
这套”找压缩单元 + 恢复目录表”的打法是通用的,恶意样本的 payload 提取也是同一件事。
三、定位注册逻辑:字符串是路标
IDMan.exe 是 6.2MB 的 MFC 程序,函数成千上万,盲读不现实。从用户能看到的东西入手——注册失败时程序会弹提示框,提示文字一定躺在文件里,这些字符串就是入口:
$ strings -td idm/IDMan.exe | grep -iE "serial|register" | grep -iE "invalid|incorrect"
2852496 ...registered with a counterfeit Serial Number...
2852736 You have entered incorrect Serial Number.
2853060 Invalid registration number entered.
-td 让 strings 输出十进制偏移。下一步要把文件偏移换算成虚拟地址(VA)——程序运行时代码引用的是内存地址,不是文件里的位置,两者通过节表映射:
def off2va(off):
for s in pe.sections:
if s.PointerToRawData <= off < s.PointerToRawData + s.SizeOfRawData:
return ImageBase + s.VirtualAddress + (off - s.PointerToRawData)
逻辑很简单:偏移落在哪个节,就用那个节的起始偏移和起始 VA 换算。得到 0x6b96c4 这些地址。
接下来在 .text 节里搜索这个 VA 的 4 字节小端形式。小端(little-endian)是 x86 的字节序:数值 0x6b96c4 在内存里存成 c4 96 6b 00。代码里 push 0x6b96c4 这条指令的机器码就是 68 c4 96 6b 00——所以搜这 4 个字节等于搜”谁在引用这个字符串”。本质上这是手工做交叉引用(xref),IDA 里按 X 键的那个功能:
pat = struct.pack('<I', 0x6b96c4)
# .text 里 find → 命中 0x4ed906
反汇编 0x4ed906 附近,发现这不是校验代码,是一张初始化表:程序启动时把字符串地址批量填进 0x787f88 这些全局变量(CString 槽位,MFC 的字符串对象)。MFC 程序常见这种两级结构。那就换目标——搜槽位地址 0x787f88 谁在用,命中 0x522b77 等几个点,全落在 0x5227e0 这个函数里。校验函数找到了。

没有 IDA 的时候,4 字节 pattern match 就是最原始也最管用的 xref。规律记一条:字符串直接搜不到使用点,先找谁把地址存进了全局变量,再搜那个变量。
四、算法还原:逐条翻译汇编
先给不熟汇编的同学一句话定心丸:读校验逻辑不需要会写汇编,只需要认识十几个常见模式。下面每段汇编我都配上”人话翻译”。
函数 0x5227e0 开头连着四个 GetDlgItemText(读对话框控件的文本),字段一目了然:0x4b0 是名,0x413 是姓,0x4a5 是邮箱(要求含 @ 且其后有 .),0x4aa 是序列号,存在栈变量 [ebp+0x1a4](ebp 是帧指针,[ebp+数字] 就是局部变量,当成变量名看即可)。
格式检查
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] == '-'
cmp 是比较,后面的 jne(不相等就跳)在IDA里往下看就能看到。0x1a9 – 0x1a4 = 5,就是第 5 个字符;0x2d 是 '-' 的 ASCII 码。直白得很:总长 23,第 5、11、17 位是横杠。

每组转数值:37 进制
然后是每组的数值转换,函数在 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 个字符
这就是”数字字符串转数值”的标准写法——想想你写 int("12345") 时编译器干的事:从左到右,v = v*10 + 当前数字。这里唯一的区别是基数是 37 不是 10,字符的值是它在 36 字节表里的下标。

注意一个微妙的地方:基数 37,但字符表只有 36 个符号(下标 0~35)。也就是说 base-37 表示里的”数字 36″没有对应字符——这个细节后面写 keygen 时会咬人,先埋个伏笔。
字符表在 0x787848,但直接 dump 那块内存全是零——它是运行时才填的。写它的代码在 0x522824,一条条 mov byte ptr [地址], 立即数:
mov byte ptr [0x787848], '2'
mov byte ptr [0x787849], 'Y'
mov byte ptr [0x78784a], 'O'
...
把 36 条指令全提出来,字母表是:
2YOPB3AQCVUXMNRS97WE0IZD4KLFGHJ8165T
运行时初始化的全局数据,静态查看是空的,要去找写入代码——mov byte/dword ptr [addr], imm 序列本身就是数据。这是对抗静态分析的小技巧,也不排除只是程序员的写法习惯。
约束检查:除法魔数
最后是四组数值的约束检查。这里有个汇编阅读的硬知识点,新手几乎必卡:编译器不做 div imm32(x86 没有除以立即数的指令),除以常数会被优化成乘 magic number(魔数)再右移。看第一组:
522cf7 mov eax, 0x2fa0be83 ; 加载魔数 M
522cfc imul ecx ; edx:eax = v * M(64 位结果)
522cfe sar edx, 3 ; 取高 32 位再算术右移 → ≈ v/43 的商
522d08 imul eax, eax, 0x2b ; 商 × 43
522d0d sub edx, eax ; v - (v/43)*43 = v % 43(余数)
522d0f jne 失败 ; 余数 ≠ 0 → 非法
522d11 test ecx, ecx
522d13 jne 通过 ; v == 0 也不行
数学原理一句话:43 × 0x2FA0BE83 ≈ 2^35,所以 v × M >> 35 ≈ v / 43,误差被取整吃掉。识别口诀:看到 imul(魔数) → sar(右移) → imul(常数) → sub,乘回的那个常数就是除数——0x2b 是 43。

四个组依次是:0x2FA0BE83 除以 43,0xB21642C9 除以 23,0x78787879 除以 17(这个魔数见多了会有条件反射),0x4D4873ED 除以 53。
汇总

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
姓名和邮箱只查非空和格式,完全不参与序列号计算。也就是说客户端校验没有绑定,可以量产。还有个佐证:0x521800 这个转换函数全程序就 4 个调用者,都在对话框校验里——没有第二条隐藏的解析路径。
五、Keygen:把校验的逆写出来
生成和校验是互逆的。校验是”编码成数值再判断”,生成就是”挑满足判断的数值再解码”。唯一要小心的就是前面埋的雷:数值转回字符时,如果某一位 base-37 数字等于 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,check 通过。回头看图 8——222YA 转出来正好是 43,43 当然是 43 的倍数,这就是”生成”在最简单情况下的样子。
验证器不是可选项。逆向理解是一回事,能自圆其说地生成是另一回事——后者能暴露理解里的每一处含糊。
六、交叉验证:手读汇编要去和反编译器对答案
单靠手读,错了自己不知道。用 Ghidra(NSA 开源的逆向套件,相当于免费 IDA)的 headless 模式(命令行批处理,不需要图形界面,服务器上就能跑)把同一个函数反编译成 C 对比:
analyzeHeadless proj idman -import IDMan.exe -processor x86:LE:32:default
反编译结果和手读逐条对上。有个有意思的差异: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';
v/d*d != v 和 v%d != 0 等价,只是反编译器选了不同的化简方向。两条独立路径得出相同结论,这个算法才算”验证过”,而不是”我觉得”。
七、排查”疑似后门”:从便宜的手段往贵的做
题目说包里藏了疑似后门。这种活最忌上来就逐函数读 12MB 二进制,永远读不完。按成本从低到高排三层。

第一层,网络指标面。 全包 URL、域名、IP 提取一遍(宽字符串和窄字符串都要,Windows 程序常用 UTF-16 存字符串),归类后找不认识的域名。本例全部能归因:官方域、证书 OCSP、帮助文档链接,还有 defexclist.txt 这个站点排除表。浏览器扩展(.crx/.xpi/.nex 都是 ZIP)也解开审了 JS,WebSocket 只连 127.0.0.1:1001 和 0.1.0.1:1001——后者是 Windows 0.x.x.x 段的回环别名,IDM 用来绕浏览器对回环地址 WebSocket 的限制,已知设计。
第二层,PE 画像。 对全部 44 个 PE 输出编译时间戳、链接器版本、有无 Authenticode 签名、节熵,找”长得不一样”的:
rows.append((pe.FILE_HEADER.TimeDateStamp, # 编译时间
linker_version, # VS2008? VS2015?
'SIG' if security_dir.Size else 'nosig',
max_entropy))
异常一眼就出来了:oldjsproxy.dll,全包唯一一个 nosig。而且它的版本资源写着 Microsoft Corporation、jsproxy.dll、11.00.19041.3031——一个自称微软系统文件却不带微软签名的 DLL,被宿主进程加载执行 PAC 代理决策。
第三层,深挖嫌疑文件。 导入表没有 CreateThread、WriteFile、CreateProcess、注册表 API,不具备独立作恶能力;导出是标准 autoproxy 接口;字符串里没有 C2 域名;jscript9/jscript 是 PAC 求值必需的 COM 引擎。最后 Ghidra 全量反编译 206 个函数,dnsResolve 这些有外联能力的函数都是教科书实现。
结论:文件本身是干净的微软 jsproxy 克隆。但定性不能停在这里——
八、讨论:干净文件与错误的信任
oldjsproxy.dll 的三个事实放在一起看:全包唯一无签名;版本资源冒充微软系统组件;加载进宿主参与所有 HTTP 流量路径。文件干净,部署形态却是后门的完美插槽——任何人以中间人或植入方式替换这个”不验签的微软组件”,都会被静默接受。所以”疑似后门”的正确定性不是”藏了后门”,而是”后门的完美插槽”,这是供应链信任缺陷,不是恶意代码。
顺带一个知识点。注册校验通过后,程序会用 SetNamedSecurityInfo 重写 HKLM\SOFTWARE\Internet Download Manager 的 DACL(自主访问控制表,Windows 里挂在每个注册表键/文件上的权限清单)。效果是破解者想删值重置试用,reg delete 直接 ACCESS DENIED,必须先取得所有权再重建 DACL。定位是软反篡改:抬门槛而非防线。所有者隐含 WRITE_DAC 权利,管理员和 SYSTEM 永远夺回得来;何况校验逻辑在客户端,patch 掉那几个比较跳转就全绕开了。
排查类分析的报告我习惯写三段:事实(可复现的命令和输出)、定性(干净/恶意/信任缺陷,附判断依据链)、影响与建议(谁能利用、怎么加固)。oldjsproxy 的建议很简单:加载前校验微软签名。
附录:产出物与延伸练习
产出物都在本地:keygen.py(生成器加验证器,注释里带反汇编地址)、decomp/IDMan_targeted.c(注册函数反编译)、decomp/oldjsproxy_all.c(嫌疑 DLL 全量反编译)、findings.md(排查报告)。
几道延伸题,没有标准答案:
- 第三章的 xref 穷举只处理了
push imm32,把它扩展成通用函数,覆盖mov、lea等寻址形式 - 用 Ghidra 脚本批量重命名:把 CString 槽位初始化表里每个槽命名成其字符串内容,后续阅读会快很多
- 追一下 counterfeit serial 的服务端核查链:从 blocked_serial_info.html 对应的 CString 槽位反查使用函数,画出注册状态机的完整转移图
- 给第七章的画像脚本加料:节名白名单、Rich header 解析、资源目录摘要