CTF WRITEUP / CRYPTO / AES-CBC
Secret_Server:AES-CBC 字节翻转攻击完整实录
一道经典的 CTF Crypto 题目,通过固定 IV 和未校验的 unpad 函数, 利用 CBC 字节翻转攻击逐字节爆破出完整 flag。
题目来源:GFSJ0308 / HITCON 2017 CTF Quals | 难度:中级 | 方向:Crypto
一、题目概述
题目给了一个 TCP 服务(nc 52.193.157.19 9999),附带 Python 服务端源码。 服务使用 AES-128-CBC 加密所有通信内容, 客户端发送 base64 编码的密文,服务端解密后根据明文内容执行不同命令。
关键命令包括 get-flag(返回 flag)、get-md5(返回 MD5 值)等。 题目提示 "AES is unbreakable. Right?",暗示攻击点不在 AES 算法本身, 而在其使用方式上。
服务端源码核心逻辑
```
key = os.urandom(16) # 随机生成,攻击者不可知 iv = '2jpmLoSsOlQrqyqE' # 固定 IV,所有人相同! def pad(msg): pad_length = 16 - len(msg) % 16 return msg + chr(pad_length) * pad_length def unpad(msg): return msg[:-ord(msg[-1])] # 没有校验 pad_length 的合法性! def encrypt(iv, msg): msg = pad(msg) cipher = AES.new(key, AES.MODE_CBC, iv) return cipher.encrypt(msg) def decrypt(iv, msg): cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(msg) return unpad(decrypted) # 危险的 unpad
## 二、漏洞识别
### 2.1 固定 IV
服务端使用固定的 IV `'2jpmLoSsOlQrqyqE'` 加密所有响应。 在 CBC 模式中,IV 应该每次加密都不同且不可预测。 固定 IV 意味着:相同的明文总是产生相同的密文, 这为后续的字节翻转攻击提供了基础。
### 2.2 unpad 函数不校验合法性
标准的 PKCS#7 解填充需要校验:最后一个字节的值 N 必须在 1-16 范围内, 且最后 N 个字节都必须等于 N。但此题的 `unpad` 直接执行:
```
def unpad(msg): return msg[:-ord(msg[-1])]
这意味着 如果最后一个字节是 55,unpad 会直接截掉末尾 55 字节, 完全不检查被截掉的部分是否真的是 padding。 这个漏洞是攻击的核心——它允许我们通过控制最后一个块的 IV, 任意截断解密后的明文。
2.3 CBC 字节翻转漏洞
CBC 模式的解密公式为:
Pi = D(Ci) XOR Ci-1
其中 D 是 AES 块解密(与 IV 无关),Ci-1 是前一个密文块(对于第一个块则是 IV)。 如果我们知道某个密文块在原始 IV 下解密出的明文, 就可以计算出一个新的 IV,使同一密文块解密为我们想要的任意明文。
新 IV = 旧明文 XOR 新明文 XOR 旧 IV
三、攻击链总览
flowchart TD A[连接服务器] --> B[绕过 PoW] B --> C[获取 Welcome 密文] C --> D[CBC 翻转 IVWelcome!! → get-flag] D --> E[获取 flag 密文] E --> F[构造 get-md5 + unpad 截断获取 MD5flag[i] 的加密值] F --> G[暴力枚举 256 个字符] G --> H{CBC 翻转验证MD5 是否匹配?} H -->|不匹配| G H -->|匹配| I[确认该字节] I --> J{还有更多字节?} J -->|是| F J -->|否| K[输出完整 flag] 攻击流程图 ## 四、逐层攻击详解 1 CBC 字节翻转:Welcome!! → get-flag 服务器首先发送 Welcome!! 的密文。我们知道其明文和对应的 IV(固定值)。 利用 CBC 字节翻转公式,可以计算出新的 IV,使同一密文块解密为 get-flag。
```
def flipplain(oldplain, newplain, iv): return strxor(strxor(oldplain, newplain), iv) get_flag_iv = flipplain(pad(b'Welcome!!'), pad(b'get-flag'), iv_encrypt) payload = base64.b64encode(get_flag_iv + cipher_welcome)
服务器收到后解密出 `get-flag`,执行命令返回 flag 的密文。 此时我们拿到了 `cipher_flag`,但不知道解密密钥,无法直接读取。
2
get-md5 命令 + unpad 截断
关键洞察:如果能让服务器执行 `get-md5` 命令, 且 `msg[7:]` 恰好是 flag 的前 N 个字节, 服务器就会返回 `encrypt(MD5(flag[:N]))` 的密文。
构造方法:
- 用 CBC 字节翻转将 flag 密文第一块的 `"hitcon{"` 翻转为 `"get-md5"`
- 在末尾拼接一个可控块,利用 unpad 的缺陷截断到只保留 `"get-md5" + flag[:N]`
```
# 翻转第一块: hitcon{ -> get-md5 new_iv = flipplain(b'hitcon{' + b'\x00'*9, b'get-md5' + b'\x00'*9, iv_encrypt) # 控制 unpad 截断量 # 总明文长 = 16 + flag_len + 16 # 保留 keep = 7 + (i+1) 字节 # pad_val = 总明文长 - keep pad_val = 16 + flag_len + 16 - (7 + i + 1) last_iv = flipplain(pad(b'Welcome!!'), b'a'*15 + bytes([pad_val]), iv_encrypt) payload = new_iv + cipher_flag + last_iv + cipher_welcome
服务器解密后,明文末尾被截掉 pad_val 字节, 只剩下 "get-md5" + flag[:i+1]。 由于 unpad 不校验,即使 pad_val > 16(跨多个块)也能正常工作。
3
逐字节 MD5 爆破
拿到 encrypt(MD5(flag[:i+1])) 的密文后,我们需要确认第 i 个字节的具体值。 方法是暴力枚举 0-255 的每个字符,在本地计算 MD5,然后用 CBC 字节翻转验证。
```
for guess in range(256): guess_md5 = MD5.new(flag_known + bytes([guess])).digest() # 构造验证密文:将 guess_md5 翻转为 "get-time" 命令 verify_iv = flipplain(guess_md5, b'get-time\x01' + b'\x01'*7, iv_encrypt) p.sendline(base64.b64encode(verify_iv + md5_cipher)) res = p.recvline().strip() if res != cipher_notfound: # 猜对了!服务器执行了 get-time,返回时间值 flag_known += chr(guess) break
验证逻辑的核心:
服务器解密 Block 1 = D(md5_cipher) XOR verify_iv
D(md5_cipher) = MD5_digest XOR iv_encrypt
verify_iv = guess_md5 XOR target XOR iv_encrypt
明文 = MD5_digest XOR guess_md5 XOR target
如果 guess 正确 → MD5_digest = guess_md5 → 明文 = target = "get-time..."
猜错时,明文是乱码,服务器返回 `command not found`; 猜对时,明文以 `get-time` 开头,服务器返回当前时间。 通过比较响应是否与 "command not found" 相同,即可判断猜测是否正确。
## 五、关键踩坑点
### 5.1 pad_val 的计算
原始 exp 中的公式是 `pad_val = 16 + len(cipher_flag) + 16 - (7 + i + 1)`。 初学者容易犯的错误是把 `keep` 算成 `8 + (i+1)` 而不是 `7 + (i+1)`。
原因是 `"get-md5"` 只有 7 个字符,`msg[7:]` 从第 8 个字符开始取。 如果 unpad 后 msg 有 8 字节(`"get-md5X"`),`msg[7:]` = `"X"`(1 字节)。 如果错误地保留 9 字节,`msg[7:]` 就会变成 2 字节,导致 MD5 不匹配。
### 5.2 flipplain 的语义
`flipplain(oldplain, newplain, iv)` 的签名容易混淆。 在 get-flag/get-md5 步骤中,`oldplain` 是通过原始 IV 解密得到的完整明文块; 但在验证步骤中,`oldplain` 传的是 `guess_md5`(AES 块解密值 D(cipher),不是 CBC 明文)。
这是因为验证时我们发送 `verify_iv + md5_cipher`, 而 `D(md5_cipher) = MD5_digest XOR iv_encrypt`。 所以 `flipplain(guess_md5, target, iv_encrypt)` 计算的是:
verify_iv = guess_md5 XOR target XOR iv_encrypt
明文 = D(md5_cipher) XOR verify_iv = (MD5_digest XOR iv_e) XOR (guess_md5 XOR target XOR iv_e)
= MD5_digest XOR guess_md5 XOR target
如果 guess 正确,明文 = target。这个细节非常关键。
### 5.3 MD5 密文长度
`MD5.new(...).digest()` 返回 16 字节。经过 `pad` 后变成 32 字节(加上 16 字节 0x10 padding), 所以密文是 2 个块(32 字节),不是 1 个块。 验证 payload 需要发送完整的 32 字节密文,服务器会自动处理第二个块的 padding。
## 六、完整攻击脚本
```
from pwn import * import base64, string from Crypto.Hash import SHA256, MD5 def strxor(s1, s2): return bytes([a ^ b for a, b in zip(s1, s2)]) def pad(msg): pad_len = 16 - len(msg) % 16 return msg + bytes([pad_len]) * pad_len def flipplain(oldplain, newplain, iv): return strxor(strxor(oldplain, newplain), iv) def bypass_pow(p): p.recvuntil(b'SHA256(XXXX+') suffix = p.recvuntil(b')', drop=True).decode() p.recvuntil(b' == ') digest = p.recvline().strip().decode() charset = string.ascii_letters + string.digits for a in charset: for b in charset: for c in charset: for d in charset: if SHA256.new((a+b+c+d+suffix).encode()).hexdigest() == digest: p.sendline((a+b+c+d).encode()) p.recvuntil(b'Done!\n') return def attack(): p = remote('52.193.157.19', 9999) bypass_pow(p) iv_encrypt = b'2jpmLoSsOlQrqyqE' cipher_welcome = base64.b64decode(p.recvline().strip())[16:] # Step 1: get flag ciphertext get_flag_iv = flipplain(pad(b'Welcome!!'), pad(b'get-flag'), iv_encrypt) p.sendline(base64.b64encode(get_flag_iv + cipher_welcome)) cipher_flag = base64.b64decode(p.recvline().strip())[16:] flag_len = len(cipher_flag) # Step 2: get "command not found" baseline p.sendline(base64.b64encode(iv_encrypt + cipher_welcome)) cipher_notfound = p.recvline().strip() flag_known = "" for i in range(flag_len - 7): # Step 3a: get encrypt(MD5(flag[:i+1])) new_iv = flipplain(b'hitcon{' + b'\x00'*9, b'get-md5' + b'\x00'*9, iv_encrypt) pad_val = 16 + flag_len + 16 - (7 + i + 1) last_iv = flipplain(pad(b'Welcome!!'), b'a'*15 + bytes([pad_val]), iv_encrypt) payload = new_iv + cipher_flag + last_iv + cipher_welcome p.sendline(base64.b64encode(payload)) md5_cipher = base64.b64decode(p.recvline().strip())[16:] # Step 3b: brute force each byte for guess in range(256): guess_md5 = MD5.new(flag_known.encode() + bytes([guess])).digest() verify_iv = flipplain(guess_md5, b'get-time\x01' + b'\x01'*7, iv_encrypt) p.sendline(base64.b64encode(verify_iv + md5_cipher)) if p.recvline().strip() != cipher_notfound: flag_known += chr(guess) break # Remove PKCS#7 padding for pad_len in range(1, 17): if all(c == chr(pad_len) for c in flag_known[-pad_len:]): flag_known = flag_known[:-pad_len] break print(f"Flag: hitcon{{{flag_known}}}") p.close() if __name__ == '__main__': attack()
七、攻击效果与时间预估
| 步骤 | 操作 | 网络往返 | 时间 |
|---|---|---|---|
| PoW | 暴力 4 字节 SHA256 前缀 | 1 |
总时间主要取决于 flag 长度。对于 20 字节的 flag 内容, 约需要 10,000 次网络往返,在延迟较低的条件下约 30 秒完成。
八、防御建议
- 使用随机 IV:每次加密前调用 os.urandom(16) 生成随机 IV, 并将 IV 随密文一起发送。固定 IV 彻底破坏了 CBC 的语义安全性。
- 校验 unpad 合法性:PKCS#7 解填充必须验证: (a) 最后一个字节的值 N 在 1-16 范围内; (b) 最后 N 个字节全部等于 N。 任一条件不满足都应报错,而不是静默截断。
- 使用认证加密模式:如 AES-GCM、AES-CCM 或 ChaCha20-Poly1305。 这些模式同时提供机密性和完整性保护,能有效防止密文篡改。
- 分离命令与数据:不要把命令解析逻辑放在解密后的明文上, 攻击者一旦能控制明文,就能执行任意命令。
九、总结
这道题目完美展示了"AES 本身安全,但使用方式不安全"的经典场景。 两个看似微小的设计缺陷——固定 IV 和未校验的 unpad——组合在一起, 形成了完整的攻击链:
- 固定 IV 使 CBC 字节翻转成为可能
- 未校验的 unpad 使任意截断明文成为可能
- 两者结合,配合 MD5 命令作为"侧信道",实现逐字节信息泄露
对于 CTF 选手来说,这类题目的核心思路是: 不要试图直接破解 AES,而是寻找模式使用上的漏洞。 CBC 字节翻转、Padding Oracle、固定 IV 等弱点, 都是比暴力破解密钥更实际的攻击路径。
参考与来源
GFSJ0308 / HITCON 2017 CTF Quals, Secret Server. 题目原始来源。
User-uploaded challenge
codercto, 从一道题看 cbc 攻击 - HITCON CTF 2017 Secret Server. 中文技术分析。
https://www.codercto.com/a/24032.html
CTF-Wiki, Padding Oracle Attack. 块密码填充攻击综合教程。
https://ctf-wiki.org/crypto/blockcipher/attack-mode/padding-oracle/
NIST SP 800-38A, Recommendation for Block Cipher Modes of Operation. 官方模式规范。
https://csrc.nist.gov/publications/detail/sp/800-38a/final
浙公网安备 33010602011771号