Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

随机数:加密系统中最被低估的组件

| , 9 minutes reading.

1. 为什么要关心这个问题?

2010 年,PlayStation 3 的安全系统被完全破解。不是因为加密算法有问题,而是因为 Sony 在数字签名时用了一个固定的「随机」数

# Sony 的致命错误(简化版)
def sign_game(game_data):
    k = 4  # 这个数字应该每次都不同!
    signature = ecdsa_sign(game_data, private_key, k)
    return signature

结果?任何人都可以计算出 Sony 的私钥,为任意代码签名,在 PS3 上运行盗版游戏和自制软件。

一个「随机数」不随机,整个安全系统崩溃。

这不是个案。从 Debian OpenSSL 漏洞(2008)到 Android Bitcoin 钱包被盗(2013),历史上无数安全灾难都源于随机数问题。

2. 定义

随机数在密码学中指的是不可预测的数值,用于生成密钥、初始化向量(IV)、nonce、盐值等。

密码学安全的随机数必须具备:

  • 不可预测性: 即使知道之前生成的所有数字,也无法预测下一个
  • 不可重现性: 相同的条件不会产生相同的序列
  • 均匀分布: 所有可能的值出现概率相等

关键区别:

  • 真随机数(TRNG): 来自物理现象,真正不可预测
  • 伪随机数(PRNG): 算法生成,看起来随机但实际上是确定性的
  • 密码学安全伪随机数(CSPRNG): 特殊设计的 PRNG,即使知道部分输出也无法预测其他输出

3. 为什么随机数这么重要

密钥生成

密钥 = random(256 bits)

如果 random() 可预测:
- 攻击者可以猜测密钥
- 加密形同虚设

一个 256 位的 AES 密钥有 2^256 种可能。但如果你的随机数生成器只能生成 2^32 种不同的密钥(因为种子只有 32 位),攻击者只需要尝试 42 亿次——几小时就能完成。

初始化向量(IV)

AES-CBC 加密:
ciphertext = AES_CBC(plaintext, key, IV)

如果 IV 可预测:
- 攻击者可以进行选择明文攻击
- 即使密钥安全,加密仍可能被破解

数字签名中的 Nonce

这就是 Sony PS3 被破解的原因:

ECDSA 签名:
signature = sign(message, private_key, k)

如果 k 重复使用:
private_key = (message1 - message2) / (signature1 - signature2)
              ↑ 直接计算出私钥!

4. rand() 为什么会害死人

标准函数库的 rand() 不是为安全设计的

// C 语言的 rand() - 绝对不要用于密码学!
int seed = time(NULL);  // 种子是当前时间
srand(seed);
int key = rand();  // 「随机」密钥

问题:

  1. 种子太小: 通常只有 32 位,最多 42 亿种可能
  2. 种子可预测: time(NULL) 是当前秒数,攻击者大概知道你何时生成密钥
  3. 算法简单: 线性同余生成器(LCG),数学上容易逆推
# Python 的 random 模块 - 同样不安全!
import random
random.seed()  # 使用系统时间
key = random.getrandbits(256)  # 不要这样做!

真实案例:Debian OpenSSL 灾难(2008)

// 有人「修复」了这段代码中的警告
MD_Update(&m, buf, j);  // 被删除
      ^
      |
    Valgrind 说这个变量未初始化

MD_Update(&m, &(md_c[0]), sizeof(md_c));  // 保留

这个「修复」移除了熵的主要来源。结果:

  • OpenSSL 只能生成 32,768 种不同的密钥
  • 所有在受影响系统上生成的 SSH 和 SSL 证书都可以被暴力破解
  • 影响了数百万台 Debian/Ubuntu 服务器

5. 真随机 vs 伪随机

真随机数(TRNG)

来源:物理现象

  • 放射性衰变
  • 热噪声
  • 量子效应
  • 鼠标移动、键盘时序
物理现象 → 测量 → 数字化 → 真随机比特

优点:真正不可预测 缺点:慢、需要特殊硬件、比特率有限

伪随机数(PRNG)

种子 → 算法 → 看起来随机的序列
# 简化的 PRNG 算法
def prng(seed):
    state = seed
    while True:
        state = (state * 1103515245 + 12345) % 2**31
        yield state

优点:快、可重现(对测试有用) 缺点:完全确定性——知道算法和种子就知道全部输出

密码学安全伪随机数(CSPRNG)

专门为安全设计的 PRNG:

熵池 ──► CSPRNG ──► 安全的随机输出

持续收集熵

特性:

  • 即使看到部分输出,也无法预测其他输出
  • 即使内部状态被泄漏,也无法恢复之前的输出
  • 持续从环境收集熵来更新内部状态

6. 操作系统的随机性来源

Linux: /dev/random vs /dev/urandom

# 阻塞式 - 熵不足时会等待
head -c 32 /dev/random

# 非阻塞式 - 总是立即返回
head -c 32 /dev/urandom

现代建议:几乎总是使用 /dev/urandom

历史上的担忧(已过时):

  • /dev/random 「更安全」因为会等待真熵
  • /dev/urandom 可能「熵不足」

现实:

  • 现代 Linux 的 urandom 使用相同的熵池
  • 系统启动后,urandom 在密码学上与 random 一样安全
  • /dev/random 的阻塞只会造成问题(DoS、性能)

熵的来源

┌─────────────────────────────────────────────────────────────┐
│ 硬件熵来源                                                  │
├─────────────────────────────────────────────────────────────┤
│ - CPU 时序抖动                                              │
│ - 硬件随机数生成器(RDRAND/RDSEED 指令)                    │
│ - TPM(可信平台模块)                                       │
│ - 专用熵生成硬件                                            │
├─────────────────────────────────────────────────────────────┤
│ 软件熵来源                                                  │
├─────────────────────────────────────────────────────────────┤
│ - 中断时序                                                  │
│ - 磁盘 I/O 时序                                             │
│ - 网络数据包时序                                            │
│ - 键盘/鼠标事件                                             │
└─────────────────────────────────────────────────────────────┘

