你需要准备什么
| 工具 |
干嘛用 |
去哪下 |
| Detect It Easy (DIE) |
第一步确认是什么壳 |
github.com/horsicq/Detect-It-Easy |
| x64dbg + ScyllaHide 插件 |
动态调试,过反调试 |
x64dbg.com(ScyllaHide 内置在 snapshot 版里) |
| 010 Editor 或 HxD |
看 hex,算 entropy |
sweetscape.com/010editor 或 mh-nexus.de/hxd |
| Python 3 + Capstone |
离线反汇编 + 写解压器 |
pip install capstone |
关于样本:从网上能找到 Themida 加壳的测试程序(比如 crackmes.one 上搜 "themida")。这篇用的样本是 Themida 3.1.8 的 x64 检测程序,体量很小(~12KB),适合拿来练手。你找什么样本都行,重点是它确实是 Themida 加的壳。
从哪开始 —— 三步概览
第 1 步:用 DIE 确认壳 → 用 010 Editor 看 EP 数据 → 确定是加密数据
第 2 步:x64dbg 断在 EP → 反汇编解压代码 → 识别 LZSS 算法
第 3 步:写 Python 离线解压 → entropy 验证 → 扔 IDA 分析
每步下面都有详细操作,包括具体点哪个按钮、你会看到什么、没看到该怎么办。
第 1 步:确认壳类型 + 看 EP 数据
1.1 用 DIE 看
把样本拖进 Detect It Easy。看中间那个 "Detect" 结果:
你应该看到类似:
Packer: Themida(2.x-3.x)[-]
Linker: Microsoft Linker(14.0)
如果看到 Themida 字样,继续。如果看到的是别的(VMProtect、Enigma 等),这篇不适用。
DIE 里点 "PE" 标签,记下两个数:
Entry Point: 00002C58 ← EP 的 RVA
Image Base: 00400000 ← 基址(x86 是这个,x64 通常是 140000000)
1.2 用 010 Editor 看 EP 的 hex
把样本拖进 010 Editor,按 Ctrl+G 跳到上面记的 EP 偏移(不是 RVA,是文件里的物理偏移——DIE 里 "Entry Point" 右边通常有文件偏移)。
你会看到类似这样的东西:
Offset(h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00002C50 9C 0F 7A 3B
00002C60 E8 44 A2 D1 7C 6F 93 E2 01 55 F3 88 2A CD 06 B1
00002C70 4E 92 7F D8 3C 61 BB 05 F7 29 8E 46 D0 13 58 AC
00002C80 33 6F C5 1A E9 47 A3 D2 7E 0B 91 F5 28 CC 06 B1
一眼看上去——有没有大片 00 00 00 00?有没有重复的 CC CC CC CC?有没有规律性的指令前缀(比如反复出现 55 48 8B)?
如果没有,全是乱七八糟看不出规律的——这是加密数据。正常 x64 代码有明显的指令边界,加密数据看起来就是随机字节。
1.3 算 entropy 确认
Python 算一下:
import math
from collections import Counter
with open("sample.exe", "rb") as f:
f.seek(0x2C58) # 填你的 EP 文件偏移
data = f.read(4608) # 代码段大小,用 DIE 的 "Size of Code" 字段
counts = Counter(data)
entropy = -sum((c/len(data)) * math.log2(c/len(data)) for c in counts.values())
print(f"entropy = {entropy:.2f}") # 接近 8 = 加密,5-6 = 正常代码
我的结果是 7.91——几乎满熵,确认加密。
如果你算出来是 5.x:说明这个样本的代码段没加密,不需要往下看了。
第 2 步:x64dbg 断在 EP,反汇编解压代码
2.1 附加到进程
打开 x64dbg,File → Open → 选你的样本。先别跑(不要按 F9)。
注意:Themida 在 EP 之前有反调试。如果你直接 F9 跑到 EP,中途会被 INT 2D / ZwSetInformationThread 干掉。两种办法:
- 方法 A(推荐):在 x64dbg 插件菜单里启用 ScyllaHide,勾上 "Themida" 预设。然后按 F9,插件会自动过掉反调试。
- 方法 B:不跑,直接在 EP 设硬件断点。按 Ctrl+G → 输入
imagebase + EP_RVA(比如 0x140002C58,x64 样本的基址大多是 0x140000000)。设硬件执行断点:右键 → Breakpoint → Hardware, Execution → 然后 F9。第一次 F9 可能被反调试打断,再按几次 F9 直到停在你的断点处。
2.2 停在 EP 之后看什么
停住了就成功了。看反汇编窗口,第一段代码应该长这样:
; 这是 x64 版本的 EP 解压器入口
lea r8, [rbp-180h] ; 输出缓冲区
mov cl, 80h ; ← 注意这个 0x80,是关键特征
mov r9, rsi ; 输入缓冲区(加密数据)
@@loop:
cmp cl, 80h
jnz short @@have_bits
mov cl, [r9] ; 读一字节
inc r9
stc ; CF = 1
@@have_bits:
adc cl, cl ; ← 这条是核心
jnb short @@literal ; CF=0 → 字面量
; CF=1 → 回引
movzx eax, word ptr [r9] ; 读 16-bit
add r9, 2
mov edx, eax
shr edx, 0Bh ; 高 5 bit = length
add edx, 3 ; 实际长度 = 编码值 + 3
and eax, 7FFh ; 低 11 bit = offset
inc eax ; 实际偏移 = 编码值 + 1
关键判断:如果你看到了 mov cl, 0x80 然后 adc cl, cl 然后 jnb 这个模式——恭喜,这就是 LZSS 位流解压器。继续看。
如果你停下来的代码不是这个模式:可能 EP 偏移不对,或者样本的 Themida 版本跟这篇不一样。去 DIE 里确认 Entry Point RVA,用 Ctrl+G 重新跳。
x86(32位)版本的逻辑完全一样,只是寄存器名字不同(cl 变 dl,r8/r9 变 edi/esi)。同一个算法。
2.3 这段代码在干嘛
不急着分析全部,先理解核心循环。画个流程图比写文字快:
┌──────────┐
│ 读下一字节│←── cl == 0x80 时触发
│ stc │
└─────┬────┘
↓
┌──────────┐
┌───────│ adc cl,cl│───────┐
│ └──────────┘ │
│ CF=1 CF=0 │
↓ ↓
┌──────────────┐ ┌──────────────┐
│ 读 16-bit │ │ 读 1 字节 │
│ 解析为 │ │ 直接拷到输出 │
│ (offset,len) │ └──────────────┘
│ 从输出缓冲 │
│ 拷贝 len 字节 │
└──────────────┘
adc cl, cl 这一条指令做了两件事:cl = (cl << 1) | CF。每执行一次,cl 的最高位被移进 CF(进位标志),然后 CF 的值变成下一条指令的条件判断依据。CF=1 就进回引分支,CF=0 就进字面量分支。
这本质上是一个逐位驱动的状态机,每个 bit 决定一个操作。
第 3 步:写 Python 离线解压,验证,扔 IDA
3.1 先 dump 出加密代码段
x64dbg 里,Memory Map 标签 → 找到 .text 段(或者 Themida 的 .boot 段)→ 右键 → Dump to File。保存为 encrypted_text.bin。
或者用 Python 直接从文件里切:
with open("sample.exe", "rb") as f:
f.seek(0x2CB0) # EP 文件偏移 + 0x58(跳过加密头)
encrypted = f.read(4608) # 代码段大小
with open("encrypted_text.bin", "wb") as f:
f.write(encrypted)
3.2 写解压器
核心就是模拟 adc cl, cl 那个循环。完整代码带上注释大概 60 行:
def lzss_decompress(data, out_size):
"""Themida 3.x LZSS 解压器"""
output = bytearray()
src_pos = 0
bit_buf = 0x80 # 相当于 cl = 0x80
while len(output) < out_size:
# 检查是否需要读新字节
if bit_buf == 0x80:
bit_buf = data[src_pos]
src_pos += 1
carry = 1 # stc → CF = 1
else:
carry = 0
# adc bit_buf, bit_buf → bit_buf = (bit_buf << 1) | carry
bit_buf = ((bit_buf << 1) | carry) & 0xFF
# jnb → 检查 CF(移位后 bit_buf 的最高位变成了 CF)
if bit_buf < 0x80: # CF = 0 → 字面量
if src_pos >= len(data):
break
output.append(data[src_pos])
src_pos += 1
else: # CF = 1 → 回引
bit_buf &= 0x7F # 清掉刚被移出去的标志位
if src_pos + 1 >= len(data):
break
pair = data[src_pos] | (data[src_pos + 1] << 8)
src_pos += 2
offset = (pair & 0x7FF) + 1
length = ((pair >> 11) & 0x1F) + 3
for i in range(length):
output.append(output[-offset])
return bytes(output[:out_size])
# 使用
with open("encrypted_text.bin", "rb") as f:
data = f.read()
decompressed = lzss_decompress(data, 4608)
with open("decompressed_text.bin", "wb") as f:
f.write(decompressed)
3.3 验证解压是否正确
第一个验证:entropy。加密数据 entropy ≈ 7.91,解压后应该掉到 5.x:
from collections import Counter
import math
counts = Counter(decompressed)
entropy = -sum((c/len(decompressed)) * math.log2(c/len(decompressed))
for c in counts.values())
print(f"entropy: {entropy:.2f}")
# 应该输出 5.2x —— 正常 x64 机器码的水平
第二个验证:文件头。解出来的前几个字节应该是 Delphi x64 的函数入口(如果你样本是 Delphi 写的):
print(decompressed[:16].hex())
# 应该看到类似: 554889e54881ec...(push rbp; mov rbp,rsp; sub rsp,xxx)
第三个验证:扔进 IDA 或 Ghidra。File → Load File → 选 decompressed_text.bin → 格式选 "Binary" → 加载地址填原始 Image Base + .text 的 RVA → Processor 选 x86-64。如果能看到正确的函数边界和交叉引用,解压就是对的。
如果 IDA 里全是 DCB(未定义数据):解压算法 bug 或者加载地址填错了。检查 Image Base(DIE 里的 "Image Base" 字段)和 .text 段 RVA(DIE 的 "Sections" 标签里找 .text 或 .boot)。
常见踩坑
Q: DIE 说是 Themida,但 EP 处不是 mov cl, 0x80?
可能是 Themida 2.x(老版本)或者 x86 版本。x86 的寄存器是 dl 不是 cl,逻辑一样。把反汇编里所有 cl 换成 dl 就能对上。
Q: x64dbg 怎么都断不到 EP?
检查 ScyllaHide 是否启用了 Themida 预设。如果启用了还是断不到,试试在 EP 前面多设几个断点(EP-0x100, EP-0x50 各设一个硬件执行断点)。
Q: 解压出来 entropy 还是 7.x?
算法没写对。最常犯的错:adc cl, cl 之后忘记判断 CF。CF=0 和 CF=1 走的分支不同,但 Python 里 bit_buf 就是一个整数,CF 要自己模拟。
Q: IDA 认出了函数但 xrefs 是乱的?
因为 IAT 没修。LZSS 解压只恢复了代码段,导入表(IAT)还是 Themida 的跳转表(运行时动态填充的)。做静态分析够用了(找函数入口、看调用链、搜字符串),但要做动态分析还得修 IAT。
总结
你拿走了三样东西:
- 一套方法:DIE 确认壳 → 010 Editor 看 EP → x64dbg 断 EP → 识别解压模式 → Python 离线解 → IDA 分析
- 一段 Python 代码:60 行的 Themida 3.x LZSS 解压器,改改偏移量就能用在别的样本上
- 一个判断标准:entropy 7.91 → 5.23 是解压正确的硬指标;IDA 里看到函数边界是软指标
不需要跑壳、不需要过反调试、不需要联网。解压这步是完全离线的。