- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
本文以 CTF-Wiki 文档 docs/zh-tw/docs/crypto/blockcipher/mode/ctr.md 为核心,系统讲解分组密码 CTR(计数器)模式的工作原理、并行化特性,并以一道 2023 年 CTF 实战题为例,完整演示如何利用"已知明文 + 已知密钥 + 部分密文"逆推计数器初始值并最终解密 flag 的全过程。读完本文,你将掌握 CTR 模式加解密数学结构,能够独立完成此类"计数器初值泄露/可逆推"型题目的 Exploit 编写。
CTR 模式概述
CTR 全称为计数器模式(Counter mode),该模式由 Whitfield Diffie 和 Martin Hellman 设计,是一种将分组密码转换为流密码(Stream Cipher)的工作模式。与 CBC、OFB 等模式通过密文或加密输出反馈来链接各个分组不同,CTR 模式的核心思想是:对一组随计数器递增而变化的数值执行分组加密,生成伪随机密钥流(keystream),再将密钥流与明文按位异或得到密文。
在 CTF-Wiki 的分组模式体系中,分组模式简介 指出:分组加密会把明文消息划分为固定大小的块,每块明文在密钥控制下分别加密为密文;当明文长度不是块大小的整数倍时,还需要进行填充(具体填充规则见 填充方式)。CTR 模式是这些模式中最具"流密码化"特征的一员,也是现代密码学与工程实践中使用极为广泛的模式。
加密流程
CTR 模式的加密流程如下所示:
从图中可以看出,加密过程由三部分构成:
- 计数器序列:每个分组使用一个由固定前缀
Nonce(图中示例为c59bcf35...)加上递增的Counter值(00000000、00000001、00000002……)组合而成的输入; - 分组加密:将
Key与"Nonce+Counter"组合送入分组密码加密模块(图中为Block Cipher Encryption),输出该计数器对应的密钥流块; - 异或运算:每个密钥流块与对应的明文块(Plaintext)做异或,输出密文块(Ciphertext)。
其数学表达为:
$$C_i = P_i \oplus E_K(Nonce \parallel Counter_i)$$
其中 $C_i$ 为第 $i$ 个密文块,$P_i$ 为第 $i$ 个明文块,$E_K$ 表示以密钥 $K$ 执行的分组加密(如 AES),$Counter_i$ 为第 $i$ 个计数器的值。
解密流程
CTR 模式的解密流程如下:
可以看到,解密过程与加密过程完全对称:使用与加密时完全相同的Key、Nonce与递增Counter序列,通过分组加密生成完全相同的密钥流,再将该密钥流与密文块异或,即可还原明文块:
$$P_i = C_i \oplus E_K(Nonce \parallel Counter_i)$$
这一特性意味着 CTR 模式的加解密使用的是同一个算法、同一套代码逻辑,无需实现分组密码的解密函数,这是它在工程实现上的重要优势。
特性分析
CTR 模式在 CTF-Wiki 原文档 中总结的核心特性如下:
| 特性 | 描述 |
|---|---|
| 加密可并行化 (Encryption parallelizable) | 是 (Yes) |
| 解密可并行化 (Decryption parallelizable) | 是 (Yes) |
| 随机读取访问 (Random read access) | 是 (Yes) |
与 OFB 模式的对比优势
原文档明确指出:CTR 模式比 OFB 模式有一些优势。OFB(输出反馈模式)的详细介绍可参见 OFB 模式文档,其反饋內容是分组加密后的内容,需要逐块串行生成密钥流。
CTR 模式的一个突出优点是:它允许并行加密和解密多个块,因为可以为每个块独立计算密钥流。由于每个块的输入(Nonce+Counter)互不依赖,加密器可以同时处理多个块,这可以显著提高加密和解密过程的性能和效率,在多核、多线程环境下尤为明显。
并行性与随机访问能力
- 加密可并行化:每个明文块的密钥流只取决于对应计数器值,块与块之间无任何依赖关系,天然适合 SIMD 指令集、GPU 或多线程并行加速;
- 解密可并行化:解密同样只依赖计数器与密钥,任何位置的密文块都可以独立、立即解密;
- 随机读取访问:由于块间无链接关系,可以跳过前面所有块直接解密任意位置的密文块,这对磁盘加密、网络传输加密等随机访问场景非常关键。
与 ECB 模式的区别
与 ECB 模式文档 中"同样明文块产生相同密文块、不隐藏明文统计规律"的缺陷相比,CTR 通过引入逐块递增的计数器值,使得即使明文块相同,所异或的密钥流也不同,从而避免了 ECB 的统计规律泄露问题。同时 CTR 又继承了 ECB"各块独立计算、可并行"的优点,并借助异或运算实现了流密码式的加解密对称性。
安全使用注意事项
从 CTR 的数学结构可以直接推得一个重要的安全结论:密钥流的重用是致命的。如果两个不同的消息使用了相同的 Nonce/Counter 初值,那么它们生成的密钥流完全相同,此时攻击者只需将两段密文异或即可得到两段明文的异或值:
$$C_1 \oplus C_2 = (P_1 \oplus KS) \oplus (P_2 \oplus KS) = P_1 \oplus P_2$$
因此在实际使用中,Nonce/计数器初值必须全局唯一(每次加密使用不同的 Nonce),且计数器不能发生回绕(wrap-around)。这是 CTR 模式公认的安全实践要求,也是下方 CTF 题目的攻击核心——题目恰恰把用于加密 flag 的密钥泄露成了计数器初始值的 hash,等价于"密钥流被可控地泄露"。
2023 某 CTF:逆推计数器初始值
接下来是原文档收录的一道 2023 年 CTF 实战题目。题目将未知的 AES 密钥 $key1$ 的整数值直接用作 CTR 计数器初始值,并给出其 SHA-256 摘要,要求攻击者根据已知明文、已知密钥 $key2$ 与部分密文逆推出计数器初始值,进而还原出被 $key1$ 加密的 flag。
题目源码
from Crypto.Util.number import long_to_bytes, bytes_to_long from Crypto.Cipher import AES from Crypto.Util import Counter from hashlib import sha256 import os from secret import flag def padding(msg): return msg + os.urandom(16 - len(msg) % 16) msg = b"where is the flag? Key in my Heart/Counter!!!!" key = b"I w0nder how????" assert len(msg) == 46 assert len(key) == 16 enc_key = os.urandom(16) initial_value = bytes_to_long(enc_key) hash = sha256(str(initial_value).encode()).hexdigest() aes = AES.new(enc_key,AES.MODE_ECB) enc_flag = aes.encrypt(padding(flag)) ctr = Counter.new(AES.block_size * 8, initial_value = initial_value) aes = AES.new(key, counter = ctr, mode = AES.MODE_CTR) enc = aes.encrypt(msg) print("enc = {}".format(enc[-16:])) print("enc_flag = {}".format(enc_flag)) print("hash = {}".format(hash)) """ enc_last16 = b'\xbe\x9bd\xc6\xd4=\x8c\xe4\x95bi\xbc\xe01\x0e\xb8' enc_flag = b'\xb2\x97\x83\x1dB\x13\x9b\xc2\x97\x9a\xa6+M\x19\xd74\xd2-\xc0\xb6\xba\xe8ZE\x0b:\x14\xed\xec!\xa1\x92\xdfZ\xb0\xbd\xb4M\xb1\x14\xea\xd8\xee\xbf\x83\x16g\xfa' hash = efb07225b3f1993113e104757210261083c79de50f577b3f0564368ee7b25eeb """已知条件梳理
题目提供的信息可以整理为:
| 已知量 | 说明 |
|---|---|
明文msg | b"where is the flag? Key in my Heart/Counter!!!!",共 46 字节 |
密钥key($key2$) | b"I w0nder how????",16 字节,CTR 模式的加密密钥 |
密文最后 16 字节enc_last16 | CTR 模式加密msg后密文的最后 16 字节 |
hash | sha256(str(initial_value).encode()),即计数器初始值整数的 SHA-256 |
enc_flag | 用未知密钥 $key1$(即enc_key)以 ECB 模式加密的 flag(含随机 padding) |
未知量只有:计数器初始值initial_value(即enc_key的字节序转整数)以及 flag 本身。
解题思路推导
回顾 CTR 模式加密流程:
$$C_i = P_i \oplus E_{key2}(Counter_i)$$
即:$明文 \oplus E_{key2}(Counter) = 密文$。根据异或运算的性质,只要同时知道明文与密文,就可以恢复出加密器的输出:
$$E_{key2}(Counter) = 明文 \oplus 密文$$
这里的关键洞察是:不要把思维局限在 CTR 模式的加解密图示上。单独看"计数器值被分组加密"这一操作,它本质上就是 ECB 模式对某个块执行加密:
$$C_{keystream} = E_{key2}(Counter)$$
而msg的加密用密钥key($key2$)进行,$key2$ 已知,因此可以直接对 $E_{key2}(Counter)$ 执行 ECB 解密:
$$Counter = D_{key2}(E_{key2}(Counter))$$
得到最后一个块所使用的计数器值后,再减去加密过程中计数器已经增加的数值(即该块对应的计数器增量),就还原出了计数器初始值initial_value。
得到initial_value后,先通过hash校验确认正确性,再将initial_value转回字节得到enc_key(即 $key1$),最后用 ECB 模式解密enc_flag并去掉随机 padding,即可得到 flag。
Exploit 详解
from Crypto.Util.number import long_to_bytes, bytes_to_long from Crypto.Cipher import AES from Crypto.Util import Counter from hashlib import sha256 import os # from secret import flag flag = b'flag{test}' def padding(msg): return msg + os.urandom(16 - len(msg) % 16) # 隨機值填充 msg = b"where is the flag? Key in my Heart/Counter!!!!" key = b"I w0nder how????" assert len(msg) == 46 assert len(key) == 16 enc_key = os.urandom(16) # 隨機key initial_value = bytes_to_long(enc_key) # key轉爲整數 hash = sha256(str(initial_value).encode()).hexdigest() # 字符串(key) 的 sha256 aes = AES.new(enc_key,AES.MODE_ECB) enc_flag = aes.encrypt(padding(flag)) # 16 * 8 = 128, # {'counter_len': 16, 'prefix': b'', 'suffix': b'', 'initial_value': 1, 'little_endian': False} ctr = Counter.new(AES.block_size * 8, initial_value = initial_value) print(ctr) aes = AES.new(key, counter = ctr, mode = AES.MODE_CTR) # key 已知, 推 counter, CTR mode 不需要 padding enc = aes.encrypt(msg) # msg 已知 # print("enc = {}".format(len(enc))) # 46 print("enc = {}".format(enc[-16:])) # 密文的最後16位, 但並不是最後一個 block print("enc_flag = {}".format(enc_flag)) print("hash = {}".format(hash)) print('題目數據輸出結束' + ' *' * 16) # Data enc_last16 = b'\xbe\x9bd\xc6\xd4=\x8c\xe4\x95bi\xbc\xe01\x0e\xb8' enc_flag = b'\xb2\x97\x83\x1dB\x13\x9b\xc2\x97\x9a\xa6+M\x19\xd74\xd2-\xc0\xb6\xba\xe8ZE\x0b:\x14\xed\xec!\xa1\x92\xdfZ\xb0\xbd\xb4M\xb1\x14\xea\xd8\xee\xbf\x83\x16g\xfa' hash = 'efb07225b3f1993113e104757210261083c79de50f577b3f0564368ee7b25eeb' # Solution # a = msg[32:] # 從明文index 32 開始 a = msg[16 * (len(msg) // 16):] # 取最後一個 block b = enc_last16[16 - (len(enc) % 16):] # 從密文index 2 開始 | 選最後一個 block # 加密最後步驟 明文 xor enc_{key}(counter) = 密文 # 解密最後步驟 enc_{key}(counter) xor 密文 = 明文 | enc_{key}(counter) = 密文 xor 明文 enc_Counter1 = bytes(a[i] ^ b[i] for i in range(14)) for i in range(0xff): for j in range(0xff): # ECB mode 要求數據長度與塊長對齊, 而加密後的數據的最後 2 bytes 我們並不清楚, 所以我們需要嘗試所有的可能 enc_Counter2 = enc_Counter1 + bytes([i]) + bytes([j]) aes = AES.new(key,AES.MODE_ECB) Counter = aes.decrypt(enc_Counter2) # E_{key}(Counter) = Counter_enc | Counter = D_{key}(Counter_enc) initial_value = bytes_to_long(Counter) - (len(msg) // 16) # 經歷兩個 block, 最後一個 block 的 Counter - block 數 = 初始值 if hash == sha256(str(initial_value).encode()).hexdigest(): # type: str print(f'found {initial_value = }') enc_key = long_to_bytes(initial_value) aes = AES.new(enc_key,AES.MODE_ECB) flag = aes.decrypt(enc_flag) print(flag) break # flag{9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d}关键坑点解析
这个 Exploit 中有几个非常容易被忽略、但对解题至关重要的坑点:
enc[-16:]并不是"最后一个完整块"。明文msg长度为 46 字节,AES 块大小为 16 字节,因此46 = 16 × 2 + 14,密文由 2 个完整块和 1 个 14 字节的不完整块组成。enc[-16:]取的是密文末尾 16 字节,其中前 2 字节属于倒数第二个完整块,只有后 14 字节才是最后一个(不完整)块的密文。按偏移精确切出最后一个块:
a = msg[16 * (len(msg) // 16):]即msg[32:],取出明文最后一个 14 字节块;b = enc_last16[16 - (len(enc) % 16):]即enc_last16[2:],跳过前 2 字节后取出密文最后一个 14 字节块。
E_{key2}(Counter)只有 14 字节确定,剩余 2 字节需穷举。由于最后一个明文块只有 14 字节,$明文 \oplus 密文$ 只能恢复出密钥流块的前 14 字节;而后续要调用 ECB 解密,数据长度必须与 16 字节块长对齐,因此需要对最后未知的 2 字节做0xff × 0xff的暴力枚举,共约 65536 次尝试。计数器增量的扣除。46 字节明文占用了 3 个计数器值(第 0 块用
initial_value,第 1 块用initial_value+1,最后一个块用initial_value+2),因此Counter - (len(msg) // 16) = Counter - 2才是初始值。从源码中Counter对象的默认打印{'counter_len': 16, 'prefix': b'', 'suffix': b'', 'initial_value': 1, 'little_endian': False}可以看出,Counter.new(AES.block_size * 8, initial_value=initial_value)创建的是 16 字节、无前后缀、默认大端序(little_endian: False)的计数器,初始值即initial_value,这也与bytes_to_long/long_to_bytes默认的大端序约定一致。用 hash 作唯一性校验。对候选
initial_value计算sha256(str(initial_value).encode())并与题目给出的 hash 比对,命中即为正确答案——这相当于把原本未知的 $key1$ 的"指纹"用作计数器初值猜测的验证器。最后一步是纯 ECB 解密。
enc_key = long_to_bytes(initial_value)恢复出 16 字节密钥后,直接AES.new(enc_key, AES.MODE_ECB)解密enc_flag,得到的明文包含随机填充的尾部字节,人工去掉即可看到 flag 本体。
总结
CTR 计数器模式通过"计数器 + 分组加密 + 异或"三要素,把分组密码转化为可并行、可随机访问的流密码。本题目最核心的攻击思路可以概括为一条链路:
已知明文 + 已知密钥 → 异或恢复密钥流块 → 视作 ECB 单块解密得到计数器值 → 减去块偏移得到初始值 → hash 校验 → 还原密钥 → ECB 解密 flag。
这提醒我们两点实战经验:其一,计数器初值/Nonce 的保密性与唯一性是 CTR 模式的命脉,任何形式的初值泄露(哪怕是 hash 摘要)都可能被攻破;其二,CTR 模式下"密文异或明文"即可还原密钥流,凡是已知明文/密文对的场景都应警惕密钥流泄露风险。
原文档在文末还给出了两道同类型练习题目,可作为延伸训练:
- 2017 star ctf ssss
- 2017 star ctf ssss2
CTR 模式与其他分组模式(ECB、CBC、OFB、CFB、PCBC)的原理与攻击专题,可继续阅读 分组模式文档目录 及 CBC 字节反转攻击 等相关章节。
- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考