🎲

Pythonのrandom.random()を完全に予測してみた

に公開

こんにちは。初めましての方は初めまして。私の今までの記事を読んだことのある人なら、進捗どうですか? とか 話題に便乗してクソ記事書きやがって とか思ってるんじゃないかなぁと思います。

前者に関しては最近モチベーションが無くてあんまり進捗が無いです。後者に関しては...私もそう思います。需要あるかなって。

今回はちょっとしたお遊びで Python の random.random() の値を 外部プロセスから完全に予測する 実験に成功したので、そのアプローチを紹介します。

なお、本記事で紹介している mt-predictor のソースコードは全て GitHub で公開していますので興味のある方はぜひ試してみてください (Docker で簡単に再現できますよ!):

https://github.com/t3tra-dev/mt-predictor

Star や Share 等して頂けると嬉しいです。

1. はじめに

Python で最も手軽に使える乱数関数といえば random.random() ですよね。
Python を書いたことのある人なら、一度は使ったことがあるでしょう。

しかしこの乱数は、暗号学的には安全ではない (CSPRNG ではない) という事実をご存知でしょうか?

実際、random モジュールの生成する値は、条件が揃えば外部から予測が可能です。

具体的な CPython 内部の実装はソースコード(Lib/random.py, Modules/_randommodule.c)を参照して頂くとして、本編では実行中の Python プロセスが次に出力する random.random() の値を外部プロセスから完全に予測してみた、という実験の記録を共有します。

2. random モジュールは安全なのか?

Python の random.random()Mersenne Twister (MT19937) を使用しています。
これは統計的な性質は非常に優れており、科学技術計算やシミュレーションに広く使われています。

しかし暗号の世界では、以下のような理由で不適格とされています:

  • 内部状態 (約2.5KB) を完全に観測されると、以降の出力を全て予測可能
  • 時系列上の相関や初期化の脆弱性 (時刻ベースシード) などがある
  • 「出力から内部状態を逆算できてしまう」という本質的な性質

公式ドキュメントでもこのように明言されています:

警告: このモジュールの擬似乱数生成器をセキュリティ目的に使用してはいけません。セキュリティや暗号学的な用途については secrets モジュールを参照してください。

これらは JavaScript の Math.random() などでも同様のことが言えますね。

Math.random() | MDN Web Docs より:

メモ: Math.random() の提供する乱数は、暗号に使用可能な安全性を備えていません。セキュリティに関連する目的では使用しないでください。代わりにウェブ暗号 API (より具体的には Crypto.getRandomValues() メソッド) を使用してください。

3. CPython の random.random() の実装

CPython の実装では Modules/_randommodule.c 内の RandomObject 構造体に次のような内部状態が保持されます:

typedef struct {
    PyObject_HEAD         // 16 byte
    int index;            // 4 byte
    uint32_t state[624];  // 624 個の 32 bit 整数
} RandomObject;

つまり、この構造体の中身を外部から読み取ることができれば、次の乱数値を正確に再現できる ということです。

3.1. Mersenne Twister の基礎

MT19937 は1996年に発表(1998年1月に論文掲載)された非常に広く使われている擬似乱数生成器であり、次ような特徴を持ちます:

  • 内部状態: 624 個の32bit整数 (= 約2.5KB)
  • 周期: 2^{19937} − 1 (非常に長い)
  • 出力の一貫性と高速性

しかし、内部状態が全て分かれば、次の出力を完全に再現できるというのが最大の弱点です(これは殆どの疑似乱数生成器にも言えることですが)。

3.2. Pythonの状態保持方式

Pythonではすでに初期化された 624個の state[] + index値 を直接内部に保持しています。

random.random() は、この配列と index によって次の出力を決定しているという訳ですね。

4. 環境と前提

この実験を成立させるためには、以下の条件を満たす必要があります:

  • 対象OS: Linux 系で、/proc/<pid>/mem が有効
  • 権限: 対象プロセスと同一ユーザー、または sudo 可能
  • タイミング: random.random() を呼び出す前に観測が完了していること

5. 実装の仕組み

今回の C プログラムでは以下のような手順を踏んで、Python の RandomObject の状態を外部から抜き出し、Mersenne Twister の再現を行っています。

5.1. 手順

  1. /proc/<pid>/maps を読み込み、rw-p 属性のメモリセグメントを抽出
  2. /proc/<pid>/mempread() で読み取り、候補領域をスライディングサーチ
  3. 16byte の PyObject_HEAD + 4byte の index + 2496byte の state を持つ構造を探す
  4. state[0] == 0x80000000U 等 Python の初期化パターンであることを確認
  5. indexstate[624] を使って Mersenne Twister の状態を復元
  6. random.random() に相当する 53 bit 精度の浮動小数点値を計算
  7. 実際の Python 側出力と比較して一致を確認

5.2. 安全性向上のためのチェック

  • index の妥当性 (0 ~ 624)
  • state[0] が MT初期化時の既知値 (例: 0x80000000)
  • 出力される値が極端な 0.0 や 1.0 に近すぎない

GitHub 上でも公開していますが、mt_predictor.c は以下のようになっています:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/uio.h>
#include <errno.h>
#include "mt19937ar.h"

