はじめに
この記事はフューチャー Advent Calendar 2025 25 日目、今年最終篇です。
今年、ランサムウェアやサプライチェーン攻撃、生成 AI の悪用などが大きなセキュリティの話題になっています。今回もセキュリティに関連する話題となります。
ブロックチェーン開発などをしていると、「Math.random() なんて絶対に使うなよ!」ってよく耳にしますよね。「どうしてそんなにダメなんだろう?本当にクラックできるのかな?」と疑問に思ったので、今回はその原因と、実際にどういう仕組みで予測されてしまうのかを試してみることにしました。
この記事では、普段何気なく使っているけれども、暗号論的に安全ではない擬似乱数生成器(PRNG)をターゲットにして、その内部状態を特定し、次にどんな数字が出るかを 予測(クラック) する手法をご紹介します!
「クラック」って、つまりどういうこと?
乱数生成器を「クラック成功!」と言うのは、ブラックボックス的に有限な連続の出力を観測することだけで、以下の 2 つができるようになることを意味します。
- 内部状態の特定: 生成された乱数をいくつか観測するだけで、その PRNG の 「現在の状態」(内部状態) を完全に割り出すこと
- 未来の予測: 内部状態が分かってしまえば、その PRNG が次に生成する乱数をすべて完全に予測できるようになる(PRNG は決定論的な計算で動いているだけですからね)
暗号論的に安全な乱数生成器とそうでないもの
乱数生成器には大きく 2 系統があります。
1. PRNG (Pseudo-Random Number Generator)
これは擬似乱数生成器と呼ばれ、文字通り「乱数のように見えるもの」を生成します。特徴は以下の通りです。
- 高速性重視: 少ない計算リソースで大量の乱数を素早く生成できる
- 統計的品質は高い: 統計的な偏りが少なく、シミュレーションやゲームの演出など、予測されても問題ない用途で使われる
- 初期シードの依存: 最初に与えられた「シード(種)」が同じなら、何度実行しても全く同じ乱数列が生成される(この特性は、再現性が求められるシミュレーションやデバグの際に重要となる)
- セキュリティは考慮外: 内部状態の構造がシンプルで、容易に逆算(予測)されてしまう
2. CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
これは暗号論的に安全な擬似乱数生成器と呼ばれます。特徴は以下の通りです。
- 予測不可能性を最優先: 内部状態を推測したり、次に来る乱数を統計的に予測したりするのが、計算論的に現実的でないほど難しいように設計されている
- エントロピー源: OS が持つ物理的なノイズ(マウスの動き、ディスク I/O、温度など)を「エントロピー(不確実性の源)」として利用し、乱数の予測が極めて困難
- 用途: 秘密鍵、セッション ID、ワンタイムパスワード、ソルト値など、予測されたら即座にセキュリティ崩壊につながる場面で使われる
多くの主要なプログラミング言語では、利便性と高速性のため、デフォルトの乱数生成関数に「暗号論的に安全ではない PRNG」を採用していることがあります。セキュリティが求められる場面では、必ず CSPRNG に切り替える必要があります。
| 言語 | デフォルト PRNG | CSPRNG 代替ライブラリ/関数 |
|---|---|---|
| JavaScript (Web) | Math.random() | window.crypto.getRandomValues() |
| JavaScript (Node.js) | Math.random() | crypto.randomBytes() |
| Python | random モジュール (MT19937) | secrets モジュール / os.urandom() / ssl.RAND_bytes() |
| Java | java.util.Random | java.security.SecureRandom |
| C# (.NET) | System.Random | System.Security.Cryptography.RandomNumberGenerator |
| Go | math/rand | crypto/rand |
| PHP | rand(), mt_rand() | random_bytes(), random_int() |
今回ターゲットとする MT19937 や xorshift128+ はこちらに該当します。
今回ターゲットにする「安全ではない」乱数たち
今回は最も使われているプログラミング言語である JavaScript と Python のデフォルト PRNG をターゲットとして見ていきましょう。
ターゲット I: JS の V8 エンジン (xorshift128+)
V8 JavaScript エンジン(Chrome や Node.js)で Math.random() の裏側を担っていた乱数生成器は、xorshift128+ です。
コアの実装はこちらのソースコードを参照してください。
実装思想の簡単なまとめ
-
2 つの 64 ビット状態値を使用
-
state0とstate1という 2 つの状態を保持することで、より長い周期(2^128-1)を実現
-
-
XOR(排他的論理和)とビットシフト操作
- XOR 演算とシフト演算は非常に高速な CPU 命令
- これらの単純な操作を組み合わせることで、複雑な計算なしに乱数を生成
-
状態の混合
- シフト操作で状態の異なるビット位置を混ぜ合わせる
- 左シフト(
<< 23、<< 17)と右シフト(>> 17、>> 26)を交互に使用 - 2 つの状態同士も XOR(
s1 ^= s0、s1 ^= s0 >> 26)して相互に影響させる
- 左シフト(
- シフト操作で状態の異なるビット位置を混ぜ合わせる
-
利点
- 高速:単純な演算のみで計算効率が良い
- 周期:
2^128-1という長い周期、統計特性が良い
ターゲット II: Python の random モジュール (MT19937)
Python の標準ライブラリである random モジュールでは、Mersenne Twister(メルセンヌ・ツイスター)がデフォルトとして使用されています。
コアの実装はこちらのソースコードを参照してください。
実装思想の簡単なまとめ
-
状態配列による周期延長
- 624 個の 32 ビット数値(
N=624)を内部状態として保持 - この大きな状態空間により、非常に長い周期(
2^19937-1)を実現
- 624 個の 32 ビット数値(
-
バッチ生成による効率化
- 一度に 624 個の数値を並列変換して更新
- その後、順序に従って出力
- これにより計算効率を改善
-
非線形変換による統計的均等性
- 出力時にビット回転と排他的論理和(XOR)による加工
- これにより出力値の統計的性質が大幅に改善される
-
利点
- 高速:ビット操作のみで実装がシンプルだし、計算効率が良い。バッチ処理で CPU キャッシュに効率的
- 周期:
2^19937-1という非常に長い周期、統計特性が良い
さて、どうやってクラックするの?
擬似乱数生成器は「初期シード → 内部状態 → 出力」という決定論のパイプラインで動いています。いったんシードが定まれば内部状態は有限集合の上を一意に遷移し、観測した出力はその状態に由来するビット列です。つまり 出力を十分に集めて逆算すれば、内部状態とシード情報を再構築できる わけです。
基本戦略
-
観測値の収集:
Math.random()のdoubleやgetrandbits(32)の整数など、手元で取得できる生データを連続で蓄積します -
出力関数の逆変換: V8 なら 64bit 値の上位 53bit だけが
double化されています。MT19937 ならテンパリング(ホイットニング)で飾られた 32bit をアンテンパーします。ここで可能な限りコア状態に近い表現へ戻します - 状態遷移のモデル化: xorshift128+ のビット演算や MT19937 の線形再帰を論理式(制約)として表現します。状態が前へ進むたびに制約も増やし、観測列に整合する内部状態を探索します
- 検証と予測: 復元できた状態を使って次の出力を生成し、観測と突き合わせます。矛盾しなければ以降の乱数を完全に予測できる、という流れです
Z3 で制約を解く
Z3 Theorem Prover はビットベクトル演算を理解できる Satisfiability Modulo Theory(SMT)SMT ソルバーです。パズルに例えると、
-
ピース(変数): PRNG の内部レジスタを 64bit/32bit の
BitVecとして宣言 -
ルール(状態遷移):
next_state()などのビット演算を Z3 の式で表現 -
ヒント(観測値): 観測した
double/整数を「この式を通せばこうなるはず」という等式制約として投入
Z3 はこれらをすべて満たす割り当てを解き、初期状態をただ一つに特定します。解が見つかったら、その状態で自前のジェネレータを回し、未来の値を出力できます。
ケーススタディ 1: V8(xorshift128+)
-
出力変換のモデル化:
DivisionConverterで「下位 11bit を捨て、上位 53bit を 2^53 で割る」という Math.random() のdouble化をto_value/from_valueとして定義します(info.md で詳述) -
状態遷移の連鎖: Z3 上では
s0,s1をBitVecのままXorShift128PlusUtil.next_state()で進め、各観測値に対応する制約を追加していきます。未解決のまま V8 の 64 個キャッシュがリフィルされた場合は制約を巻き戻して再探索します -
キャッシュ対応: V8 は LIFO キャッシュを使うため、観測される順序と生成順の入れ替わる瞬間があります。本リポジトリの
SolverStatusはSOLVED_BEFORE_CACHE_REFILLやCACHE_REFILLED_WHILE_SOLVINGを持ち、状態機械で整合性を保っています
ケーススタディ 2: MT19937(Python random)
-
アンテンパー:
getrandbits(32)の値に対し、右シフト/左シフト+ XOR のテンパリングを逆順に剥がすことで「生の 32bit ワード」を復元します。これは可逆なので 624 個集めれば確定的に内部状態が得られます -
setstateへの投入: 復元した 624 ワードとインデックスをtuple(state + [N])の形にしてrandom.Random.setstate()へ入れると、公式実装と完全に同じ状態を再構築できます。その後はpredict_next()がgetrandbits(32)と同じ列を吐き続けます
実装の利用
CLI では -t オプションで V8, V8_LEGACY, V8_INT, MT19937 などのクラッカーを切り替えられます。例えば V8 現行実装を狙うなら RngType.V8 を選び、観測した Math.random() の出力を標準入力に流し込めば OK。2025 年 2 月の V8 random ToDouble 変更にも対応する派生クラスを用意しているので、詳細は README と該当ソースを参照してください。
CLI のツールの形になっていますので、ぜひお試しいただければと思います。
上記はデモのスクショ録画です。
左側は普通の Chrome ブラウザの開発者ツールのコンソールで、Math.random()を連続に呼んでいます。右側は、自作の Cracker の CLI ツールに最初の 5 個ぐらいに入力したら、後続の値が推測できたことは分かります。
まとめ:クラック検証から学ぶ、乱数選択の超重要ベストプラクティス
この記事でつかんだ事実
- 暗号論的に安全でない PRNG は、わずかな出力から内部状態を特定され、以降の値を完全に予測されうる
- MT19937 や xorshift128+ といった高速系 PRNG は、線形性・決定論・可逆な出力関数ゆえに、統計的に優秀でもセキュリティ用途には不適
- Z3 を使った制約解法によって、出力 → 状態 → 未来予測という逆算パイプラインが現実的に実装できる
直面するリスク
- 鍵素材の漏洩: 乱数トークンやセッション ID を PRNG で生成すると、観測者にほぼリアルタイムで複製される恐れ
- ビジネスロジックの破綻: ガチャやゲーム内経済のような統計バランスが、悪意ある予測で崩される可能性
- 信頼の棄損: 「予測不可能であるべきものが予測できる」状態は、サービスやプロダクトの信頼を根底から揺るがす
どう守る?今日からできるアクション
- セキュリティ関連のコードレビューでは、
Math.random()やrandom.Randomの乱用をチェックリスト化する - ランタイムが提供する CSPRNG API をデフォルト選択肢にし、PRNG を使う際は理由と範囲を明記する
- 既存システムで PRNG が鍵素材に使われていないか棚卸しし、該当箇所があれば置き換え計画を立てる
ブラウザ環境なら window.crypto.getRandomValues() が OS エントロピーを引き出してくれるので、Math.random() をセキュリティ用途で使うのは即刻やめましょう。用途に応じて乱数生成器を適切に選び、予測不可能な安全設計をデフォルトにしていきましょう。
では、メリークリスマス!
˗ˋ🎄 ⋆。🎅🏻🦌𓂃˚ ༘ •₊🎁 ˎˊ˗
