mt19937 预测

在大概七月份的时候,我学习了 mt19937 的底层原理,并完全理解了随机数预测!!

Part 1: the basics

可能大家都听说过 mt19937 是很不安全的,有 624 个数字就可以预测后面的所有数。(这是我从某年的 cf 愚人节比赛里得知的)

那么为什么是这样子呢?

我们来仔细剖析一下 mt19937 的算法。

mt19937 的底层实际上是一个长度为 624 的数组作为状态,每次获取一个随机数后会将这个状态进行一些调整。

我们来看一下伪代码:

(代码来自 mersenne-twister-predictor 仓库)

N = 624
M = 397
MATRIX_A   = 0x9908b0df 
UPPER_MASK = 0x80000000 
LOWER_MASK = 0x7fffffff 
def generate(mt, kk):
    mag01 = [0x0, MATRIX_A]
    y = (mt[kk] & UPPER_MASK) | (mt[(kk + 1) % N] & LOWER_MASK)
    mt[kk] = mt[(kk + M) % N] ^ (y >> 1) ^ mag01[y & 0x1]
def tempering(y):
    y ^= (y >> 11)
    y ^= (y <<  7) & 0x9d2c5680
    y ^= (y << 15) & 0xefc60000
    y ^= (y >> 18)
    return y
def genrand_int32(mt, mti):
    generate(mt, mti)
    y = mt[mti]
    mti = (mti + 1) % N
    return tempering(y), mti

对的实际上就这么多。

这个流程可以拆分成两部分:

  1. 维护一个指针,每次将这个指针位置的状态进行一些修改,得到一个新的数,并移动指针。

  2. 将得到的数进行一次 tempering,返回结果。

第二部分其实是完全没有什么意义的,因为这个 tempering 函数是可逆的,所以我们完全可以把得到的数直接逆回去。

这里直接给出 untempering 函数:

def untempering(y):
    y ^= (y >> 18)
    y ^= (y << 15) & 0xefc60000
    y ^= ((y <<  7) & 0x9d2c5680) ^ ((y << 14) & 0x94284000) ^ ((y << 21) & 0x14200000) ^ ((y << 28) & 0x10000000)
    y ^= (y >> 11) ^ (y >> 22)
    return y

我们要关注的其实是第一部分。注意到很重要的一点是,我们在解决了第二部分的 untempering 后,实际上我们直接得到的就是 state 的每一位。那么,当我们获得连续的 624 个生成的随机数之后,自然也就获得了整个 state,那么显然就可以直接通过这个 state 得到后续的任意输出了。

当你仔细了解这个结果后,你就会发现,市面上(?)的 624 还原简直还是太没有什么技术含量了。(当年我第一次学习的时候在 github 上找到过一份代码(我找不到了,比较悲伤),当时他在测试里面还写了个“正确率”,导致我还以为这种东西不是 100% 预测的。现在发现那种人纯傻逼吧,害人不浅啊)

Part 2: the advanced

可是实际上我们会碰到很多情况,它根本不会给你连续的 624 个 32 位整数,可能存在一些数根本获取不到。这怎么办?

我们可以继续深挖一下这个算法。可以发现,每次生成新的数时,其实只使用了三个数:mt[k]mt[k+1]mt[k+M]。而且 mt[k] 实际上只用到了最高位,所以大部分情况下这一位都能暴力,所以新的数主要由 mt[k+1]mt[k+M] 决定。

这里面还有很多可以挖掘的点,就不一一说明了。有很多可以通过一些简单的实验或者仔细研读代码可以发现的东西,归根结底就是利用数学关系进行一些爆破即可。

Part 3: the ????

今天要写这篇博客的主要原因是,我才知道的这件事情。

你为啥跟我直接一把梭啊?!😨CTF里不是这样!😡你应该多分析算法☝,然后找到泄露的随机数的一些位。偶尔给我进行一些数学分析,然后在已知的数学关系上进行爆破☺️。最后在把得到的 state 放入 python random 中,预测随机数😮,我把密文解密出来☺️,然后把 flag 拿到交上去啊😃。你怎么直接上来就一把梭!😡?CTF里根本不是这样!😡我不接受!!😡😡😡

注意到上面的 mt19937 的所有操作都是位运算,操作简直线性完了,这本质上就是在 \(\mathrm{GF}(2)\) 上的一些线性方程组。所以我们的本质上就是在用 19968 个未知位和 19968 个 leak 的位建立线性方程组,解出线性方程组即可。

所以这玩意他妈能一把梭。

Introducing: gf2bv

这玩意好用的原因是他甚至帮你实现了 mt19937,所以你连上面的原理都不需要知道,只需要知道有 19968 个 leak 就行了。我草。

实操:

import random
import os
from gf2bv import LinearSystem
from gf2bv.crypto.mt import MT19937

rng = random.Random()

out = []

N = 2500

for i in range(N):
    rng.getrandbits(32) # 丢一个
    out.append(rng.getrandbits(32) & 0x00f0f000) # 给这一点位就够了吧!

# 一共 2500 * 8 = 20000 > 19968 位

# 一把梭启动!!
lin = LinearSystem([32] * 624)
mt = lin.gens()

rng_p = MT19937(mt)
zeros = []
for o in out:
    rng_p.getrandbits(32)
    zeros.append((rng_p.getrandbits(32) & 0x00f0f000) ^ o)

zeros += [mt[0] ^ 0x80000000] 
# 这个好像是因为 mt19937 初始化的算法里总是会把 mt[0] 初始化为 0x80000000,具体为啥我没研究

print("Solving...")

sols = lin.solve_all(zeros)

for sol in sols:
    rng_p = MT19937(sol)
    pyrand = rng_p.to_python_random()
    ans = 0
    # 验证一下解对不对
    for i in range(N):
        pyrand.getrandbits(32)
        veri = pyrand.getrandbits(32) & 0x00f0f000
        if veri == out[i]:
            ans += 1
    if ans == N:
        print("Found correct solution!")
        for i in range(100):
            assert rng.getrandbits(32) == pyrand.getrandbits(32)
        print("Checked successfully!")
        break

爽翻了给你。

Part 4: the aftermath

这个唯一解决不掉的就是 randrange,但是好像 z3 也能跑,具体我没研究。

https://github.com/Mistsuu/randcracks/tree/release/python_mt19937

后续在研究吧。

posted @ 2025-11-18 23:37  APJifengc  阅读(236)  评论(6)    收藏  举报