预测伪随机数:用 Z3 反算 rand() 的种子#

本章的内容#

伪随机数看上去是随机的,但只要知道它的生成方式,就可以预测后面的值,本章以 Visual C++ 的 rand() 为例。

  • rand() 由线性同余法生成,即 LCG

  • 只要知道几个输出,就可以用 Z3 反算出内部状态,即种子

  • 从反算出的种子出发,预测下一个值

BitVec 即固定长度的位向量,与 URem 即无符号取余,是本章用到的两个工具。

线性同余法#

线性同余法是生成伪随机数的一种方法,用下面的递推式产生下一个状态。

\[ X_{n+1} = \left(A \times X_n + B\right) \bmod M \]

\(A\)、\(B\)、\(M\) 是常数,通常满足下面的条件。这四条是常数取值的最低要求;周期能有多长,还取决于几个常数之间的配合。

  • \(M > A\)

  • \(M > B\)

  • \(A > 0\)

  • \(B \geq 0\)

若 \(M = 2^{32}\),这个式子就相当于 32 位整数的乘法与加法之后,丢掉溢出的部分。这一步在 32 位机器上不需要额外的代码。

Visual C++ 的 rand()#

Visual C++ 的 rand() 使用线性同余法,源代码如下。

static long holdrand = 1L;

int __cdecl rand(void)
{
    return (((holdrand = holdrand * 214013L + 2531011L) >> 16) & 0x7FFF);
}

void __cdecl srand(unsigned int seed)
{
    holdrand = (long)seed;
}

这段代码只做两件事,下面分别说明。第二件事决定输出的位数,也决定反算的难度。

  1. 更新内部状态 holdrand

\[ \text{holdrand} = (\text{holdrand} \times 214013 + 2531011) \bmod 2^{32} \]
  1. 取出高 16 位,作为 0〜32767 的整数返回,上限就是 0x7FFF

输出只是内部状态的一部分,因此从输出反算状态,本质上是一个信息是否够用的问题,而不是纯数学上的技巧。每次输出只给出 15 位,32 位状态剩下的部分要靠后续输出逐步补齐。也正因如此,rand() 不适合作加密用途:输出量太少,状态容易被还原。

下面准备一个行为与 rand() 相同的 Python 类。

class PseudoRandom:
    """和 Visual C++ 的 rand() 行为相同的伪随机数"""

    def __init__(self, seed=1):
        self._holdrand = seed

    def srand(self, seed: int):
        self._holdrand = seed

    def rand(self) -> int:
        self._holdrand = (self._holdrand * 214013 + 2531011) & 0xFFFFFFFF
        return (self._holdrand >> 16) & 0x7FFF

生成 8 个随机数#

取种子 1792,生成 8 个 rand() % 100,即 0〜99 的整数,第 9 个留作验证用。取余 100 是为了模拟只看到部分信息的情形。

rand = PseudoRandom(1792)
numbers = [rand.rand() % 100 for _ in range(8)]
print(numbers)
print(rand.rand() % 100)
[90, 79, 97, 7, 20, 77, 17, 89]
16

用 Z3 反算种子#

下面只用 numbers 中的 8 个值,推断第 9 个值。

内部状态是 32 位的,因此用一个 BitVec('seed', 32) 变量来表示,再把 rand() 的计算原样写成约束。

  1. states[0] 是种子推进一次之后的状态,states[1] 是再下一次,依此类推

  2. 相邻的状态满足 state_{i+1} = state_i * a + b,32 位会自然回绕,乘法溢出被自动丢掉

  3. 由各状态取出的输出 (state >> 16) & 0x7FFF 除以 mod 的余数,与观察到的 numbers 相等

由于只知道除以 mod 的余数,状态本身未必能唯一确定,但能产生同一数列的种子总能找到,只是未必只有一个。余数丢掉的信息越多,候选种子就越多。

from z3 import *


def find_seed_and_next(numbers, mod, a=214013, b=2531011):
    """从观测到的输出序列,求出种子和下一个输出"""
    seed = BitVec('seed', 32)
    states = [BitVec(f's_{i}', 32) for i in range(len(numbers) + 1)]
    outputs = [URem((s >> 16) & 0x7FFF, mod) for s in states]

    solver = Solver()

    # 1. 状态的转移(LCG)
    for prev, cur in zip([seed] + states[:-1], states):
        solver.add(prev * a + b == cur)

    # 2. 和观测到的输出一致
    for out, observed in zip(outputs, numbers):
        solver.add(out == observed)

    if solver.check() != sat:
        return None

    model = solver.model()
    return model[seed].as_long(), model.eval(outputs[-1]).as_long()


seed_val, next_val = find_seed_and_next(numbers, mod=100)
print(f'seed = {seed_val}')
print(f'next = {next_val}')
seed = 1792
next = 16

原来的种子 1792 被找了出来,下一个值也预测为 16,而实际的第 9 个值同样是 16,与上面单元格的输出对照即可。

下面用找到的种子重新生成一次随机数列,确认两次的结果是否一致。

rand = PseudoRandom(seed_val)
[rand.rand() % 100 for _ in range(9)]
[90, 79, 97, 7, 20, 77, 17, 89, 16]

完全相同的 9 个数列了出来,预测成功。这个问题的解是 1792 与 2147485440 两个,两者都能产生同一数列,区别只在不影响输出的高位上,输出无法区分它们。

应用:用骰子连续掷出 10 个 6#

用 rand() % 6 + 1 掷骰子时,本节来寻找能连续掷出 10 个 6 的种子,而要得到 6,只需 rand() % 6 == 5,因此把 [5] * 10 作为观察值传入。这与反算种子是同一个框架:把观察到的结果写成约束,让 Z3 去找满足它的种子。

seed_val, next_val = find_seed_and_next([5] * 10, mod=6)
print(seed_val)

rand = PseudoRandom(seed_val)
[rand.rand() % 6 for _ in range(11)]
3689596556
[5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0]

连续 10 次得到 5、也就是骰子掷出 6 的种子被找到了,不过条件里只写入了最初 10 次,第 11 次并未写入,因此第 11 次的输出未必还会是 5,这次运行得到的是 0。若要让 11 次连续都出 6,把 [5] * 11 作为条件即可;另外,满足条件的种子并不只有一个,每次运行都可能找到不同的种子。

BitVec 与 URem#

BitVec 是 Z3 中固定长度位向量的类型。

  • BitVec('x', 32) 按 32 位整数的方式工作

  • 乘法与加法溢出的高位会被丢掉,与 C 语言的符号无关;位向量的长度决定模数,32 位就等价于对 \(2^{32}\) 取模

  • 可以使用 &、|、^、>>、<< 等位运算

URem(a, b) 是无符号取余,即 a % b。BitVec 没有符号,因此取余要用 URem() 而不用 %。

把 rand() 的代码原样照抄过来,只需要这两个工具。C 里的类型转换与符号处理在这里都不需要,位向量的长度已经固定。

C 的代码

Z3 的表达式

holdrand * 214013 + 2531011

prev * 214013 + 2531011

丢掉 32 位溢出的部分

BitVec 的运算,自动完成

holdrand >> 16

s >> 16

& 0x7FFF

& 0x7FFF

% mod

URem(..., mod)

小结#

  • 只要知道随机数的生成式,就可以从输出反算内部状态,把生成式直接写成 BitVec 即可

  • 输出只是状态的一部分时,种子的候选可能不止一个

  • 用反算出的种子就能预测下一个值

  • 像连续 10 次出 6 这样的条件,也只要作为观察值传入,就能搜索种子