Windows: CryptGenRandom / BCryptGenRandom

// Windows API
BYTE buffer[32];
BCryptGenRandom(NULL, buffer, 32, BCRYPT_USE_SYSTEM_PREFERRED_RNG);

macOS/iOS: SecRandomCopyBytes

var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)

7. 各语言的正确用法

Python

import secrets  # Python 3.6+,专为密码学设计

# 生成安全的随机字节
key = secrets.token_bytes(32)  # 256 位密钥

# 生成安全的十六进制字符串
token = secrets.token_hex(32)  # 64 字符的 hex 字符串

# 生成 URL 安全的 token
url_token = secrets.token_urlsafe(32)

# 在范围内选择安全的随机数
dice = secrets.randbelow(6) + 1  # 1-6

# 安全的随机选择
password_char = secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789')
# 绝对不要这样做!
import random
key = random.getrandbits(256)  # 不安全!

Node.js

const crypto = require('crypto');

// 生成安全的随机字节
const key = crypto.randomBytes(32);

// 生成安全的 UUID
const { randomUUID } = require('crypto');
const uuid = randomUUID();

// 生成范围内的安全随机数
function secureRandomInt(min, max) {
    const range = max - min;
    const bytesNeeded = Math.ceil(Math.log2(range) / 8);
    const randomBytes = crypto.randomBytes(bytesNeeded);
    const randomValue = parseInt(randomBytes.toString('hex'), 16);
    return min + (randomValue % range);
}
// 绝对不要这样做!
const key = Math.random() * 2**256;  // 不安全!

Go

import (
    "crypto/rand"
    "encoding/hex"
)

// 生成安全的随机字节
func generateKey() ([]byte, error) {
    key := make([]byte, 32)
    _, err := rand.Read(key)
    if err != nil {
        return nil, err
    }
    return key, nil
}

// 生成安全的十六进制字符串
func generateToken() (string, error) {
    bytes := make([]byte, 32)
    _, err := rand.Read(bytes)
    if err != nil {
        return "", err
    }
    return hex.EncodeToString(bytes), nil
}
// 绝对不要这样做!
import "math/rand"
key := rand.Int63()  // 不安全!

Rust

use rand::Rng;
use rand::rngs::OsRng;

fn main() {
    // 使用 OS 的 CSPRNG
    let mut key = [0u8; 32];
    OsRng.fill(&mut key);

    // 生成范围内的安全随机数
    let n: u32 = OsRng.gen_range(1..=100);
}

8. 常见错误与防范

错误 1:使用时间作为种子

# 错误
import random
random.seed(int(time.time()))
key = random.getrandbits(256)

# 攻击者知道你大概何时生成密钥
# 只需要尝试那段时间内的所有秒数

错误 2:重复使用 nonce/IV

# 错误
iv = b'0' * 16  # 固定 IV
for message in messages:
    ciphertext = aes_cbc_encrypt(message, key, iv)  # 相同 IV!

错误 3:缩小随机数范围

# 错误
import secrets
key = secrets.randbelow(1000000)  # 只有 100 万种可能!

# 正确
key = secrets.token_bytes(32)  # 2^256 种可能

错误 4:在虚拟机快照后不重新播种

VM 快照 → 恢复 → 生成「随机」数

         和上次快照后生成的一样!

某些 CSPRNG 在 VM 恢复后需要重新收集熵。

错误 5:自己实现随机数生成器

# 错误:「我设计了一个更好的算法」
def my_random():
    global state
    state = (state * 31337 + 12345) ^ (time.time_ns() % 256)
    return state

# 这几乎肯定会有安全问题

9. 测试随机数品质

基本统计测试

import secrets
from collections import Counter

# 生成大量随机数
samples = [secrets.randbelow(256) for _ in range(100000)]

# 检查分布
counter = Counter(samples)
for i in range(256):
    expected = 100000 / 256
    actual = counter[i]
    if abs(actual - expected) / expected > 0.1:  # 超过 10% 偏差
        print(f"警告:值 {i} 的分布异常")

NIST 随机数测试套件

# 使用 NIST SP 800-22 测试套件
# https://csrc.nist.gov/projects/random-bit-generation/documentation-and-software

./assess 1000000  # 测试 100 万比特

测试项目包括:

  • 频率测试(0 和 1 的比例)
  • 块频率测试
  • 游程测试
  • 最长游程测试
  • 矩阵秩测试
  • 离散傅立叶变换测试
  • 等等…

10. 本章小结

三点要记住:

  1. 永远使用 CSPRNG。 在任何涉及安全的场景——密钥、IV、nonce、盐值、token——都必须使用密码学安全的随机数生成器。Python 用 secrets,Node.js 用 crypto.randomBytes,绝对不要用 randomMath.random()

  2. 随机数的品质决定加密的强度。 一个 256 位的密钥只有在每一位都是真正随机的情况下才有 2^256 的安全性。如果生成器有缺陷,实际安全性可能只有 2^32 或更低。

  3. 重复使用是致命的。 在 ECDSA 中重复 nonce 会直接泄漏私钥。在 AES-CTR 中重复 nonce 会让攻击者用 XOR 恢复明文。每个随机数都必须是一次性的。

11. 下一步

我们已经完成了密码学基础的五个核心概念:加密不等于安全、密码学解决的问题、对称与非对称加密、哈希函数、以及随机数。

在下一部分,我们将深入对称加密的工程实践:从 DES 说起——分组加密的基本思想,为什么要分组,以及 DES 为什么会失败。