1. 样本与分析目标
| PE 类型 | PE32+,x86-64,Windows GUI |
| 首选 ImageBase | 0x00400000 |
| 加壳入口 RVA / VA | 0x1000 / 0x00401000 |
| 外层 PE 节 | .text、.data |
| 外层 .data 文件偏移 | 0xF000 |
在加壳 EXE 中,直接搜索密码的 ASCII/UTF-16LE 连续编码都没有命中,两个中文提示的 UTF-16LE 编码也没有直接命中。
为了定位按钮事件,可先还原外层加载器处理的原程序节数据。外层 .data 内的相关结构是:
.data + 0x9C:原程序节数量,值为 11
.data + 0xE1:原程序节描述表,每项 0x2C 字节
每条记录可以按 <8s9I 读取,其中与还原相关的是节名、原始 VirtualSize、RVA、存储长度、存储偏移、属性和压缩长度。样本中的解压例程位于原生 VA 0x0040343A,按高位优先读取 bit,处理字面量和回溯复制。inspect_xfcrackme.py 中的 Bits 与 decompress 是该例程的 Python 还原。
将各节写回对应 RVA 后,再依据字符串清单处理 UTF-16 字符串。清单偏移位于 .data + 0x44,初始字符串密钥位于 .data + 0x48。清单中的字符串逐个重新初始化密钥,每个 UTF-16 码元的低字节按下式处理:
memory[string_rva + j] ^= (key >> 24) & 0xFF # j = 0, 2, 4, ...
key = (key * 0x9E3779B1 + 0x12345678) & 0xFFFFFFFF
完成这些数据还原后,可以识别 TForm1、edtkey、btn1Click,并在方法表中定位到:
TForm1.btn1Click:VA 0x006D9960
但是原按钮事件中的保护区域:
[0x006D997A, 0x006D99CF)
仍然全部是 CC 字节。另一个保护区域 [0x006D9A9D, 0x006D9AF7) 也是如此。这说明本样本的关键原生代码已被移走,不能把这些 INT3 填充当成待反编译的实际校验逻辑。
外层解压只恢复了原程序的部分代码和数据布局;密码比较必须继续到 VM 字节码中找。
为求密码,最终求解脚本不必重复整个外层还原:它可以直接读取仍保存在外层 .data 中的 VM 元数据和字节码。
3. 从加载器定位 VM 上下文与字节码
在加载器中,原生 VA 0x00401C66 附近读取 VM 区域数量,0x00401C73 附近读取区域表偏移。对应到外层 .data:
.data + 0x50:VM 区域数量 = 2
.data + 0x54:VM 区域表偏移 = 0x155CA8
每条 VM 区域记录为 16 字节,四个字段按小端 DWORD 存储:
struct VMRegion {
uint32_t patch_rva; // 原程序中被替换区域的 RVA
uint32_t context_offset; // 相对外层 .data 的上下文偏移
uint32_t bytecode_entry; // 在共享字节码中的入口偏移
uint32_t return_va; // 离开该区域后的原生返回地址
};
实际解析结果:
| 区域 |
patch RVA |
context:相对 .data |
字节码入口 |
return VA |
| 0 |
0x2D997A |
0x14E693 |
0x0000 |
0x006D99CF |
| 1 |
0x2D9A9D |
0x155693 |
0x01FD |
0x006D9AF7 |
加载器使用这些字段构造跳板,带入上下文和字节码入口,再转入 VM。这也把区域 0 与上一步定位到的按钮事件对应起来。
每个上下文中与静态提取相关的字段为:
context + 0x00:uint64,相对于该 context 的字节码偏移
context + 0x10:uint32,文件中初始编码密钥
context + 0x14:uint32,字节码长度
区域 0 中上述值分别为 0x7220、0xECE00DCD、0x3F5,所以:
字节码文件偏移
= 外层 .data 文件偏移 + context 内偏移 + 字节码相对偏移
= 0xF000 + 0x14E693 + 0x7220
= 0x1648B3
区域 1 算得:
0xF000 + 0x155693 + 0x220 = 0x1648B3
两个区域共享同一份 0x3F5,即 1,013 字节的字节码,只是入口不同。
4. 还原字节码的编码规则
VM 取指循环位于原生 VA 0x00403EB8。关键指令为:
mov r10d, esi
sub r10d, r15d
imul r10d, r10d, 0x9E3779B1
add r10d, r12d
mov al, byte ptr [rsi]
xor al, r10b
inc rsi
这里,RSI 是当前取字节的位置,R15 是整个共享字节码的基址,R12D 是当前编码密钥。
令 i 为从共享字节码起点计算的偏移,有:
plain[i] = cipher[i] XOR ((i * 0x9E3779B1 + key) & 0xFF)
对原始文件,使用上下文中的初始密钥 0xECE00DCD:
raw = exe[0x1648B3:0x1648B3 + 0x3F5]
plain = bytes(
value ^ ((i * 0x9E3779B1 + 0xECE00DCD) & 0xFF)
for i, value in enumerate(raw)
)
注意必须用整个共享流的索引。分析区域 1 时,不能从入口 0x1FD 重新把索引置零。
为什么运行时换密钥不妨碍静态提取
0x00403B95 和 0x00403C17 一带还包含换钥逻辑。它在切换密钥的同时,将字节码按旧、新两个密钥流的异或差重新编码:
new_cipher[i] = old_cipher[i] XOR stream(old_key, i) XOR stream(new_key, i)
因此正常换钥前后的明文相同。静态读取原始文件时,直接用文件保存的初始密钥解码即可,不需要猜测运行时的时间戳或栈地址。
取指循环旁还可以看到计时检查及异常情况下改变密钥的逻辑;这里没有模拟完整的运行时保护环境。
5. 确认 opcode 的真实语义
仅解出字节码还不足以证明密码,必须知道相关 opcode 实际做了什么。
5.1 先还原原生 handler
部分 handler 自身也做了字节级 XOR。解密字节位于文件偏移 0xE68D,值为 0x6E。样本初始化及首次分发逻辑分别处理以下两个文件区间:
[0x3F40, 0x5DD2)
[0x603F, 0xD519)
可在分析副本上静态还原:
stub = bytearray(exe[:0xF000])
key = exe[0xE68D]
for start, size in [(0x3F40, 0x1E92), (0x603F, 0x74DA)]:
for offset in range(start, start + size):
stub[offset] ^= key
这不是修改目标程序的成功条件,只是把 handler 的原始机器指令恢复到分析副本中。
5.2 绕过随机表项顺序,直接恢复映射
运行时 opcode 映射表和 handler 分发表在文件中尚未初始化,直接读取会得到零。初始化逻辑会随机重排槽位,但 opcode 与对应 handler 一起移动。
文件中保留了静态的配对表,因此可以直接恢复逻辑关系:
handlers = {}
for i in range(0xB9):
opcode = stub[0xD87F + i] ^ 0x1D
relative = struct.unpack_from('<q', stub, 0xD938 + 8*i)[0]
handlers[opcode] = 0x40DF00 + relative
其原因是:若初始化执行 map[opcode] = permutation[i],同时令 table[permutation[i]] = handler[i],则最终 table[map[opcode]] 仍指向原本对应的 handler。无需复现随机排列本身。
5.3 本题需要的指令子集
下表的 R0、R1 等是虚拟寄存器,不是直接把编号当作真实 CPU 寄存器名。
| opcode |
handler VA |
与本题相关的语义 |
01 / 02 |
0x403F40 / 0x403FE1 |
虚拟栈 PUSH / POP |
03 |
0x404082 |
寄存器复制 |
04 |
0x40415C |
读取 imm32,符号扩展后写寄存器 |
0E |
0x404B1F |
32 位加法,更新标志 |
10 |
0x404C6E |
32 位按位 AND,更新标志 |
20 / 21 / 22 |
0x40B751 / 0x40B7DD / 0x40B873 |
JMP / JE / JNE |
37 |
0x40C997 |
从地址中读取无符号 16 位值 |
39 |
0x404E06 |
寄存器低 32 位与 imm32 比较,保存标志 |
例如 37 的 handler 中可以看到:
xor edx, edx
mov dx, word ptr [r14]
说明它读取两个字节并零扩展,并非读取单个 ASCII 字节。
还要区分两种字节序:PE 元数据是小端;该 VM 的 32 位立即数和跳转偏移使用高字节在前的编码;被比较的输入字符串内存按 UTF-16LE 读取。不能把三个层面的数据全部用一种端序解析。
6. 从逐字符比较中提取密码
区域 0 的比较子程序位于字节码区间 [0x0032, 0x01B0)。开头保存寄存器,并准备每次前进两个字节:
0032: PUSH R9
0034: PUSH R10
0036: PUSH R8
0038: MOV R9, 2
003E: MOV R8, R1
0041: CMP32 R8, 0
0047: JE 0x01A4
R1 是比较时的输入指针;这里先检查其低 32 位是否为零。后面的核心结构不断重复:
004C: LOAD_U16 R8, [R1]
0053: CMP32 R8, 0x74 ; 't'
0059: JNE 0x01A4
005E: ADD32 R1, R9 ; 前进两个字节
0061: LOAD_U16 R8, [R1]
0068: CMP32 R8, 0x74 ; 't'
006E: JNE 0x01A4
0073: ADD32 R1, R9
0076: LOAD_U16 R8, [R1]
007D: CMP32 R8, 0x7A ; 'z'
0083: JNE 0x01A4
第一组比较的原始字节是:
37 08 01 00 00 00 00 LOAD_U16 R8, [R1 + 0]
39 08 00 00 00 74 CMP32 R8, 0x74
22 00 00 01 A4 JNE 0x1A4
0E 01 09 ADD32 R1, R9
将每组 CMP32 中的字符立即数顺序提取,得到:
十六进制:74 74 7A 67 6C 69 66 65 31 32 33 2E 63 6F 6D 00
字符: t t z g l i f e 1 2 3 . c o m \0
因此密码是 ttzglife123.com。不是暴力枚举,而是读取密码验证逻辑中的约束。
不能漏掉结束符
最后一组比较位于 0x0187:
0187: LOAD_U16 R8, [R1]
018E: CMP32 R8, 0
0194: JNE 0x01A4
这里要求第 16 个 UTF-16 码元为零。对普通文本输入,这对应前面 15 个字符之后立即结束,因此在末尾追加 x 不通过。
严格地说,检查的是 NUL 码元,不是读取 Delphi 字符串的长度字段;不能据此把比较器描述成对任意含嵌入 NUL 的内存字符串都进行长度元数据检查。
比较器返回值
0199: MOV R0, 0 ; 所有比较均通过
019F: JMP 0x01AA
01A4: MOV R0, 1 ; 任一比较失败
01AA: POP R8
01AC: POP R10
01AE: POP R9
所以本子程序是 R0 = 0 表示相等,R0 = 1 表示不等。
第二个区域中,比较子程序位于 [0x0204, 0x0382),首个字符比较位于 0x021E,共同失败分支为 0x0376。它提取出的 15 个字符及最后的零码元与区域 0 完全一致。
7. 把比较结果与成功提示连接起来
区域 0 在比較器之后执行以下关键逻辑;下面省略不影响本段分支说明的其他指令:
01B0: AND32 R0, R0
01B3: JNE 0x01DD
01BB: MOV R1, 0x006D9A40
CALL_NATIVE 0x00621340
JMP 0x01FC
01DD: MOV R1, 0x006D9A58
CALL_NATIVE 0x00621340
这里实际 opcode 是 AND32。对本题的 R0 = 0/1,它保留数值并设置零标志,达到根据比较器返回值分支的目的。
在静态还原的原程序数据中,两个指针对应:
0x006D9A40:34 78 E3 89 10 62 9F 52 00 00
破解成功
0x006D9A58:0C 54 D7 5F CD 4E 00 97 AA 52 9B 52 00 00
同志仍需努力
由此形成完整证据链:
字符与结束符全部匹配 → R0 = 0 → 选择“破解成功”字符串
任一位置不匹配 → R0 = 1 → 选择“同志仍需努力”字符串
这比“在某处发现一个像密码的字符串”多了一步控制流验证。
总结
本题的关键并非复杂的密码学运算,而是保护器把一个简单字符串比较变成了编码的虚拟机指令。有效路径是:
加载器元数据 → VM 上下文 → 字节码解码 → handler 语义
→ 逐字符约束 → 结束符检查 → 成功分支对应 → 局部解释验证
只恢复解题所需的指令和控制流,就足以得到密码;无需先把整个保护器全部反编译,也无需修改成功条件。
最终答案:ttzglife123.com。