#define PY_HEAD       16       // PyObject_HEAD size
#define IDX_BYTES      4
#define STATE_WORDS  624
#define STATE_BYTES (STATE_WORDS * sizeof(uint32_t))
#define CAND_BYTES  (PY_HEAD + IDX_BYTES + STATE_BYTES)

static inline double python_random(void) {
    uint32_t a = genrand_int32() >> 5;
    uint32_t b = genrand_int32() >> 6;
    return (a * 67108864.0 + b) / 9007199254740992.0;
}

static int try_candidate(uint8_t *base, size_t off) {
    int idx = *(int *)(base + off + PY_HEAD);
    if (idx < 0 || idx > STATE_WORDS) return 0;

    uint32_t *st = (uint32_t *)(base + off + PY_HEAD + IDX_BYTES);
    if (st[0] != 0x80000000U) return 0;  // Python特有の初期状態チェック

    mt_set_state(st, idx);

    double r1 = python_random();
    double r2 = python_random();
    if (r1 <= 1e-12 || r1 >= 0.999999999999) return 0;

    double r3 = python_random();
    printf("[+] state @ offset 0x%zx\n", off);
    printf("    next[1] random.random() = %.17f\n", r1);
    printf("    next[2] random.random() = %.17f\n", r2);
    printf("    next[3] random.random() = %.17f\n", r3);
    return 1;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <pid>\n", argv[0]);
        return 1;
    }
    pid_t pid = atoi(argv[1]);
    char maps_path[64], mem_path[64];
    snprintf(maps_path, sizeof maps_path, "/proc/%d/maps", pid);
    snprintf(mem_path, sizeof mem_path, "/proc/%d/mem", pid);

    FILE *maps = fopen(maps_path, "r");
    if (!maps) { perror("fopen maps"); return 1; }

    int memfd = open(mem_path, O_RDONLY);
    if (memfd < 0) { perror("open mem"); fclose(maps); return 1; }

    char line[256];
    while (fgets(line, sizeof line, maps)) {
        unsigned long start, end;
        char perm[5];
        if (sscanf(line, "%lx-%lx %4s", &start, &end, perm) != 3)
            continue;
        if (strstr(perm, "rw") == NULL) continue;

        size_t region = end - start;
        uint8_t *buf = malloc(region);
        if (!buf) continue;

        if (pread(memfd, buf, region, start) != (ssize_t)region) {
            free(buf);
            continue;
        }

        for (size_t off = 0; off + CAND_BYTES <= region; off += 4) {
            if (try_candidate(buf, off)) {
                free(buf);
                close(memfd);
                fclose(maps);
                return 0;
            }
        }
        free(buf);
    }

    puts("[-] MT state not found.");
    close(memfd);
    fclose(maps);
    return 1;
}

6. 結果と検証

検証用に以下のような Python プログラムを記述しました:

import random
import time
import os

print(f"PID: {os.getpid()}")

time.sleep(10)

print(f"random.random(): {random.random()}")

victim.py という名前で保存しておきます。

ビルド等の手順は (GitHub の README に記載しているので) 割愛しますが、例えばこのような結果が得られると思います。

root@920d7178d451:/app# python3 victim.py &
[1] 8
root@920d7178d451:/app# PID: 8
./mt_predictor 8
[+] state @ offset 0x84b0
    next[1] random.random() = 0.23343851299819518
    next[2] random.random() = 0.28635628575684968
    next[3] random.random() = 0.14805106308346927
root@920d7178d451:/app# random.random(): 0.23343851299819518
[1]+  Done                    python3 victim.py
root@920d7178d451:/app# 

next[1] と完全に一致しましたね。
お察しの通り next[2] では 2 回目の random() 呼び出しを、next[3] では 3 回目の random() 呼び出しの結果を推測しています。

このように、外部プロセスからでも random.random() の出力を事前に予測できることが確認できました。

7. 考察と限界

7.1 成功する条件

  • Python プロセスが乱数をまだ消費していない (index がズレていない)
  • シンプルなコード (GC やオブジェクトのデストラクトが影響しない)
  • 権限のある Linux 環境での実行 (同一ユーザーや sudo など)

7.2. 限界と注意点

  • random.random() をすでに1回以上呼び出していると index がズレてしまう(勿論 index を調整することも可能ではあるが、一発で当てるのは難しい)
  • 複雑な Python プログラムでは GC により RandomObject が解放されている可能性あり
  • OS (特にコンテナ環境) によっては /proc/<pid>/mem が制限されている
  • macOS 等では /proc が存在しないため別の手法 (Mach API) が必要

8. まとめ

今回は「Python の random.random() はどこまで予測できるのか?」という問いに対して、
外部プロセスから完全な予測を行う実験を通じて答えを出しました。

この実験を通じて得られた教訓は:

  • セキュリティ目的で random モジュールを使ってはいけない
  • 擬似乱数の内部構造は簡単に 見える こともある
  • 実際に攻撃できる例があるという事実こそが最大の警告

ということです。

9. おわりに

公式ドキュメントに従っていればそこまで重大なインシデントにはならないでしょうから、ほんの少しの好奇心の産物だと思ってお楽しみ頂ければ幸いです。最後まで読んで下さりありがとうございました!

Discussion