前言
Crackme是一种专门设计的小程序,用于测试和练习程序设计人员的逆向工程技能。
笔者是小白,解题中可能有不到位的地方。解题的过程也会尽量把知识点细致的去介绍。
PE 文件是什么
打开后是两个 .exe 文件。.exe 和 .dll 都是 PE 格式(Portable Executable,可移植可执行文件)。
┌──────────────────────────────────┐
│ DOS 头(历史遗留,占位用) │
├──────────────────────────────────┤
│ PE 头 ← 记录了"有几节、入口在哪" │
├──────────────────────────────────┤
│ 节表 ← 每节的目录:名字/地址/大小│
├──────────────────────────────────┤
│ .text ← 代码(CPU 执行的指令) │
│ .rdata ← 只读数据(常量、字符串) │
│ .data ← 可读写数据(全局变量) │
│ .pdata ← 异常处理信息 │
│ .reloc ← 重定位信息 │
└──────────────────────────────────┘
文件的格式就像这样一个分格子的收纳盒。 节(Section) 就是上面这些"格子"。本题里最重要的两个:
.text= 代码区。程序的所有逻辑都在这里。.rdata= 只读数据区。硬编码的密钥、密文通常就放在这里,因为它不会被程序修改。
这里我们得审查一下题目了,提交解密的敏感信息。关键词是「解密」,提示我们答案是被加密过的,需要先找到密钥再解出来。那么,既然密钥是写死的常量,它八成在 .rdata。
RVA 和文件偏移
同一个字节在 PE 文件里有两个不同的地址:
| 名称 | 含义 | 单位 |
|---|---|---|
| 文件偏移(File Offset) | 这个字节在磁盘文件里排第几个 | 从文件开头数 |
| RVA(相对虚拟地址) | 这个字节加载到内存后在模块里的位置 | 从模块开头数 |
为什么不一样? 因为节与节之间需要内存对齐(通常是 0x1000 = 4096 字节的整数倍)。文件里为了省空间是紧凑排列的,加载到内存会被"撑开",于是两边的偏移就对不上了。 换算公式(在两个地址都已知的情况下):
文件偏移 = RVA - 节的 VirtualAddress + 节的 PointerToRawData
举个例子,本题的 .rdata 节:
节的 VirtualAddress = 0x27000 (加载后从 RVA 0x27000 开始)
节的 PointerToRawData = 0x25800 (在文件里从偏移 0x25800 开始)
那么文件偏移 0x25be0 对应的 RVA 是:
RVA = 0x25be0 - 0x25800 + 0x27000 = 0x273e0
💡 实用建议:用
pefile换算:pe.get_rva_from_offset(0x25be0) # 文件偏移 -> RVA pe.get_offset_from_rva(0x273e0) # RVA -> 文件偏移
一般反汇编器(如 IDA、objdump)显示的是 RVA 或虚拟地址。
导入表
程序不可能自己实现所有功能——它要调用操作系统提供的函数。导入表(Import Table) 就是一张清单,写明:
"我要用
bcrypt.dll里的BCryptEncrypt、WS2_32.dll里的send……"
为什么导入表对逆向超级有用?
因为函数名不会骗人。即使代码被混淆得面目全非,导入表里的函数名还是清清楚楚的。看一眼就知道程序会用什么能力:
| 看到这个 DLL | 说明程序会…… |
|---|---|
bcrypt.dll / crypt32.dll | 做加解密 |
WS2_32.dll / wininet.dll | 联网通信 |
kernel32.dll | 文件、内存、进程等基础操作 |
user32.dll | 弹窗、窗口界面 |
advapi32.dll / kernel32 注册表 API | 读写注册表 |
这是逆向的"第一印象"。
导入表里只有函数名,没有地址。真正的函数地址是程序启动时由 Windows 加载器填进 IAT(Import Address Table,导入地址表)的。代码调用时写的是 call [IAT里的某一项]——所以你在反汇编里看到 call QWORD PTR [rip+0x1234] 这种奇怪写法,不要慌,那是在调用一个导入函数。
对称加密
对称加密 = 一把钥匙开一把锁
密钥(Key) 就是一串字节,加密用它,解密也用它。
- DES:老算法,密钥 8 字节,实际有效强度只有 56 位(已经不安全了,但老程序里常见)。
- AES:现代标准,密钥可以是 16 / 24 / 32 字节。16 字节的叫 AES-128。
分组加密 = 把数据切块
DES 和 AES 都是分组密码:它们一次只能处理固定大小的一块。
- DES 的块大小 = 8 字节
- AES 的块大小 = 16 字节
数据长度不是块的整数倍怎么办?见下面的"填充"。
工作模式:ECB 和 CBC
ECB(电子密码本):每块独立加密。
缺点:相同的明文块 → 相同的密文块。像"逐字盖章",图案规律会暴露。比如加密一张纯色图片,ECB 会保留轮廓。
CBC(密码块链接):最常用的模式,本题两个算法都用它。规则是:
加密第 1 块:密文1 = E(明文1 ⊕ IV)
加密第 2 块:密文2 = E(明文2 ⊕ 密文1)
加密第 3 块:密文3 = E(明文3 ⊕ 密文2)
...以此类推
(⊕ 是异或运算;E() 是加密函数)
每个明文块先和前一块的结果异或,再加密。 这样相同明文块会得到不同密文,规律被打破了。
IV(Initialization Vector,初始向量)就是第 1 块用的"前一块"——因为第 1 块没有前一块,需要一个约定的初始值。IV 不是密钥,可以公开,但通信双方必须用同一个,否则解不出来。
明文: [块1] [块2] [块3]
│ │ │
IV ──⊕──┘ │ │
│ │ │
▼ ▼ ▼
AES加密 AES加密 AES加密
│ │ │
▼ ▼ ▼
密文1 ──⊕───┘ │
│ │
└──⊕───────┘
▼ ▼ ▼
[密文1] [密文2] [密文3]
填充(Padding)
AES 块是 16 字节,但你的数据可能是 4 字节("AAAA")。长度不够一块怎么办?
标准做法叫 PKCS#7 填充:缺几个字节就补几个"几"。
举例,AES 加密 "AAAA"(4 字节):
原始: 41 41 41 41 (4 字节)
缺 12 字节,所以补 12 个 0x0C(0x0C = 12):
填充: 41 41 41 41 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C (16 字节)
再举例,数据正好 16 字节呢?
原始: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 (16 字节)
一个字节都不缺?照样补一整块 0x10:
填充: 41×16 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 (32 字节)
这个规则很反直觉但非常重要:即使数据长度正好是块的整数倍,也必须额外补一整块。
解题
题目描述
crackme!(提交解密的敏感信息)
题目给了两个exe文件,都是64位。
这是 MSVC 编译的原生 64 位 C++ 控制台程序,不是 .NET,没有加壳。
先尝试运行。
这里可以发现,要求输入一个
serial,也就是一个序列号或者口令的东西。
打印时带了 [CLIENT] 前缀,说明作者写了很多调试输出。
要连本机 5566 端口,同时显示服务器没开。
这里开服务器后再次运行:
输出:
=== CLIENT output (serial='AAAA') ===
Enter serial: [CLIENT] Preparing connection to 127.0.0.1:5566
[CLIENT] Connected to server.
[CLIENT] Plain serial: AAAA
[CLIENT] Plain serial length: 4
[CLIENT] DES ciphertext (8 bytes): 53 B8 46 31 F8 E4 BF 1F
[CLIENT] Packet header (4 bytes): 00 00 00 08
[CLIENT] Sent DES-encrypted payload to server.
[CLIENT] Received response header (4 bytes): 00 00 00 20
[CLIENT] AES response length: 32
[CLIENT] AES response ciphertext (32 bytes): 8B 5E F8 CC 97 AC 6D 61 B4 3B 3C A5 2A C1 FE 03 2E 35 28 42 15 C6 48 EF 8C 7C BA B0 55 0B FC 30
[CLIENT] AES response plaintext: FAIL: wrong flag
Server: FAIL: wrong flag
=== SERVER output ===
Server listening on 127.0.0.1:5566
[SERVER] Client connected.
[SERVER] Received request header (4 bytes): 00 00 00 08
[SERVER] DES request length: 8
[SERVER] DES request ciphertext (8 bytes): 53 B8 46 31 F8 E4 BF 1F
[SERVER] DES request plaintext: AAAA
[SERVER] Client serial plaintext: AAAA
[SERVER] Client serial AES ciphertext (16 bytes): 1B 94 FA EE 52 5F AF B5 9E 83 84 3F 7A 1D 3D 7B
[SERVER] Expected AES ciphertext (48 bytes): FC 8F 2B 91 3A 35 B5 E8 70 EB 18 63 79 F4 CB A0 C4 B0 CC 19 8D 3F A6 39 C5 4E E2 0D 1A 8E 3E 6C 79 16 BD 4C A3 BE 71 4B 95 FC A7 CD 77 73 A2 56
[SERVER] Ciphertext comparison result: MISMATCH
[SERVER] Response plaintext: FAIL: wrong flag
[SERVER] AES response ciphertext (32 bytes): 8B 5E F8 CC 97 AC 6D 61 B4 3B 3C A5 2A C1 FE 03 2E 35 28 42 15 C6 48 EF 8C 7C BA B0 55 0B FC 30
[SERVER] Response header (4 bytes): 00 00 00 20
[SERVER] Encrypted response sent.
这里有了完整的运行逻辑。
步骤 1:client 读取 serital 并加密
[CLIENT] Plain serial: AAAA ← 明文是 4 个字符
[CLIENT] Plain serial length: 4
[CLIENT] DES ciphertext (8 bytes): 53 B8 46 31 F8 E4 BF 1F
分析:
- 明文
"AAAA"= 4 字节 - 密文 = 8 字节
DES 块大小 = 8 字节
明文 4 字节 → 缺 4 字节 → 补 4 个 0x04(PKCS#7 规则:缺几个补几个"几")
实际加密的数据 = 41 41 41 41 04 04 04 04 (8 字节 = 1 块)
加密结果 = 53 B8 46 31 F8 E4 BF 1F (8 字节 = 1 块)
步骤 2:client 发包
[CLIENT] Packet header (4 bytes): 00 00 00 08
[CLIENT] Sent DES-encrypted payload to server.
header 是一个 4 字节的长度字段,值是 00 00 00 08。
00 00 00 08 按大端序读 = 8,正好等于后面密文的长度(8 字节)。
所以协议格式是:
┌────────────────┬──────────────────────┐
│ 4 字节大端长度 │ 该长度的数据(密文) │
└────────────────┴──────────────────────┘
这是个非常经典的 "Length-Prefixed Message"(长度前缀消息)格式。为什么需要长度前缀?因为 TCP 是字节流,没有消息边界——你不告诉对方"我发了 8 个字节",对方就不知道该读到哪停。
步骤 3:server 解密并验证
[SERVER] Received request header (4 bytes): 00 00 00 08
[SERVER] DES request length: 8
[SERVER] DES request ciphertext (8 bytes): 53 B8 46 31 F8 E4 BF 1F ← 同 client ✓
[SERVER] DES request plaintext: AAAA ← 解密回来了 ✓
[SERVER] Client serial plaintext: AAAA
server 用 DES 解密,成功还原出 "AAAA"。说明两边持有的 DES 密钥完全相同。
步骤 4:核心!server 的验证逻辑
[SERVER] Client serial AES ciphertext (16 bytes): 1B 94 FA EE 52 5F AF B5 9E 83 84 3F 7A 1D 3D 7B
[SERVER] Expected AES ciphertext (48 bytes): FC 8F 2B 91 3A 35 B5 E8 ... 77 73 A2 56
[SERVER] Ciphertext comparison result: MISMATCH
这是整个题目的核心逻辑,逐行分析:
① server 把收到的 serial 用 AES 加密
明文 "AAAA" (4 字节) → AES-CBC 加密 → 16 字节
(4 字节 + 12 字节填充 = 16 字节 = 1 个 AES 块 ✓)
② 和一个 48 字节的"期望密文"比较
Expected AES ciphertext (48 bytes): FC 8F 2B 91 ...
这个 48 字节的值是硬编码在程序里的(因为题目固定,正确 serial 也固定)。
③ 比较结果:MISMATCH(不匹配)
题目描述是「提交解密的敏感信息」,说明这里应该是要拿到AES密钥的。
步骤 5:响应也加密
[SERVER] Response plaintext: FAIL: wrong flag
[SERVER] AES response ciphertext (32 bytes): 8B 5E F8 CC ...
[SERVER] Response header (4 bytes): 00 00 00 20 ← 0x20 = 32 ✓
client 收到后解密:
[CLIENT] Received response header (4 bytes): 00 00 00 20
[CLIENT] AES response length: 32
[CLIENT] AES response plaintext: FAIL: wrong flag
双向都加密:请求用 DES,响应用 AES。
那么,协议大致为:
client server (127.0.0.1:5566)
──────────────────────────────────────────────────────────────────────────────
1. 读取用户输入的 serial(明文)
2. DES-CBC 加密 → 密文
3. 发包: [4B大端长度][DES密文]
──────────────────────────────────────►
4. 收包,按长度读取
5. DES 解密 → serial 明文
6. AES-CBC 加密 serial
7. 与硬编码 48B 期望密文比对
一致 → "OK: serial accepted"
不一致 → "FAIL: wrong flag"
8. 响应 AES 加密
9. 回包:[4B长度][AES密文]
◄──────────────────────────────────────
10. 收到响应
11. AES 解密,打印结果
打开imports先查导入表:
给相应加解密的函数打上断点,进行动态测试:
在 BCryptGenerateSymmetricKey 断点处停下了,并且抓到了 AES 密钥生成现场。
找到AES Key与AES IV:
同时DES Key与AES IV:
这里确认的方式是:
00025be0 C3 9D AB D7 E9 90 B8 C5 ← 8 字节
00025be8 8F B2 CD E1 93 A4 FE 87 ← 8 字节
00025bf0 6E C1 A2 37 58 9F 03 D4 B5 E7 0C 92 FA 41 8B 66 ← 16 字节
00025c00 91 AD F3 87 C9 B4 8A EE D2 A1 9F C7 B3 D9 85 E4 ← 16 字节
00025c10 FC 8F 2B 91 ... (48 字节) ← 密文
| 偏移 | 长度 | 推测 | 依据 |
|---|---|---|---|
0x25be0 | 8 | DES IV | DES 的 IV 是 8 字节 |
0x25be8 | 8 | DES KEY | DES 的密钥是 8 字节 |
0x25bf0 | 16 | AES IV | AES 的 IV 是 16 字节 |
0x25c00 | 16 | AES KEY | AES-128 的密钥是 16 字节 |
0x25c10 | 48 | 期望密文 | 已确认 |
下面的 FC 8F 2B 91 ... 就是 Expected AES ciphertext (48 bytes) 。
因为是复现,所以用智能体写了份高级的全流程脚本:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
crackme 独立解题脚本(只依赖 pycryptodome)
用法:
python solve_standalone.py # 从 server.exe 现场提取密钥并解密
python solve_standalone.py --verify # 额外用已知明文/密文对校验密钥
原理:
server.exe 的 .rdata 段 (RVA 0x273e0 / 文件偏移 0x25be0) 处紧凑排布着:
0x25be0 DES IV (8 B)
0x25be8 DES KEY (8 B)
0x25bf0 AES IV (16 B)
0x25c00 AES KEY (16 B)
0x25c10 Expected CT (48 B) <-- AES-CBC(正确 serial) 的结果
用 AES 密钥解密 Expected CT 即得敏感信息(serial/flag)。
"""
import os
import sys
# Windows 控制台默认 GBK,统一改 UTF-8 避免中文乱码
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
try:
from Crypto.Cipher import AES, DES
from Crypto.Util.Padding import pad, unpad
except ImportError:
sys.exit("缺少依赖,请先执行: python -m pip install --target ./pylibs pycryptodome")
BASE = os.path.dirname(os.path.abspath(__file__))
# .rdata 中密钥块的起始文件偏移
BLOB_OFF = 0x25BE0
OFF_DES_IV = BLOB_OFF + 0x00
OFF_DES_KEY = BLOB_OFF + 0x08
OFF_AES_IV = BLOB_OFF + 0x10
OFF_AES_KEY = BLOB_OFF + 0x20
OFF_EXPECTED_CT = BLOB_OFF + 0x30
LEN_EXPECTED_CT = 48
def main():
path = os.path.join(BASE, 'extracted', 'server.exe')
with open(path, 'rb') as f:
blob = f.read()
des_iv = blob[OFF_DES_IV:OFF_DES_IV + 8]
des_key = blob[OFF_DES_KEY:OFF_DES_KEY + 8]
aes_iv = blob[OFF_AES_IV:OFF_AES_IV + 16]
aes_key = blob[OFF_AES_KEY:OFF_AES_KEY + 16]
expected_ct = blob[OFF_EXPECTED_CT:OFF_EXPECTED_CT + LEN_EXPECTED_CT]
print("=" * 64)
print("从 server.exe 提取到的密钥材料")
print("=" * 64)
print(" DES IV @ %08x : %s" % (OFF_DES_IV, des_iv.hex().upper()))
print(" DES KEY @ %08x : %s" % (OFF_DES_KEY, des_key.hex().upper()))
print(" AES IV @ %08x : %s" % (OFF_AES_IV, aes_iv.hex().upper()))
print(" AES KEY @ %08x : %s" % (OFF_AES_KEY, aes_key.hex().upper()))
print(" EXPECTED @ %08x : %s" % (OFF_EXPECTED_CT, expected_ct.hex().upper()))
print()
print("=" * 64)
print("AES-128-CBC 解密 Expected CT")
print("=" * 64)
plain = AES.new(aes_key, AES.MODE_CBC, aes_iv).decrypt(expected_ct)
print(" 原始明文 : %r" % plain)
try:
flag = unpad(plain, 16)
print(" 去填充后 : %r" % flag)
except ValueError:
flag = plain
print(" (PKCS#7 去填充失败,输出原始明文)")
print()
print("*" * 64)
print("FLAG: %s" % flag.decode('ascii', 'replace'))
print("*" * 64)
if '--verify' in sys.argv:
print()
print("=" * 64)
print("密钥正确性校验(已知明文/密文对)")
print("=" * 64)
checks = [
("AES-CBC('AAAA')",
AES.new(aes_key, AES.MODE_CBC, aes_iv).encrypt(pad(b'AAAA', 16)),
'1B94FAEE525FAFB59E83843F7A1D3D7B'),
("DES-CBC('AAAA')",
DES.new(des_key, DES.MODE_CBC, des_iv).encrypt(pad(b'AAAA', 8)),
'53B84631F8E4BF1F'),
("AES-CBC('FAIL: wrong flag')",
AES.new(aes_key, AES.MODE_CBC, aes_iv).encrypt(pad(b'FAIL: wrong flag', 16)),
'8B5EF8CC97AC6D61B43B3CA52AC1FE032E35284215C648EF8C7CBAB0550BFC30'),
]
allok = True
for label, computed, expected_hex in checks:
ok = computed.hex().upper() == expected_hex
allok &= ok
print(" [%s] %s" % ("OK" if ok else "!!", label))
print(" 计算: %s" % computed.hex().upper())
print(" 实际: %s" % expected_hex)
print()
print(" 全部通过: %s" % allok)
if __name__ == '__main__':
main()
当然,利用AES密钥就可以进行解密:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
k = bytes.fromhex("91adf387c9b48aeed2a19fc7b3d985e4")
v = bytes.fromhex("6ec1a237589f03d4b5e70c92fa418b66")
m_bytes = bytes.fromhex("fc8f2b913a35b5e870eb186379f4cba0c4b0cc198d3fa639c54ee20d1a8e3e6c7916bd4ca3be714b95fca7cd7773a256")
cipher = AES.new(k,AES.MODE_CBC,v)
n_bytes = cipher.decrypt(m_bytes)
print(unpad(n_bytes,16).decode())
得到:
A1B2C3D4E5F60718293A4B5C6D7E8F90
验证: