2026软件系统安全赛复赛creckme

0 阅读15分钟

前言

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 里的 BCryptEncryptWS2_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 200x20 = 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 878 字节
00025bf0  6E C1 A2 37 58 9F 03 D4 B5 E7 0C 92 FA 41 8B 6616 字节
00025c00  91 AD F3 87 C9 B4 8A EE D2 A1 9F C7 B3 D9 85 E4    ← 16 字节
00025c10  FC 8F 2B 91 ... (48 字节)                          ← 密文
偏移长度推测依据
0x25be08DES IVDES 的 IV 是 8 字节
0x25be88DES KEYDES 的密钥是 8 字节
0x25bf016AES IVAES 的 IV 是 16 字节
0x25c0016AES KEYAES-128 的密钥是 16 字节
0x25c1048期望密文已确认

下面的 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

验证: 在这里插入图片描述 在这里插入图片描述