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 為什麼會失敗。