[LitCTF2026] lit_xor_two_story

LitCTF2026 lit_xor_two_story

  • [LitCTF2026 lit_xor_two_story](#[LitCTF2026] lit_xor_two_story)
    • [1. 题目描述](#1. 题目描述)
    • [2. 漏洞分析](#2. 漏洞分析)
    • [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 结果验证)
    • [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;
  • etaoin 等字母的出现频率通常更高;
  • 协议头、文件魔数、JSON 结构和 Flag 前缀往往具有固定格式。

攻击者可以据此实施 Crib Dragging(拖词攻击)

  1. 猜测其中一段明文可能包含某个常见单词或固定片段,例如 " the ""litctf{"
  2. 将猜测片段与 M 1 ⊕ M 2 M_1\oplus M_2 M1⊕M2 的不同位置逐一异或;
  3. 观察得到的另一段候选文本是否符合自然语言或目标格式;
  4. 如果结果合理,就以此为突破口继续扩展,逐步恢复两段明文。

假设攻击者猜测出 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

因此,脚本中的两次异或并不是"碰运气",而是严格对应以下两个步骤:

  1. 计算 C 1 ⊕ C 2 C_1\oplus C_2 C1⊕C2,消去重复使用的密钥 K K K;
  2. 再与已知明文 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 类型,而密文 c1c2 经过 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

这样可以避免因数据复制错误或长度不一致而得到残缺结果。


④ 为什么第一次异或 c1c2

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." --- 每个密码学入门者要学会的第一课。

相关推荐
hansang_IR1 小时前
【题解】[AGC020E] Encoding Subsets
c++·算法·dp
核数聚2 小时前
【赛迪专访核数聚】深耕数据治理,打通数据孤岛夯实 AI 发展根基
大数据·人工智能·算法
min(a,b)2 小时前
学习第 6 天:类型注解、装饰器与高级特性
python·学习
小许同学记录成长2 小时前
相对辐射定标技术文档
图像处理·算法
兮动人2 小时前
Python字符串类型
开发语言·python·python字符串类型
码云骑士2 小时前
80-指令微调Instruction-Tuning-指令数据构造-多任务训练-过拟合检测
python
白白白小纯2 小时前
算法篇—返回倒数第k个节点
c语言·数据结构·算法·leetcode
小羊先生car2 小时前
RTOS-F429-HAL-(动/静态)任务的创建(2026/7/27)
开发语言·算法·c#
无忧.芙桃3 小时前
数据结构之堆
c语言·数据结构·c++·算法·
爱昏羔3 小时前
上篇:从PDF到向量库 — 物流行业RAG系统的知识库构建全解析
python·langchain·pdf·agent·rag