从零逆向一个商业安装器:IDM Crackme 完整实战教程

从零逆向一个商业安装器: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:10010.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:干净的文件,错误的信任

事实链:

  1. 全包 44 个 PE 中唯一无签名
  2. 版本资源自称微软系统组件(jsproxy.dll,Win10 19041)
  3. 被宿主进程加载执行 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 写报告的三段式

排查类分析的报告结构建议:

  1. 事实(可复现的命令与输出,本文全部保留)
  2. 定性(干净/恶意/信任缺陷,给出判断依据链)
  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 延伸练习(无答案)

  1. 把第 3 章的 xref 穷举封装成”虚拟地址 → 引用列表”的通用函数,处理 mov/lea
    其他寻址形式(不只是 push imm32
  2. 用 Ghidra 脚本批量重命名:把 CString 槽位初始化表中每个槽命名成其字符串内容
  3. 追踪”counterfeit serial”服务端核查链:从 blocked_serial_info.html 字符串的
    CString 槽位反查使用函数,画出注册状态机的完整转移图
  4. 给 7.3 的画像脚本加上:节名白名单检查、Rich header 解析、资源目录摘要