简介:这份PDF专门剖析CTF竞赛中AES-GCM因Nonce重用引发的同模式多密文攻击,适合密码学方向参赛者与安全学习者。文档从AES-GCM工作原理、计数器模式与伽罗瓦域乘法讲起,深入Nonce重用漏洞的数学原理,并给出攻击步骤、工具代码及优化策略。随后通过2021东京CTF、2022全国大学生信息安全竞赛、2023国际密码学竞赛、2024量子计算环境等四个真实赛题案例,完整还原漏洞发现、攻击实现与防御启示,同时整理Nonce管理、密钥轮换、安全开发等最佳实践,既有理论推导也有可落地的解题思路。资源为单个PDF文件,约4.16MB,支持目录跳转和大纲定位,内容排版完整清晰。目前已有334人学习,可作为CTF-Misc密码学模块的专题参考。
1. 同模式多密文攻击:一次Nonce重用,整组密文全部白给
CTF里遇到AES-GCM时,很多人第一反应是“带认证、带标签,这下总该安全了吧”。实际上GCM的加密部分本质是CTR流密码,Nonce一旦重复,密钥流就会完整复用。攻击者拿到两条共享Nonce的密文,做一次简单的异或就能把密钥流消掉,剩下的就是两组明文的异或结果;如果其中一段明文已知,另一段就直接还原。我见过不少选手在固定Nonce的题上耗掉一整晚,最后发现解法和CBC的bit flipping完全不在一个量级,仅仅几行Python就出Flag。这篇文章从GCM的加密结构讲起,落到可以直接复现的多密文还原脚本,再整理输出解析和数据切分的常见坑。适合正在打CTF的选手,也适合写代码时随手把Nonce写死、事后想确认危害范围的开发者。
2. GCM的Nonce重用为什么是泄密现场:密钥流复用的三个层次
2.1 先从CTR加密链路拆起:Nonce决定密钥流,密钥流决定一切
AES-GCM的加密部分和CTR模式长得一模一样。加密时会把密钥K和初始Nonce拼成一个初始计数器块J0,标准做法是12字节Nonce后面接4字节的0x00000001。之后的每一个明文块,对应的计数器块就是J0、J0+1、J0+2……依次递增。真正的“加密”动作是先用AES加密这些计数器块,得到一串密钥流S_i = AES_K(J0 + i),再把明文P_i和密钥流做异或,得到密文C_i = P_i XOR S_i。
这里最核心的一条性质是:密钥流只由密钥K和Nonce决定,和明文没有任何关系。同一个密钥下,只要Nonce相同,加密两条不同的消息,产生的S_i就完全相同。换句话说,对于两条共享Nonce的消息,加密过程本质上是用同一把“一次一密”的密码本分别盖住了两份明文。一次一密在每次使用不同密码本时是安全的,但一旦密码本重用,整个加密体系就塌了。
2.2 密文异或会约掉密钥流:从公式到多密文复用
把两条共享Nonce的消息写出来比对:
C1 = P1 XOR S C2 = P2 XOR S
两边分别异或,S会被直接消掉,得到C1 XOR C2 = P1 XOR P2。这个等式不依赖密钥,也不依赖Nonce的具体数值,只要Nonce相同就成立。多密文场景下这个关系对任意两条密文都成立。攻击者手上若有三条密文,就能得到三个独立的明文异或关系;如果有四条,关系就更多。真正决定攻击成败的只有一件事:有没有任何一段明文是已知的。
这里的已知明文来源很多。CTF题里最常见的是第一条消息是公开的提示语、固定问候语,或者消息本身是BMP、PNG、ZIP这类带固定文件头的格式。给一道模拟题举例:第一条密文对应的明文是b"Hello, challenger!",第二条密文里藏着flag。那密钥流的头部就是第一条密文与这条明文的异或结果,提取出来直接去解第二条密文,flag当场就出来了。即使没有完整已知明文,只要知道文件头的前16字节或flag格式的固定前缀,也能先恢复出一段密钥流,再借助多消息交叉扩展。
多密文比单对密文强的地方在于:密文对越多,约束条件越密。例如不知道任何明文时,单对密文的每个字节可枚举的可打印字符组合很多,但三对、四对密文同时约束同一个明文位置时,候选往往能收敛到唯一结果。这个投票式的求解思路在后面第3章会给完整脚本,属于纯字符集约束,不涉及任何密码分析工具。
2.3 GHASH的连带影响:Nonce重用不只是泄明文
Nonce重用还有一个很多人忽略的破坏面,它同时砸向GCM的认证部分。GCM的认证标签T = GHASH_H(AAD || C) XOR E_K(J0),其中E_K(J0)是一个固定的掩码。Nonce一旦重用,掩码就相同。拿到两条共享Nonce的消息以及各自的Tag后,把两个Tag异或,掩码会直接抵消,只剩下GHASH_H作用于两条消息的差异值。
GHASH_H本质上是在GF(2^128)上用H作为未知数做多项式求值。消息长一块,H的次数就高一阶;两条消息的Tag异或结果可以整理成一个关于H的多项式方程。多组消息对能联立方程组,理论上可以把认证密钥H解出来。H一旦到手,攻击者就能伪造任意AAD和密文的合法标签,彻底打穿GCM的认证防线。这个方向在CTF里不常考,但理解这条线能让你意识到“Nonce重用导致数据泄露”到底泄露到了什么程度。第6章开头会给出推导起点,这里先立住。
2.4 Nonce重用的三个高发源头:出题人和开发者的共同坑
写题和写业务代码踩的坑高度重合。第一种最直白:用一个写死的Nonce常量加密所有消息,比如nonce = b"fixed_nonce_000"。第二种是随机数截断,把random.randbytes(4)或时间戳的低4字节当Nonce用,消息一多必然碰撞。第三种隐蔽一些:有人不直接调GCM,而是用CTR模式手写计数器,把Nonce当作计数器初值从0开始递增,和标准GCM的J0构造差了一个1,看起来像废掉的Nonce,实际上只要两条消息从同一个计数器起点开始,密钥流照样重合。
我处理模拟题时有个习惯:拿到加密脚本先全局搜nonce、iv、counter三个词,看它每次加密是新建对象还是复用同一个对象,再确认Nonce长度是不是12字节。大多数Nonce重用题,证据就在这三行代码里。比对着密文猜测快得多。
3. 多密文攻击落地:从已知明文到批量还原的脚本
3.1 场景一:有已知明文头,一把密钥流传遍所有密文
最舒服的场景:第一条密文的完整明文已知,后面跟着一堆共享Nonce的密文。先把第一条密文和已知明文异或,取出密钥流;再把密钥流和后面每条密文的前段异或,逐一还原。脚本很短,纯Python就能跑,不需要任何密码学库。
def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def decrypt_all(known_ciphertext: bytes, known_plaintext: bytes, ciphertexts: list[bytes]) -> list[bytes]: # 密钥流长度受已知密文长度限制,先截取能覆盖的部分 keystream = xor_bytes(known_ciphertext[: len(known_plaintext)], known_plaintext) results = [] for idx, c in enumerate(ciphertexts): # 每条密文能解出的长度最多等于 keystream 的长度 p = xor_bytes(keystream, c[: len(keystream)]) results.append(p) print(f"message {idx}: {p!r}") return results这段代码的关键参数是zip的截断行为。xor_bytes里zip会停在较短的那个字节串上,所以当密文比密钥流短时,结果正好是密文全长;当密文比密钥流长时,多出来的尾部无法还原。这个“尾部空缺”是常见翻车点,解决思路是用另一条消息的已知明文段去补,或借助下一个场景的字符集约束把缺口填上。
3.2 场景二:没有已知明文,用字符集约束逐字节爆破
更多时候题目并没有大方地送一段完整明文,只告诉你flag是可打印ASCII字符。此时从密文对关系C1 XOR C2 = P1 XOR P2出发,对每一个字节做枚举:假设P1这个字节是某个可打印字符,算出P2对应字节,如果P2也可打印,就记下这个候选。遍历全部可能后,留下的候选集合就是这一字节的可行解。
import string PRINTABLE = (string.ascii_letters + string.digits + string.punctuation).encode() def byte_candidates(c1: int, c2: int) -> list[int]: result = [] for p1 in PRINTABLE: p2 = c1 ^ c2 ^ p1 if p2 in PRINTABLE: result.append(p1) return result # 用法:逐字节调用,得到第一个明文字节的所有候选 # cands = byte_candidates(c1[0], c2[0]) # print(cands)这里的核心约束是“可打印字符异或可打印字符”。注意明文空间假设直接决定结果质量:如果明文是UTF-8中文或任意字节流,这个脚本就会失效。CTF里flag几乎总是ASCII可打印,配合大括号、下划线这些固定格式,候选通常能压得很小。单个字符位置靠两密文约束通常还剩几十个候选,别急着眼花,下一步多消息投票能帮你收干净。
3.3 多消息投票:让候选解自己收敛
同一条明文位置如果同时被三对密文约束,可行解就会快速收缩。原理很简单:每一对密文给出一个候选集合,取全部集合的交集。实现上先收集所有共享Nonce密文在位置i的字节,然后两两组合成密文对,对每个明文位置做交集运算。
def intersect_candidates(pairs: list[tuple[int, int]]) -> set[int]: # pairs 里的每一对是 (c_a_byte, c_b_byte),它们来自同一个明文位置 i final = None for c1b, c2b in pairs: cand = set() for p in PRINTABLE: q = c1b ^ c2b ^ p if q in PRINTABLE: cand.add(p) final = cand if final is None else (final & cand) return final # 示例:假设有 4 条密文,明文第 5 个字节是所有消息共用的同一个位置 # pairs = [(c0[5], c1[5]), (c0[5], c2[5]), (c0[5], c3[5])] # solution = intersect_candidates(pairs)我一般会写一个外层循环,从0遍历到最短密文长度,对每个位置动态构造pairs,最后把所有收敛到单元素的候选直接拼成明文。注意密文长度参差不齐时,多出的部分可用的密文对变少,投票效果会变差,此时优先恢复长度较长且覆盖范围更广的那条明文。
4. 解密翻车现场:Nonce重用攻击常见的五个坑
4.1 现象:密文长度对不上,解出来尾部多出16字节乱码
原因:题目输出的hex串是ciphertext + tag拼接,解密脚本直接把整条hex解成字节流,尾部16字节是GHASH标签,不是密文本体。用它们去参与异或,恢复结果前段正常,尾部全是乱码。
解决:解析输出时先剥掉最后16字节。常见做法是raw = bytes.fromhex(line),然后ciphertext = raw[:-16],tag = raw[-16:]。在高版本PyCryptodome里,encrypt_and_digest返回的密文和标签本身就是分开的,但题目输出为了省事经常拼在一起,所以解析时要习惯性切片。
4.2 现象:Nonce看起来相同,但解出来全是乱码
原因:加密脚本虽然每次输出相同的Nonce,但内部用的是AES.MODE_CTR而不是AES.MODE_GCM,计数器初值可能从0开始,也可能对Nonce做了额外拼接。两条消息的起始计数器块不一致,密钥流没有真正对齐,简单异或自然得不到明文。
解决:先用一组已知明文对验证密钥流是否真的相同。把第一条密文的前16字节和已知明文异或,得到一段密钥流,再把这同一段密钥流和第二条密文的前16字节异或,看结果是否可读。不可读就说明计数器起点有偏差,回到加密脚本确认计数器构造方式。
4.3 现象:第一条明文太短,密钥流不够解密后面的密文
原因:密钥流的长度上限由已知明文长度决定。已知明文只有32字节,后面某条密文有80字节,那只能还原前32字节,剩余部分没有密钥流可用。
解决:去找其他密文里的已知明文段扩展密钥流。比如第三条密文的前16字节已知是b"flag{",那它和第一条密文对应位置异或,能补齐密钥流的第二个窗口。窗口越多,密钥流覆盖率越高。实在没有已知段时,用第3章的字符集投票约束,把高可能性候选填进去,再配合flag格式校验。
4.4 现象:XOR结果可打印但拼不出完整Flag
原因:部分题目在加密前对明文做了额外拼接,比如把AAD、用户ID、时间戳都塞进明文里,又或者明文是结构化文件格式,中间夹着二进制字段和校验和。直接按整条消息恢复会得到一段混合了可见与不可见字节的碎片。
解决:先分析加密脚本里每条消息到底传入了什么。若消息是BMP或PNG文件,就把恢复结果写成完整文件再打开;若消息是日志拼接,先按长度和打印率分段,再逐段做XOR。不要指望一整块都human-readable,能定位到flag所在字段就够了。
4.5 现象:恢复结果正确,但Flag校验一直不通过
原因:题目可能把Nonce重用设计成多阶段,第一阶段恢复出的只是中间量,还需要二次解算;或者Flag格式不是flag{...},而是ctf{...}或题目自定义前缀。选手拿到可打印字符串就急着交,忽略了格式细节。
解决:恢复后先看首尾字符,再对比题目描述中的Flag格式说明。脚本里加一个自动检查,遍历所有恢复结果,匹配startswith和endswith,打印所有候选。见过不少题目把前缀换成题目名,最后一分钟才发现。
5. 一道模拟题从头打到尾:固定Nonce加密两条消息,Flag直接还原
5.1 拿到题目先做三件事:读加密脚本、认Nonce、分密文和Tag
模拟题目给了两个文件:一个加密脚本,一份输出文本。加密脚本大致的逻辑是生成一次Nonce,然后加密两条消息并打印hex。这里给出还原题目结构的示例脚本,方便理解输出格式。
from Crypto.Cipher import AES import os key = os.urandom(16) nonce = os.urandom(12) # 注意:只生成一次,后续加密全部复用 welcome = b"Hello, challenger! The same nonce means the same keystream.\n" flag = open("flag.txt", "rb").read().strip() def send(msg: bytes): cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) ct, tag = cipher.encrypt_and_digest(msg) print((ct + tag).hex()) send(welcome) send(flag)拿到题目先做三件事。第一,确认Nonce是否只创建了一次,这是重用的直接证据;第二,确认密文和Tag是否拼在一起输出,决定解析方式;第三,确认第一条消息的内容是否公开可读。这个模拟题里第一条消息就是welcome字符串,明文完整已知,是最舒服的出题姿势。
5.2 攻击思路:把第一条消息当作公开参照物
攻击链路很简单。第一条消息的明文welcome是公开字符串,它的密文c0在输出里也能直接拿到。把c0和welcome异或,就得到密钥流。这个密钥流和第二条消息使用的密钥流完全相同,所以再用它去异或第二条密文c1,flag就还原了。
这里要注意一个细节:c0的实际长度等于welcome的长度,密钥流最多也只有这么长。如果flag比welcome还长,那超出部分就没法直接解。所以出题时welcome通常会比flag长,或者题目设计成多条消息互相补窗口。实战里看到第一条已知明文比目标短,别慌,找其他已知明文段去扩展密钥流。
5.3 完整还原脚本与运行验证
把上面的思路落成脚本,直接从命令行读取两行hex输出,打印还原出的明文。
import sys def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def main() -> None: if len(sys.argv) != 3: print("usage: python solve.py <ct0_hex> <ct1_hex>") return ct0 = bytes.fromhex(sys.argv[1]) ct1 = bytes.fromhex(sys.argv[2]) # 输出格式是 ciphertext + tag,先剥掉末尾 16 字节 c0, c1 = ct0[:-16], ct1[:-16] # 第一条消息的明文在题目描述里公开给出 known_plain = b"Hello, challenger! The same nonce means the same keystream.\n" # c0 和 known_plain 等长时,密钥流正好是 c0 的长度 keystream = xor_bytes(c0, known_plain) # 用同一段密钥流解第二条消息 flag = xor_bytes(keystream, c1[: len(keystream)]) print(flag.decode(errors="replace")) if __name__ == "__main__": main()运行后输出里应该直接出现flag。关键参数是keystream的长度:它不会超过min(len(c0), len(known_plain))。当c1比keystream短时,xor_bytes自动截断到c1的长度,结果完整;当c1更长时,尾部解不了,脚本会丢字节。想验证脚本结果是否可靠,可以把还原出的明文拿去匹配首尾格式,或者再拆出一组新的已知明文段交叉确认。
6. 把数据泄露用到极致:多密文下的定位、验证与扩展攻击
6.1 泄露边界:明文之外,Tag对还能泄露什么
回到第2章埋下的那条线。共享Nonce的两条消息,Tag关系满足T1 XOR T2 = GHASH_H(A1 || C1) XOR GHASH_H(A2 || C2)。如果消息都是单块长度且AAD为空,这个方程会化简成关于H的二次式,在GF(2^128)上解方程就能恢复H。多块消息对应更高次多项式,但多组Tag能联立成方程组,用SageMath一样能处理。CTF里遇到Nonce重用题,绝大多数考到明文恢复就结束了,但知道这条扩展路径,面对未来更刁钻的题时至少清楚数据泄露的真正边界在哪里。
6.2 密文一堆怎么挑:长度排序 + 可打印率筛选
实战中拿到的输出往往不止两行,而是几十行hex。先别急着逐对异或,按可打印率和长度把密文排序,优先处理最可能包含flag可读内容的消息。可打印率就是字节中可打印字符占的比例,一个纯ASCII明文的密文,和正确密钥流异或后,这段比例会接近1。
import string def printable_ratio(b: bytes) -> float: if not b: return 0.0 printable = bytes(string.printable, "ascii") ok = sum(1 for x in b if x in printable) return ok / len(b) items = [] for line in open("output.txt"): line = line.strip() if not line: continue raw = bytes.fromhex(line) body = raw[:-16] # 去掉 tag items.append((len(body), printable_ratio(body), raw)) items.sort(key=lambda t: t[1], reverse=True) for length, ratio, raw in items[:10]: print(length, f"{ratio:.2f}", raw[:32].hex())排序结果能帮你快速圈定哪些消息值得先解,哪些消息可能是随机填充或二进制文件。可打印率低的消息不代表没用,它可能提供额外的密文对约束,用来做投票收敛。
6.3 自动验证:把恢复结果过滤成Flag
解密结果可能有十几段,人来翻容易漏。我会在攻击脚本最后挂一个过滤函数,遍历所有恢复出的明文,先匹配flag格式,再放宽到全可打印。
def find_flag(candidates: list[bytes]) -> bytes: for p in candidates: if p.startswith(b"flag{") and p.endswith(b"}"): return p for p in candidates: if all(32 <= x < 127 for x in p): return p return b""这个函数解决的是最后一步的识别问题。配合第3章的投票脚本,整个流程可以从原始hex直接走到flag,中间不需要人工盯字节。一次线下模拟里我就是靠这组流程把二十条固定Nonce加密的日志全部还原,最后在第三条日志里找到了当年写死的口令。那之后我在自己代码里凡是用到Nonce,都会加一条断言确保每次调用都不同。希望帮到你。
本文还有配套的精品资源,点击获取