☰
CTF-Wiki 密码学实战:CTR 计数器模式原理剖析与 CTF 逆向攻击
2026/9/25 7:10:36 网站建设 项目流程
  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载

本文以 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 模式的加密流程如下所示:

从图中可以看出,加密过程由三部分构成:

  1. 计数器序列:每个分组使用一个由固定前缀Nonce(图中示例为c59bcf35...)加上递增的Counter值(00000000、00000001、00000002……)组合而成的输入;
  2. 分组加密:将Key与"Nonce+Counter"组合送入分组密码加密模块(图中为Block Cipher Encryption),输出该计数器对应的密钥流块;
  3. 异或运算:每个密钥流块与对应的明文块(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 """

已知条件梳理

题目提供的信息可以整理为:

已知量说明
明文msgb"where is the flag? Key in my Heart/Counter!!!!",共 46 字节
密钥key($key2$)b"I w0nder how????",16 字节,CTR 模式的加密密钥
密文最后 16 字节enc_last16CTR 模式加密msg后密文的最后 16 字节
hashsha256(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 中有几个非常容易被忽略、但对解题至关重要的坑点:

  1. enc[-16:]并不是"最后一个完整块"。明文msg长度为 46 字节,AES 块大小为 16 字节,因此46 = 16 × 2 + 14,密文由 2 个完整块和 1 个 14 字节的不完整块组成。enc[-16:]取的是密文末尾 16 字节,其中前 2 字节属于倒数第二个完整块,只有后 14 字节才是最后一个(不完整)块的密文。

  2. 按偏移精确切出最后一个块:

    • a = msg[16 * (len(msg) // 16):]即msg[32:],取出明文最后一个 14 字节块;
    • b = enc_last16[16 - (len(enc) % 16):]即enc_last16[2:],跳过前 2 字节后取出密文最后一个 14 字节块。
  3. E_{key2}(Counter)只有 14 字节确定,剩余 2 字节需穷举。由于最后一个明文块只有 14 字节,$明文 \oplus 密文$ 只能恢复出密钥流块的前 14 字节;而后续要调用 ECB 解密,数据长度必须与 16 字节块长对齐,因此需要对最后未知的 2 字节做0xff × 0xff的暴力枚举,共约 65536 次尝试。

  4. 计数器增量的扣除。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默认的大端序约定一致。

  5. 用 hash 作唯一性校验。对候选initial_value计算sha256(str(initial_value).encode())并与题目给出的 hash 比对,命中即为正确答案——这相当于把原本未知的 $key1$ 的"指纹"用作计数器初值猜测的验证器。

  6. 最后一步是纯 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!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载
上一篇:DeerFlow 开源智能体框架上手指南:深度研究、数据分析与图表生成的完整路径
下一篇:贴个链接一条命令拉完整个主页:抖音无水印批量下载实操

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询