LitCTF2026 lit_xor_two_story
- [LitCTF2026 lit_xor_two_story](#[LitCTF2026] lit_xor_two_story)
-
- [1. 题目描述](#1. 题目描述)
- [2. 漏洞分析](#2. 漏洞分析)
-
- [2.1 One-Time Pad 的理论安全性](#2.1 One-Time Pad 的理论安全性)
- [2.2 Two-Time Pad:密钥流复用漏洞](#2.2 Two-Time Pad:密钥流复用漏洞)
- [2.3 二进制实例演算](#2.3 二进制实例演算)
- [2.4 为什么获得 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2 仍然很危险?](#2.4 为什么获得 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2 仍然很危险?)
- [2.5 已知明文攻击](#2.5 已知明文攻击)
- [3. Exploit:构造思路与脚本分析](#3. Exploit:构造思路与脚本分析)
-
- [3.1 已知信息](#3.1 已知信息)
- [3.2 为什么可以这样构造?](#3.2 为什么可以这样构造?)
- [3.3 Exploit 脚本](#3.3 Exploit 脚本)
- [3.4 脚本为什么这样写?](#3.4 脚本为什么这样写?)
- [3.5 另一种写法:显式恢复密钥流](#3.5 另一种写法:显式恢复密钥流)
- [3.6 精简写法](#3.6 精简写法)
- [3.7 结果验证](#3.7 结果验证)
-
- 验证一:数据长度
- 验证二:密文的相同前缀
- [验证三:Flag 格式](#验证三:Flag 格式)
- [4. Flag](#4. Flag)
- [5. 总结](#5. 总结)
- [Key Takeaway](#Key Takeaway)
LitCTF2026 lit_xor_two_story
题目描述
某同学用 同一串随机密钥流 k 对两条长度均为 40 字节的明文做异或「流密码」加密,却忘记了一次一密的基本要求:密钥绝不能复用。
python
#!/usr/bin/env python3
"""
LitCTF2026 --- One-time pad reused for two messages (40 bytes each).
Players receive output.txt and README; they do not receive secret.py.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
try:
from secret import M1_FLAG
except ImportError:
raise SystemExit(
"secret.py (organizer) is required to generate ciphertext; "
"players work from output.txt only."
)
# Public second message --- duplicated in README for contestants.
M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!"
assert len(M1_FLAG) == len(M2_KNOWN) == 40
def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--write",
type=Path,
help="Write hex lines to file.",
)
args = parser.parse_args()
n = len(M1_FLAG)
k = os.urandom(n)
c1 = xor_bytes(M1_FLAG, k)
c2 = xor_bytes(M2_KNOWN, k)
lines = [
f"c1 = {c1.hex()}",
f"c2 = {c2.hex()}",
f"len = {n}",
]
text = "\n".join(lines) + "\n"
print(text, end="")
if args.write:
args.write.write_text(text, encoding="utf-8")
if __name__ == "__main__":
main()
# c1 = 5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28
# c2 = 5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474
# len = 40
1. 题目描述
题目提供了一个加密脚本 encrypt.py,使用 相同的密钥流 对两段明文进行 XOR 加密。
python
M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!"
k = os.urandom(40) # 随机生成 40 字节密钥
c1 = M1_FLAG ⊕ k
c2 = M2_KNOWN ⊕ k
输出文件(output.txt):
c1 = 5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28
c2 = 5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474
len = 40
已知条件: M2 已知,内容为 litctf2026_xor_keystream_reuse_40bytes!!
目标:恢复 M1(即 FLAG)。
2. 漏洞分析
2.1 One-Time Pad 的理论安全性
One-Time Pad(OTP,一次一密)的核心思想,是使用一段与明文等长、均匀随机且仅使用一次的密钥 K K K,通过异或运算对明文 M M M 进行加密,得到密文 C C C:
C = M ⊕ K C = M \oplus K C=M⊕K
解密时,再使用同一密钥与密文进行异或:
M = C ⊕ K M = C \oplus K M=C⊕K
这是因为异或运算满足 K ⊕ K = 0 K \oplus K = 0 K⊕K=0,所以:
C ⊕ K = ( M ⊕ K ) ⊕ K = M ⊕ ( K ⊕ K ) = M C \oplus K = (M \oplus K) \oplus K = M \oplus (K \oplus K) = M C⊕K=(M⊕K)⊕K=M⊕(K⊕K)=M
Shannon 在 1949 年发表的《Communication Theory of Secrecy Systems》中给出了完美保密性(Perfect Secrecy)的严格定义 。对于任意明文 m m m 和任意可能出现的密文 c c c,如果满足:
Pr ( M = m ∣ C = c ) = Pr ( M = m ) \Pr(M=m\mid C=c)=\Pr(M=m) Pr(M=m∣C=c)=Pr(M=m)
那么该加密系统具有完美保密性。
通俗地说,攻击者在看到密文之后,对明文的判断并不会比看到密文之前更加准确。换言之,密文本身没有向攻击者泄露任何关于明文的信息。
要让标准 OTP 达到这种信息论安全性,通常需要满足以下三个核心条件。
条件一:密钥必须均匀随机
密钥 K K K 的每一位都应当从均匀分布中独立采样。对于任意一位 K i K_i Ki,都有:
Pr ( K i = 0 ) = Pr ( K i = 1 ) = 1 2 \Pr(K_i=0)=\Pr(K_i=1)=\frac{1}{2} Pr(Ki=0)=Pr(Ki=1)=21
如果密钥长度为 n n n 位,那么密钥空间为 { 0 , 1 } n \{0,1\}^n {0,1}n,每个密钥 k k k 被选中的概率均应为:
Pr ( K = k ) = 1 2 n \Pr(K=k)=\frac{1}{2^n} Pr(K=k)=2n1
这意味着:
- 密钥不能带有可被利用的统计偏差;
- 密钥必须与明文相互独立;
- 从严格的信息论意义上说,不能使用确定性的伪随机数生成器代替真正的随机密钥。
在工程实践中,密码学安全伪随机数生成器(CSPRNG)可以生成计算上难以预测的密钥流,但这种方案属于计算安全 ,而不是 OTP 所强调的信息论安全。
条件二:密钥必须具有足够的长度与熵
对于一条长度为 n n n 位的消息,标准 OTP 会使用一段独立的 n n n 位随机密钥:
∣ K ∣ = ∣ M ∣ = n |K|=|M|=n ∣K∣=∣M∣=n
如果可用密钥比明文短,就只能让部分明文得不到密钥覆盖,或者循环、扩展使用已有密钥。无论采用哪种方式,都会破坏标准 OTP 的安全前提。
更准确地说,完美保密要求密钥所提供的不确定性足以覆盖明文的不确定性。其必要条件可以直观地写成:
H ( K ) ≥ H ( M ) H(K)\geq H(M) H(K)≥H(M)
其中, H ( ⋅ ) H(\cdot) H(⋅) 表示信息熵。对于固定长度的二进制消息,最常见的 OTP 构造就是让密钥与明文严格等长。
条件三:同一密钥绝不能重复使用
这是最容易被忽视、同时也是最致命的一条。
每加密一条新消息,都必须使用一份全新的、从未使用过的随机密钥。密钥一旦被用于加密某条消息,就不能再次用于其他消息。"One-Time Pad"中的 One-Time,强调的正是"只使用一次"。
关键结论:随机、等长、一次性使用,三者缺一不可。一旦复用密钥,OTP 的完美保密性就会立即失效。
从直观上看,当密钥均匀随机且与明文等长时,对于攻击者观察到的任意密文 c c c,每一个候选明文 m m m 都对应着唯一的候选密钥:
k = m ⊕ c k=m\oplus c k=m⊕c
由于所有密钥出现的概率相同,攻击者无法判断哪一个候选明文才是真实明文,因此密文不会帮助其排除任何可能性。
2.2 Two-Time Pad:密钥流复用漏洞
当同一段密钥 K K K 被用于加密两条不同的明文时,原本的 One-Time Pad 就变成了不安全的 Two-Time Pad。
设两条明文分别为 M 1 M_1 M1 和 M 2 M_2 M2,使用同一个密钥 K K K 加密后得到:
C 1 = M 1 ⊕ K C_1=M_1\oplus K C1=M1⊕K
C 2 = M 2 ⊕ K C_2=M_2\oplus K C2=M2⊕K
攻击者截获两段密文后,可以直接计算 C 1 ⊕ C 2 C_1\oplus C_2 C1⊕C2:
C 1 ⊕ C 2 = ( M 1 ⊕ K ) ⊕ ( M 2 ⊕ K ) = M 1 ⊕ M 2 ⊕ K ⊕ K = M 1 ⊕ M 2 ⊕ 0 = M 1 ⊕ M 2 \begin{aligned} C_1\oplus C_2 &=(M_1\oplus K)\oplus(M_2\oplus K)\\ &=M_1\oplus M_2\oplus K\oplus K\\ &=M_1\oplus M_2\oplus 0\\ &=M_1\oplus M_2 \end{aligned} C1⊕C2=(M1⊕K)⊕(M2⊕K)=M1⊕M2⊕K⊕K=M1⊕M2⊕0=M1⊕M2
这里利用了异或运算的交换律、结合律以及自反性:
K ⊕ K = 0 K\oplus K=0 K⊕K=0
因此,重复使用的密钥 K K K 被完全消去。攻击者即使不知道密钥,也能直接获得两段明文的异或结果:
C 1 ⊕ C 2 = M 1 ⊕ M 2 C_1\oplus C_2=M_1\oplus M_2 C1⊕C2=M1⊕M2
这正是密钥流复用漏洞的核心。
2.3 二进制实例演算
假设有如下两段 8 位明文和一段随机密钥:
M 1 = 10110100 M_1=10110100 M1=10110100
M 2 = 01101011 M_2=01101011 M2=01101011
K = 11010010 K=11010010 K=11010010
分别进行异或加密:
C 1 = M 1 ⊕ K = 10110100 ⊕ 11010010 = 01100110 \begin{aligned} C_1 &=M_1\oplus K\\ &=10110100\oplus11010010\\ &=01100110 \end{aligned} C1=M1⊕K=10110100⊕11010010=01100110
C 2 = M 2 ⊕ K = 01101011 ⊕ 11010010 = 10111001 \begin{aligned} C_2 &=M_2\oplus K\\ &=01101011\oplus11010010\\ &=10111001 \end{aligned} C2=M2⊕K=01101011⊕11010010=10111001
攻击者截获 C 1 C_1 C1 和 C 2 C_2 C2 后,将两者异或:
C 1 ⊕ C 2 = 01100110 ⊕ 10111001 = 11011111 \begin{aligned} C_1\oplus C_2 &=01100110\oplus10111001\\ &=11011111 \end{aligned} C1⊕C2=01100110⊕10111001=11011111
再计算两段原始明文的异或结果:
M 1 ⊕ M 2 = 10110100 ⊕ 01101011 = 11011111 \begin{aligned} M_1\oplus M_2 &=10110100\oplus01101011\\ &=11011111 \end{aligned} M1⊕M2=10110100⊕01101011=11011111
可以看到:
C 1 ⊕ C 2 = M 1 ⊕ M 2 = 11011111 C_1\oplus C_2=M_1\oplus M_2=11011111 C1⊕C2=M1⊕M2=11011111
两者完全相等。攻击者不需要知道密钥 K = 11010010 K=11010010 K=11010010,仅凭两段密文就能够获得 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2。
2.4 为什么获得 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2 仍然很危险?
乍看之下,攻击者得到的只是两段明文的异或结果,而不是具体的 M 1 M_1 M1 或 M 2 M_2 M2。但自然语言、协议报文和结构化文件通常都具有很强的统计冗余性,攻击者可以利用这些规律逐步推测原文。
以 ASCII 英文文本为例:
- 空格字符的编码为 0x20 \texttt{0x20} 0x20,并且出现频率很高;
- 大写字母主要位于 0x41 \texttt{0x41} 0x41 到 0x5A \texttt{0x5A} 0x5A;
- 小写字母主要位于 0x61 \texttt{0x61} 0x61 到 0x7A \texttt{0x7A} 0x7A;
e、t、a、o、i、n等字母的出现频率通常更高;- 协议头、文件魔数、JSON 结构和 Flag 前缀往往具有固定格式。
攻击者可以据此实施 Crib Dragging(拖词攻击):
- 猜测其中一段明文可能包含某个常见单词或固定片段,例如
" the "或"litctf{"; - 将猜测片段与 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2 的不同位置逐一异或;
- 观察得到的另一段候选文本是否符合自然语言或目标格式;
- 如果结果合理,就以此为突破口继续扩展,逐步恢复两段明文。
假设攻击者猜测出 M 1 M_1 M1 的某一段内容,那么对应位置的另一段明文可以通过下式得到:
M 2 = ( M 1 ⊕ M 2 ) ⊕ M 1 M_2=(M_1\oplus M_2)\oplus M_1 M2=(M1⊕M2)⊕M1
这是因为:
( M 1 ⊕ M 2 ) ⊕ M 1 = M 2 ⊕ ( M 1 ⊕ M 1 ) = M 2 (M_1\oplus M_2)\oplus M_1 =M_2\oplus(M_1\oplus M_1) =M_2 (M1⊕M2)⊕M1=M2⊕(M1⊕M1)=M2
2.5 已知明文攻击
更严重的情况是:攻击者已经知道其中一段完整明文。
假设 M 2 M_2 M2 已知,则可以直接利用第二组明密文恢复密钥:
K = C 2 ⊕ M 2 K=C_2\oplus M_2 K=C2⊕M2
随后使用该密钥解密第一段密文:
M 1 = C 1 ⊕ K M_1=C_1\oplus K M1=C1⊕K
把两步合并,可以得到:
M 1 = C 1 ⊕ ( C 2 ⊕ M 2 ) = C 1 ⊕ C 2 ⊕ M 2 \begin{aligned} M_1 &=C_1\oplus(C_2\oplus M_2)\\ &=C_1\oplus C_2\oplus M_2 \end{aligned} M1=C1⊕(C2⊕M2)=C1⊕C2⊕M2
因此,只要知道 M 2 M_2 M2,就可以直接计算:
M 1 = C 1 ⊕ C 2 ⊕ M 2 \boxed{M_1=C_1\oplus C_2\oplus M_2} M1=C1⊕C2⊕M2
这正是本题所利用的已知明文攻击。题目公开了第二段明文 M 2 M_2 M2,因此我们不需要进行复杂的频率分析或拖词攻击,只需要进行三次逐字节异或,就能完整恢复第一段明文 M 1 M_1 M1。
现实教训: 历史上的 Venona Project(维诺那计划)正是密钥材料被重复使用所造成的著名案例。密码分析人员通过分析使用重复密钥加密的苏联电报,逐步恢复了部分消息内容。这说明即使密钥本身足够随机,只要违反"一次一密"的使用原则,系统仍然可能遭到破解。
3. Exploit:构造思路与脚本分析
3.1 已知信息
题目提供了以下三项关键数据:
- 第一段密文 C 1 C_1 C1;
- 第二段密文 C 2 C_2 C2;
- 第二段密文对应的已知明文 M 2 M_2 M2。
base
c1 = 5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28
c2 = 5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474
len = 40
两段密文的生成方式分别为:
C 1 = M 1 ⊕ K C_1=M_1\oplus K C1=M1⊕K
C 2 = M 2 ⊕ K C_2=M_2\oplus K C2=M2⊕K
其中, M 1 M_1 M1 是需要恢复的 Flag, K K K 是两次加密重复使用的同一段密钥流。
因此,当前已知 C 1 C_1 C1、 C 2 C_2 C2 和 M 2 M_2 M2,未知 M 1 M_1 M1 和 K K K。我们的目标,就是利用已知的三项数据消去未知密钥 K K K。
3.2 为什么可以这样构造?
最直观的解法是先利用已知的明密文对 ( M 2 , C 2 ) (M_2,C_2) (M2,C2) 恢复密钥流。
bash
M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!"
由:
C 2 = M 2 ⊕ K C_2=M_2\oplus K C2=M2⊕K
等式两边同时异或 M 2 M_2 M2:
C 2 ⊕ M 2 = ( M 2 ⊕ K ) ⊕ M 2 = K ⊕ ( M 2 ⊕ M 2 ) = K ⊕ 0 = K \begin{aligned} C_2\oplus M_2 &=(M_2\oplus K)\oplus M_2\\ &=K\oplus(M_2\oplus M_2)\\ &=K\oplus0\\ &=K \end{aligned} C2⊕M2=(M2⊕K)⊕M2=K⊕(M2⊕M2)=K⊕0=K
因此,密钥流可以直接恢复为:
K = C 2 ⊕ M 2 K=C_2\oplus M_2 K=C2⊕M2
得到 K K K 后,再解密第一段密文:
M 1 = C 1 ⊕ K = C 1 ⊕ ( C 2 ⊕ M 2 ) = C 1 ⊕ C 2 ⊕ M 2 \begin{aligned} M_1 &=C_1\oplus K\\ &=C_1\oplus(C_2\oplus M_2)\\ &=C_1\oplus C_2\oplus M_2 \end{aligned} M1=C1⊕K=C1⊕(C2⊕M2)=C1⊕C2⊕M2
所以最终的核心公式是:
M 1 = C 1 ⊕ C 2 ⊕ M 2 \boxed{M_1=C_1\oplus C_2\oplus M_2} M1=C1⊕C2⊕M2
这也可以理解为先计算:
C 1 ⊕ C 2 = M 1 ⊕ M 2 C_1\oplus C_2=M_1\oplus M_2 C1⊕C2=M1⊕M2
再异或一次已知明文 M 2 M_2 M2:
( C 1 ⊕ C 2 ) ⊕ M 2 = ( M 1 ⊕ M 2 ) ⊕ M 2 = M 1 ⊕ ( M 2 ⊕ M 2 ) = M 1 \begin{aligned} (C_1\oplus C_2)\oplus M_2 &=(M_1\oplus M_2)\oplus M_2\\ &=M_1\oplus(M_2\oplus M_2)\\ &=M_1 \end{aligned} (C1⊕C2)⊕M2=(M1⊕M2)⊕M2=M1⊕(M2⊕M2)=M1
因此,脚本中的两次异或并不是"碰运气",而是严格对应以下两个步骤:
- 计算 C 1 ⊕ C 2 C_1\oplus C_2 C1⊕C2,消去重复使用的密钥 K K K;
- 再与已知明文 M 2 M_2 M2 异或,消去 M 2 M_2 M2,最终只留下 M 1 M_1 M1。
不能直接计算 C 1 ⊕ M 2 C_1\oplus M_2 C1⊕M2。因为 C 1 = M 1 ⊕ K C_1=M_1\oplus K C1=M1⊕K,所以 C 1 ⊕ M 2 = M 1 ⊕ K ⊕ M 2 C_1\oplus M_2=M_1\oplus K\oplus M_2 C1⊕M2=M1⊕K⊕M2,其中的未知密钥 K K K 并没有被消去。
3.3 Exploit 脚本
为了让代码结构与数学推导一一对应,可以先编写一个通用的逐字节异或函数:
python
def xor_bytes(left: bytes, right: bytes) -> bytes:
"""对两个等长字节串逐字节进行 XOR。"""
if len(left) != len(right):
raise ValueError("XOR operands must have the same length")
return bytes(a ^ b for a, b in zip(left, right))
c1 = bytes.fromhex(
"5f70a847ce12759e156e3cad1aa9530a"
"119386a02ffc1c31bf14ab7a0a82ccc1"
"08f8476f75c98a28"
)
c2 = bytes.fromhex(
"5f70a847ce123cc153283ca710ae7f04"
"2b8490a238eb2228970fad6a2694f298"
"5dc5557e69e5f474"
)
known_m2 = b"litctf2026_xor_keystream_reuse_40bytes!!"
# 确保两段密文和已知明文长度一致,避免 zip 静默截断。
assert len(c1) == len(c2) == len(known_m2) == 40
# 第一步:C1 ^ C2 = M1 ^ M2,重复使用的密钥流 K 被消去。
m1_xor_m2 = xor_bytes(c1, c2)
# 第二步:(M1 ^ M2) ^ M2 = M1,利用已知明文恢复 Flag。
m1 = xor_bytes(m1_xor_m2, known_m2)
print(m1.decode("ascii"))
运行结果:
text
litctf{otp_reuse_never_twice_same_key__}
3.4 脚本为什么这样写?
① 为什么使用 bytes.fromhex()?
题目给出的密文是十六进制字符串,例如:
text
5f70a847ce12759e...
这里的字符 5f 表示一个字节 0x5f \texttt{0x5f} 0x5f,70 表示下一个字节 0x70 \texttt{0x70} 0x70。但 Python 不能直接对十六进制文本进行逐字节异或,所以需要先将其还原为真正的字节串:
python
c1 = bytes.fromhex("5f70a847ce12...")
例如:
python
bytes.fromhex("5f70") == b"\x5f\x70"
题目中的密文包含 80 个十六进制字符。由于两个十六进制字符表示一个字节,因此转换后共有:
80 2 = 40 bytes \frac{80}{2}=40\text{ bytes} 280=40 bytes
这与题目给出的 len = 40 一致。
② 为什么已知明文前面要加 b?
python
known_m2 = b"litctf2026_xor_keystream_reuse_40bytes!!"
Python 中普通的字符串属于 str 类型,而密文 c1、c2 经过 bytes.fromhex() 转换后属于 bytes 类型。
XOR 是针对整数或字节进行的运算,不能直接对 Python 字符串执行:
python
"a" ^ "b" # TypeError
在字符串前加上 b 后,它就会成为字节串:
python
b"A" == bytes([0x41])
由于题目明文全部由 ASCII 字符组成,所以每个可见字符都恰好对应一个字节,可以直接参与逐字节异或。
③ 为什么使用 zip(left, right)?
当遍历一个 bytes 对象时,Python 每次取出的元素都是一个范围在 0 0 0 到 255 255 255 之间的整数:
python
list(b"ABC")
# [65, 66, 67]
zip(left, right) 会按照相同下标将两个字节串配对:
text
left[0] <-> right[0]
left[1] <-> right[1]
left[2] <-> right[2]
...
因此:
python
bytes(a ^ b for a, b in zip(left, right))
完成的就是:
result i = left i ⊕ right i \text{result}i=\text{left}i\oplus\text{right}i resulti=lefti⊕righti
最后,bytes(...) 会把逐字节异或得到的整数重新组合成一个新的字节串。
需要注意的是,zip() 默认会在较短的序列结束时停止。如果两个操作数长度不同,代码不会自动报错,而是会悄悄丢弃较长序列的剩余部分。因此,脚本先检查:
python
if len(left) != len(right):
raise ValueError(...)
并额外使用:
python
assert len(c1) == len(c2) == len(known_m2) == 40
这样可以避免因数据复制错误或长度不一致而得到残缺结果。
④ 为什么第一次异或 c1 和 c2?
python
m1_xor_m2 = xor_bytes(c1, c2)
它对应的数学过程为:
C 1 ⊕ C 2 = ( M 1 ⊕ K ) ⊕ ( M 2 ⊕ K ) = M 1 ⊕ M 2 \begin{aligned} C_1\oplus C_2 &=(M_1\oplus K)\oplus(M_2\oplus K)\\ &=M_1\oplus M_2 \end{aligned} C1⊕C2=(M1⊕K)⊕(M2⊕K)=M1⊕M2
由于两段密文使用了同一段密钥流,两个 K K K 异或后变为 0 0 0。所以这一步的目的不是直接获得 Flag,而是先把未知密钥从等式中消去。
⑤ 为什么第二次再与 known_m2 异或?
python
m1 = xor_bytes(m1_xor_m2, known_m2)
第一次异或得到的是 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2。由于 M 2 M_2 M2 已知,再异或一次 M 2 M_2 M2:
( M 1 ⊕ M 2 ) ⊕ M 2 = M 1 (M_1\oplus M_2)\oplus M_2=M_1 (M1⊕M2)⊕M2=M1
两个 M 2 M_2 M2 相互抵消,最终得到第一段明文 M 1 M_1 M1,也就是 Flag。
⑥ 为什么最后需要 decode()?
python
print(m1.decode("ascii"))
异或运算恢复出的 m1 仍然是一个 bytes 对象。如果直接输出:
python
print(m1)
结果会带有字节串标记:
text
b'litctf{otp_reuse_never_twice_same_key__}'
调用 decode("ascii") 后,Python 会将字节串转换为正常字符串:
text
litctf{otp_reuse_never_twice_same_key__}
这里使用 ASCII 是因为 Flag 只包含英文字母、数字、下划线和花括号。使用默认的 UTF-8 解码也可以得到相同结果。
3.5 另一种写法:显式恢复密钥流
如果希望代码与"已知明文攻击"的过程更加直观,也可以先恢复密钥流 K K K:
python
key = xor_bytes(c2, known_m2) # K = C2 ^ M2
m1 = xor_bytes(c1, key) # M1 = C1 ^ K
print("key =", key.hex())
print("flag =", m1.decode("ascii"))
恢复出的密钥流为:
text
3319dc24ba740ef1611e63df7fdc206f4efde3d64a8e4345c87dc81f55f1adac6da72c0a0c96d555
再使用它与 C 1 C_1 C1 异或,同样可以恢复 M 1 M_1 M1。
两种写法本质相同:
M 1 = C 1 ⊕ K = C 1 ⊕ ( C 2 ⊕ M 2 ) = C 1 ⊕ C 2 ⊕ M 2 \begin{aligned} M_1 &=C_1\oplus K\\ &=C_1\oplus(C_2\oplus M_2)\\ &=C_1\oplus C_2\oplus M_2 \end{aligned} M1=C1⊕K=C1⊕(C2⊕M2)=C1⊕C2⊕M2
第一种写法更简洁,第二种写法更直观地展示了"已知明文泄露密钥流"的过程。
3.6 精简写法
理解原理后,也可以把三次逐字节异或合并到一个生成式中:
python
m1 = bytes(a ^ b ^ c for a, b, c in zip(c1, c2, known_m2))
print(m1.decode("ascii"))
这里每个位置计算的都是:
M 1 i = C 1 i ⊕ C 2 i ⊕ M 2 i M_1i=C_1i\oplus C_2i\oplus M_2i M1i=C1i⊕C2i⊕M2i
虽然这种写法更短,但在 Writeup 中建议先展示分步版本,因为分步变量 m1_xor_m2 能够更加清晰地对应数学推导。
3.7 结果验证
验证一:数据长度
三段数据的长度均为 40 字节:
python
print(len(c1), len(c2), len(known_m2), len(m1))
输出:
text
40 40 40 40
这说明异或过程中没有发生数据截断,恢复出的明文长度也与题目描述一致。
验证二:密文的相同前缀
观察两段密文,可以发现它们的前 12 个十六进制字符相同:
text
c1 = 5f70a847ce12 759e156e3cad...
c2 = 5f70a847ce12 3cc153283ca7...
两个十六进制字符表示一个字节,因此相同部分的长度是:
12 2 = 6 bytes \frac{12}{2}=6\text{ bytes} 212=6 bytes
所以准确地说,两段密文是前 6 个字节相同,而不是"前半部分完全相同"。
在使用相同密钥流的前提下,如果某个位置的两段明文相同,即:
M 1 i = M 2 i M_1i=M_2i M1i=M2i
那么对应密文也一定相同:
C 1 i = M 1 i ⊕ K i C 2 i = M 2 i ⊕ K i M 1 i = M 2 i ⟹ C 1 i = C 2 i \begin{aligned} C_1i&=M_1i\oplus Ki\\ C_2i&=M_2i\oplus Ki\\ M_1i=M_2i&\Longrightarrow C_1i=C_2i \end{aligned} C1iC2iM1i=M2i=M1i⊕Ki=M2i⊕Ki⟹C1i=C2i
恢复出的 Flag 和已知明文分别以如下内容开头:
text
M1 = litctf{...
M2 = litctf2026_...
它们共同的前缀正好是 6 字节的 litctf,因此两段密文的前 6 个字节也完全相同。
从第 7 个字节开始:
text
M1[6] = '{' = 0x7b
M2[6] = '2' = 0x32
两段明文不再相同,所以对应的密文字节也出现差异:
text
C1[6] = 0x75
C2[6] = 0x3c
并且满足:
0x75 ⊕ 0x3c = 0x7b ⊕ 0x32 = 0x49 \texttt{0x75}\oplus\texttt{0x3c}= \texttt{0x7b}\oplus\texttt{0x32}= \texttt{0x49} 0x75⊕0x3c=0x7b⊕0x32=0x49
这进一步验证了:
C 1 ⊕ C 2 = M 1 ⊕ M 2 C_1\oplus C_2=M_1\oplus M_2 C1⊕C2=M1⊕M2
验证三:Flag 格式
最终恢复出的内容为:
text
litctf{otp_reuse_never_twice_same_key__}
它满足比赛规定的 litctf{...} 格式,并且长度恰好为 40 字节,因此结果可信。
4. Flag
最终通过计算获取到的flag为:
text
litctf{otp_reuse_never_twice_same_key__}
5. 总结
本题是一道经典的 XOR 密钥流复用题,关键推导只有三步:
text
C1 = M1 ^ K
C2 = M2 ^ K
C1 ^ C2 ^ M2 = M1
需要记住的核心结论是:
- XOR 本身没有问题,错误在于重复使用同一段密钥流;
- 两段密文异或后,相同密钥流会被抵消;
- 如果其中一段明文已知,另一段明文可以被完整恢复;
- "One-Time" 的含义就是密钥只能使用一次。
Never reuse a one-time pad.
Key Takeaway
- One-Time Pad 的安全性完全依赖于密钥只使用一次。 密钥一旦复用,两密文异或即可消去密钥。
- 配合任意一段已知明文(known-plaintext attack),另一段明文即可完整恢复。
- 本题是 OTP 复用漏洞的经典入门示例:两段长度相同、密钥相同、一段明文已知,直接异或求解。
"Don't reuse the pad." --- 每个密码学入门者要学会的第一课。