1. 项目概述:从一道伪随机加密题切入CTF密码世界

如果你刚接触CTF(Capture The Flag,夺旗赛),面对五花八门的密码学题目可能会感到无从下手,尤其是那些看起来像天书一样的加密脚本。今天,我们就从一个非常经典的切入点开始——一道基于Python伪随机数生成器的加密题。这类题目在CTF的密码学(Crypto)类别中极为常见,它不像复杂的RSA或ECC那样需要深厚的数学背景,而是巧妙地利用了编程语言中一个看似普通却暗藏玄机的特性:伪随机数的可预测性。通过解这道题,你不仅能拿到Flag,更能深刻理解“在计算机的世界里,没有真正的随机”这句话,并顺带掌握解决此类问题所需的Python基础技能。这正是一个完美的起点,让你从“看题懵”到“能动手”,真正入门CTF密码题的实战。

这道题通常会给你一个用Python编写的加密脚本,以及一段被加密后的密文(通常就是Flag)。脚本的核心逻辑是:它使用Python内置的 random 模块生成一个随机数序列作为密钥,然后用这个密钥去异或(XOR)你的明文信息。出题人可能会贴心地把加密脚本给你,但不会给你密钥。新手的第一反应可能是:“随机数我怎么猜?” 这正是陷阱所在,也是我们要攻破的关键。我们将一步步拆解,你会发现,只要知道了加密时使用的随机种子(seed),就能完全复现出相同的“随机”密钥。而种子,往往就藏在题目描述、文件注释,或者加密脚本本身那些不起眼的细节里。

2. 核心原理拆解:伪随机数的“命门”与异或运算的妙用

2.1 伪随机数生成器(PRNG)为何可预测

我们需要彻底理解“伪随机”这个概念。计算机无法凭空产生真正的随机数,它需要一套确定的算法和一个初始值(即种子,seed)来生成一串看似随机的数字序列。在Python中, random.randint(a, b) 这类函数依赖的是梅森旋转算法(Mersenne Twister)。这个算法是确定的: 只要初始种子相同,无论何时何地运行,它生成的整个数字序列都将完全相同

这就好比给你一本庞大的、已经印好的“随机数表”。种子就是这本书的页码。如果我知道你用的是哪一页(种子),我手里有完全相同的另一本书,那么你接下来要念出的每一个“随机”数字,我都了如指掌。在CTF题目中,出题人常常会固定这个种子。固定种子的方式非常直接: random.seed(某个值) 。这个“某个值”可能是一个简单的数字(如 2024 , 0 ),一个字符串(如 "ctf" ),或者是当前时间戳等。一旦种子被固定,整个加密过程所使用的密钥流就从“不可预测”变成了“完全确定”。我们的核心攻击思路,就是找到或爆破出这个种子。

2.2 异或(XOR)加密与解密的神奇特性

这道题使用的加密算法几乎都是异或。异或运算在密码学中被称为“完美的对称加密”,在已知密钥的情况下,因为它有一个黄金特性: (A XOR K) XOR K = A 。换句话说,用密钥K对明文A加密得到密文C,那么再用同一个密钥K对密文C操作一次,就能完美还原出明文A。

在Python中,异或操作符是 ^ 。对于整数,它是按位异或;对于字符或字节,我们通常先将其转换为整数(ASCII码或直接字节值)进行异或,再转换回来。题目中的加密过程通常是这样的:生成一个随机整数密钥(比如在0-255之间),然后与明文字符的ASCII码逐位异或。由于异或的对称性, 解密过程和加密过程完全一样 。只要我们拿到了加密时使用的那个密钥序列,对着密文再来一遍异或,Flag就出来了。

2.3 题目常见模式与攻击面分析

典型的题目文件会包含以下部分:

  1. 加密脚本( encrypt.py :展示加密逻辑。里面一定有 random.seed(something) 和用 random.randint 生成密钥的循环。
  2. 输出文件( encrypted.txt ciphertext :一堆看起来乱码的数字或字符,这就是被加密后的Flag。
  3. 有时会有提示 :比如“种子是我的生日”、“种子是某个简单单词的哈希值”等。

攻击面主要集中在以下几点:

  • 种子信息泄露 :种子可能硬编码在脚本里(但被注释或混淆),也可能隐藏在文件元数据、图片隐写或简单的社工信息里。
  • 种子空间爆破 :如果种子是一个范围不大的整数(比如0-10000),或者是一个常见单词,我们可以写脚本暴力尝试所有可能的种子。
  • 已知明文攻击 :如果知道Flag的格式(例如 flag{ 开头),我们可以利用这部分已知的明文和对应的密文,反推出密钥流的前几位,进而验证或缩小种子搜索范围。

3. 实战环境准备与Python基础速成

3.1 Python环境快速搭建

你不需要成为一个Python专家,但需要一个能运行脚本的环境。这里推荐两种最快捷的方式:

方案一:使用在线Python环境(最快上手) 对于CTF新手,尤其是临时解题,在线环境免去了安装配置的烦恼。推荐 PythonTutor Replit 。你只需打开网站,将代码粘贴进去运行即可。这非常适合单文件脚本的调试和分析。

方案二:本地安装Python + VSCode(推荐长期使用) 如果你想持续学习,本地环境更强大。

  1. 安装Python :访问 python.org,下载最新稳定版(如3.11)。安装时务必勾选“Add Python to PATH”,这样可以在命令行直接使用 python 命令。
  2. 安装编辑器 :下载安装VSCode。在扩展商店搜索并安装“Python”扩展包,它会提供语法高亮、代码提示和调试功能。
  3. 测试 :打开VSCode,新建一个文件 test.py ,输入 print(“Hello CTF!”) ,按F5运行。如果终端成功输出,环境就配好了。

注意:很多CTF题目基于Python 2.x 和 3.x 的差异出题,尤其是 print 语句和除法运算。如今绝大多数新题都是Python 3,但遇到老题时需要注意。我们的示例均以Python 3为准。

3.2 本课必备的Python语法五分钟速览

你只需要掌握以下几个核心点,就能读懂和编写大部分解题脚本:

变量与类型

seed = 12345 # 整数
flag = “flag{this_is_a_test}” # 字符串
key_list = [] # 空列表,用于存密钥

字符串可以看作字符的列表,可以用 flag[0] 取第一个字符 ’f’

循环语句 for 循环是最常用的,用于遍历序列或重复操作。

# 遍历字符串中的每个字符
for char in “flag”:
    print(char) # 依次输出 f, l, a, g

# 循环固定次数(比如生成10个随机密钥)
for i in range(10):
    key = random.randint(0, 255)
    key_list.append(key)

range(n) 生成一个从0到n-1的整数序列。

条件判断 if 语句用于做逻辑判断。

if seed == 42:
    print(“Found the seed!”)
elif seed > 100:
    print(“Seed is too large.”)
else:
    print(“Keep searching...”)

列表与列表推导式 列表是存放数据的主力。列表推导式能让你一行代码生成列表,非常简洁。

# 传统方式:生成0-9的平方列表
squares = []
for i in range(10):
    squares.append(i*i)

# 列表推导式:效果完全相同,更简洁
squares = [i*i for i in range(10)]

函数定义 将一段代码块封装起来,便于重复使用。

def xor_decrypt(ciphertext, key_list):
    “”“传入密文字符串和密钥列表,返回解密后的明文”“”
    plaintext = “”
    for i in range(len(ciphertext)):
        # 将字符转ASCII码,与密钥异或,再转回字符
        p_char = chr(ord(ciphertext[i]) ^ key_list[i])
        plaintext += p_char
    return plaintext

文件读写 读取题目给的密文文件。

with open(‘encrypted.txt’, ‘r’) as f:
    ciphertext = f.read().strip() # .strip()用于去除首尾换行符和空格

掌握以上语法,你就能看懂并修改接下来的解题脚本了。

4. 案例实操:一步步破解一道伪随机加密题

假设我们拿到如下题目文件:

  • source.py (加密脚本)
  • ciphertext.txt (加密后的文本)

source.py 内容如下:

import random

flag = “flag{you_guessed_it!}” # 这是明文Flag,实际题目中不会给你
random.seed(100) # 种子固定为100

encrypted = “”
for char in flag:
    key = random.randint(0, 127) # 生成0-127的随机密钥
    encrypted_char = chr(ord(char) ^ key) # 异或加密
    encrypted += encrypted_char

print(“Ciphertext:”, encrypted)
# 输出结果被保存到了ciphertext.txt

ciphertext.txt 内容是一串乱码: ’x1b\x16\x0f\x1eO\x0b\x08\x1c\x00\x16\x11\x0e\x1d\x0c\x0b\x02\x17E\x0c\x0e\x0b’

我们的任务是:仅根据 source.py ciphertext.txt ,找出被隐藏的Flag。

4.1 第一步:分析加密脚本,定位关键点

首先,我们审视 source.py 。虽然里面直接写出了Flag明文,但我们要假装没看见,只关注加密逻辑。

  1. 种子 random.seed(100) 。这是最关键的信息!它告诉我们,随机数生成器的起始点被固定为数字 100
  2. 加密逻辑 :对Flag的每个字符,生成一个0-127的随机密钥,然后进行异或运算。
  3. 输出 :加密后的字符被拼接成字符串并输出。

结论:由于种子已知,我们可以在自己的环境中用相同的种子 100 初始化随机数生成器,从而复现出完全相同的密钥序列。然后用这个密钥序列去异或密文,即可解密。

4.2 第二步:编写解密脚本

我们新建一个 decrypt.py 文件。

import random

# 1. 读取密文
with open(‘ciphertext.txt’, ‘r’, encoding=‘utf-8’) as f:
    ciphertext = f.read().strip()

# 2. 用相同的种子初始化随机数生成器
random.seed(100) # 必须和加密脚本中的种子一模一样!

# 3. 生成密钥序列
# 密文有多长,我们就需要生成多少个密钥
key_list = []
for i in range(len(ciphertext)):
    key = random.randint(0, 127)
    key_list.append(key)

# 4. 利用异或的对称性进行解密
plaintext = “”
for i in range(len(ciphertext)):
    # 解密:密文字符 XOR 密钥字符
    p_char = chr(ord(ciphertext[i]) ^ key_list[i])
    plaintext += p_char

# 5. 输出结果
print(“Decrypted Flag:”, plaintext)

运行这个脚本,你将会直接得到输出: flag{you_guessed_it!} 。第一道题就这样解决了。

实操心得:在写解密脚本时,务必确保每一个细节都与加密脚本对齐。包括 random.randint 的范围(这里是0-127)、操作对象是字符的ASCII码。一个常见的错误是加密时用 ord(char) ^ key ,解密时却写成了 key ^ ord(char) ,虽然异或满足交换律结果一样,但保持顺序一致是良好的习惯。

4.3 第三步:应对未知种子的情况——爆破攻击

现实中的题目不会这么友好。加密脚本里的种子可能是一个未知数`random.seed(???)。这时,我们就需要“爆破”。

爆破的思路很简单:既然种子可能是一个不大的整数,我们就让计算机替我们一个一个地试。假设我们猜测种子在0到9999之间。

修改我们的 decrypt.py 为爆破版本:

import random

with open(‘ciphertext.txt’, ‘r’, encoding=‘utf-8’) as f:
    ciphertext = f.read().strip()

# 尝试0到9999的所有整数作为种子
for possible_seed in range(10000):
    # 为每个可能的种子重置随机状态
    random.seed(possible_seed)

    # 生成该种子对应的密钥流
    key_list = [random.randint(0, 127) for _ in range(len(ciphertext))]

    # 尝试解密
    plaintext = “”
    for i in range(len(ciphertext)):
        plaintext += chr(ord(ciphertext[i]) ^ key_list[i])

    # 判断解密结果是否有效:Flag通常有可读的格式
    # 例如,我们判断解密后的字符串是否以“flag{”开头
    if plaintext.startswith(“flag{“):
        print(f”Found it! Seed: {possible_seed}”)
        print(f”Flag: {plaintext}”)
        break # 找到后退出循环

这个脚本会从0开始尝试,一旦某个种子解密出的文本以 ”flag{“ 开头,就认为找到了正确的种子和Flag。对于10000种可能性,现代计算机瞬间就能完成。

注意事项:爆破的范围需要根据题目提示合理估计。如果毫无头绪,可以从0试到65535(2字节整数范围),或者常见数字如年份、简单哈希值等。如果种子是字符串,如 random.seed(“ctf”) ,则需要爆破字典里的常见单词。此时, seed 的参数是字符串的哈希值,但 random.seed(“ctf”) random.seed( hash(“ctf”) ) 在Python中效果是等价的,我们可以直接遍历字符串列表。

5. 深入技巧与高阶攻击手法

5.1 利用已知明文缩小爆破范围

如果Flag的格式已知(比如绝大多数CTF Flag格式为 flag{ FLAG{ ),我们就拥有了前5个字节的已知明文( f , l , a , g , { )。我们可以利用这一点,在完整解密前就提前判断种子是否正确,大幅提升爆破效率。

已知: 明文P XOR 密钥K = 密文C 。因此, 密钥K = 明文P XOR 密文C

我们可以先根据已知的明文前缀,反推出密钥流的前几位应该是什么。然后,在爆破种子时,不需要生成完整密钥并解密全部文本,只需要生成前几个随机数,看它们是否等于我们反推出来的密钥值。如果不匹配,立即跳过当前种子,尝试下一个。

import random

with open(‘ciphertext.txt’, ‘r’, encoding=‘utf-8’) as f:
    ciphertext = f.read().strip()

known_plaintext_prefix = “flag{“
# 反推出密钥前缀应该是什么
expected_key_prefix = []
for i in range(len(known_plaintext_prefix)):
    expected_key = ord(known_plaintext_prefix[i]) ^ ord(ciphertext[i])
    expected_key_prefix.append(expected_key)

for possible_seed in range(10000):
    random.seed(possible_seed)
    # 只生成前len(known_plaintext_prefix)个随机数进行比较
    match = True
    for i in range(len(expected_key_prefix)):
        if random.randint(0, 127) != expected_key_prefix[i]:
            match = False
            break
    if match:
        # 前缀匹配,再用这个种子完整解密一次
        random.seed(possible_seed) # 重置种子,从头开始
        full_key_list = [random.randint(0, 127) for _ in range(len(ciphertext))]
        plaintext = “”.join(chr(ord(ciphertext[i]) ^ full_key_list[i]) for i in range(len(ciphertext)))
        print(f”Found seed: {possible_seed}”)
        print(f”Flag: {plaintext}”)
        break

这种方法将每次尝试的计算量从“生成N个随机数+解密N个字符”降低到“生成M个随机数并比较”(M是已知明文长度,通常很小),效率提升成百上千倍。

5.2 处理非字符输出与编码问题

有时,出题人不会将加密后的字节直接转为字符输出,而是输出十六进制字符串或Base64编码。例如,密文可能是一串像 ”2a3b4c5d…” 的十六进制数。你需要先将其转换回原始的字节序列,再进行异或操作。

处理十六进制密文:

import random
import binascii

hex_ciphertext = “2a3b4c5d…” # 从文件读取的十六进制字符串
# 将十六进制字符串转换为字节对象
ciphertext_bytes = binascii.unhexlify(hex_ciphertext)

random.seed(known_seed)
# 现在ciphertext_bytes是字节串,需要用整数形式异或
plaintext_bytes = bytearray()
for i in range(len(ciphertext_bytes)):
    key = random.randint(0, 255) # 注意范围可能变为0-255
    plaintext_byte = ciphertext_bytes[i] ^ key
    plaintext_bytes.append(plaintext_byte)

# 将字节对象解码为字符串
flag = plaintext_bytes.decode(‘utf-8’)
print(flag)

处理Base64密文:

import random
import base64

b64_ciphertext = “LmNvb…” # 从文件读取的Base64字符串
ciphertext_bytes = base64.b64decode(b64_ciphertext)
# 后续解密步骤与处理十六进制相同

关键点:无论密文以何种形式给出,解题的第一步永远是将其还原为最原始的字节序列。异或操作是在字节(整数)层面进行的。

5.3 当随机数生成方式发生变化时

出题人可能会使用 random.getrandbits(8) 来生成一个8位的随机数(0-255),或者使用 random.choice() 从一个列表中选取密钥。原理不变:只要种子固定,这些“随机”选择序列也是固定的。你的解密脚本必须完全模仿加密脚本的密钥生成方式。

例如,如果加密脚本写的是:

key = random.getrandbits(8)

那么你的解密脚本中也要用 random.getrandbits(8) 来生成密钥。

6. 常见问题排查与调试技巧实录

即使理解了原理,在实战编写脚本时也难免会遇到各种“坑”。这里记录几个最常见的问题和解决方法。

问题1:解密出来是乱码,或者报 UnicodeDecodeError 错误。

  • 原因分析 :这通常意味着你的解密过程有误,得到的字节序列无法用默认的UTF-8编码解码成合法字符。可能的原因有:
    1. 种子错误。
    2. 加密/解密时 random.randint 的范围不一致(比如加密用0-255,解密用0-127)。
    3. 密文读取时包含了多余的字符(如换行符 \n )。
  • 排查步骤
    1. 打印中间值 :在解密循环中,打印出 i (索引)、 ciphertext[i] 的ASCII码、生成的 key 、以及异或后的结果。与加密脚本的输出进行对比(如果你有加密脚本的话)。
    2. 检查种子 :确认你使用的种子与加密时完全一致。如果是爆破,检查循环范围是否覆盖了正确种子。
    3. 检查范围 :仔细比对加密脚本中 random.randint 的参数。
    4. 清理输入 :使用 .strip() 方法处理读取的密文,去除首尾空白字符。

问题2:爆破脚本运行了很久都没结果。

  • 原因分析 :爆破空间太大,或者判断条件太严格/太宽松。
  • 优化策略
    1. 使用已知明文攻击 :如前所述,利用 flag{ 前缀可以极大加速。
    2. 调整判断条件 :除了检查开头,还可以检查解密结果中是否包含可读的单词(如 flag ctf )、是否只包含可打印字符( string.printable ),或者检查结尾是否有 }
    3. 并行计算 :如果种子空间巨大(如上百万),可以考虑使用Python的 multiprocessing 库进行多进程并行爆破。
    4. 理性估计 :根据题目描述猜测种子范围。如果是“我的幸运数字”,可能小于100;如果是“Unix时间戳”,那可能是10位数字。

问题3:题目给的密文是数字列表,而不是字符串。

  • 解决方案 :这种情况更简单。密文可能像 [123, 45, 67, …] 。你直接将其作为Python列表加载即可。
import ast
with open(‘ciphertext.txt’, ‘r’) as f:
    # 假设文件内容就是Python列表的文本形式
    cipher_numbers = ast.literal_eval(f.read())
# 然后直接对这些数字进行异或操作
plain_numbers = [c ^ key for c, key in zip(cipher_numbers, key_list)]
# 再将数字列表转为字符串
plaintext = “”.join(chr(num) for num in plain_numbers)

问题4:加密脚本使用了 random.shuffle() 等复杂操作。

  • 进阶挑战 :有些题目不直接用随机数异或,而是用随机数打乱一个字符表,然后进行替换加密。这增加了难度,但核心不变:只要种子固定,打乱的顺序就固定。你需要在自己的脚本里用相同种子初始化后,调用 random.shuffle 得到相同的乱序表,然后进行逆向的查找还原。

调试心法 :当你卡住时,回归本源。写一个最简单的、已知种子和明文的“加密-解密”自验证脚本。确保你的加解密函数能完美还原。然后再用这个解密函数去套题目。这能帮你隔离问题,确定是逻辑错误还是数据输入错误。

7. 举一反三:伪随机数在CTF中的其他出题形式

掌握了基于种子的伪随机数预测,你就解锁了一类广泛的CTF密码题。除了简单的异或,它还可能以以下形式出现:

  1. 随机流密码(Stream Cipher) :本题就是最简单的流密码。更复杂的流密码(如RC4)也依赖于密钥流,如果密钥流生成器的种子被弱化或泄露,同样可被攻击。
  2. 随机数验证(Predictable Nonce) :在数字签名或一些协议中,如果随机数(nonce)可预测,会导致密钥泄露。例如,ECDSA签名中如果nonce重复或可预测,可以直接解出私钥。
  3. 基于随机数的访问控制 :Web题目中,可能会用随机数生成一个临时的访问令牌(token)。如果这个随机数生成器被预测,攻击者就可以伪造令牌。
  4. 随机数种子与系统状态相关 :有的题目会用 random.seed(int(time.time())) ,即用当前时间戳做种子。如果加密时间可以大致推测(比如题目发布时间),就可以在一个小的时间窗口内进行爆破。

理解伪随机数的确定性,是理解许多现代密码系统实现中“旁路”和“弱点”的基础。这道入门题为你打开了一扇门,门后是一个将编程、逻辑思维和密码学常识紧密结合的趣味世界。下次当你看到 random.seed 时,你会立刻意识到:这里可能藏着解题的钥匙。

更多推荐