はじめに: 「乱数」は1種類じゃない
「ランダムな値が欲しい」と思った時、JavaScript なら反射的に Math.random()、Python なら random.randint() と書きがちです。サイコロやおみくじ、ゲームのアイテムドロップ判定なら問題ありません。
しかしパスワード生成、セッショントークン、暗号鍵、UUID などセキュリティが絡む場面で Math.random() を使うと、そのアプリは確実にハッキング可能になります。理由を理解するには、「疑似乱数」と「暗号学的乱数」という2種類の乱数を区別する必要があります。
疑似乱数(PRNG)と暗号学的乱数(CSPRNG)
コンピュータは決定論的な機械なので、本物の乱数を作れません。代わりに「ランダムに見える数列」を計算で生成しています。これを疑似乱数(Pseudo-Random Number Generator, PRNG)と呼びます。
| 項目 | PRNG(普通の乱数) | CSPRNG(暗号学的乱数) |
|---|---|---|
| 速度 | 非常に速い | 少し遅い(OS呼び出し含む) |
| 予測可能性 | 過去の出力から次が予測可能 | 過去の出力からは予測不可能 |
| 用途 | シミュレーション、ゲーム | 暗号鍵、トークン、パスワード |
| JS での代表 | Math.random() | crypto.getRandomValues() |
| Python | random モジュール | secrets モジュール |
鍵となる違いは「過去の出力から次の出力を予測できるかどうか」。PRNG はアルゴリズムと内部状態(シード)が分かれば次が予測できます。CSPRNG はそれが計算的に困難になるよう設計されています。
Math.random の中身
現代のブラウザの Math.random は xorshift128+ や xoshiro128** といった高速な PRNG を使っています。シード(初期状態)はブラウザ起動時に決まり、それ以降は計算で次々と数列を生成します。
2015 年、研究者によって V8(Chrome)の Math.random 実装が解析され、「数個の出力を観測すれば内部状態を復元できる」ことが示されました。これは攻撃者にとって:
- 3個ほど出力を観測(例: ある画面に表示された連番ID)
- 内部状態を復元
- 「次の
Math.random()」が何になるか完全に予測できる
という攻撃を意味します。
実際に起きた事故
Math.random / 弱い乱数による事故は枚挙にいとまがありません:
Hacker News のパスワードリセット脆弱性
2017 年、ある SaaS で「パスワードリセット用のトークンが Math.random() で生成されていた」ことが発覚し、攻撃者が任意のユーザーのパスワードをリセットできる状態でした。
暗号資産ウォレットの秘密鍵漏洩
2013 年、Android の Bitcoin ウォレットで Java の SecureRandom が 同じシードを使ってしまうバグがあり、複数ユーザーが同じ秘密鍵を持つ事故が起きて数千ドル相当のビットコインが盗まれました。CSPRNG であっても実装ミスで弱くなる例。
セッション ID の予測
古い PHP のセッション ID 生成は弱く、複数のセッション ID を観測することで他ユーザーのセッションを予測する攻撃が成立した時代がありました(PHP 7 以降は CSPRNG ベースに改善)。
JavaScript で正しく書く
ブラウザ・Node.js
// ❌ 危険
const token = Math.random().toString(36).substring(2)
// ✅ 安全(ブラウザ・Node.js 両対応)
const buf = new Uint8Array(32)
crypto.getRandomValues(buf)
const token = Array.from(buf, b => b.toString(16).padStart(2, "0")).join("")
// ✅ UUID v4 が欲しいだけならこれで十分
const id = crypto.randomUUID()crypto.getRandomValues は OS の CSPRNG(Linux なら /dev/urandom、Windows なら CryptGenRandom 系)を呼び出します。crypto.randomUUID はそれを使って RFC 4122 v4 の UUID を返す便利関数。
本サイトの Password Generator も UUID Generator も、内部で crypto.getRandomValues / crypto.randomUUID を使っています。Math.random は1箇所も使いません。
Python
# ❌ 危険
import random
token = random.randint(100000, 999999)
# ✅ 安全
import secrets
token = secrets.token_urlsafe(32) # URLセーフな乱数文字列
n = secrets.randbelow(1_000_000) # 0以上1,000,000未満の整数
choice = secrets.choice(["a", "b", "c"]) # リストから1つPython は 3.6 で secrets モジュールが追加され、それ以前は os.urandom を直接使っていました。random モジュールは Mersenne Twister という高速な PRNG で、暗号用途には絶対に使ってはいけません。
その他の言語
- Go:
crypto/randパッケージ(math/randではなく) - Java:
java.security.SecureRandom(java.util.Randomではなく) - Ruby:
SecureRandomモジュール(Randomではなく) - Rust:
rand::rngs::OsRngまたはgetrandomクレート
共通パターンは「標準の random 系を使わず、secure / crypto / os を冠したモジュールを使う」。これだけ覚えておけば事故は激減します。
Modulo Bias - もう一つの罠
CSPRNG を使えば終わり、ではありません。「乱数を範囲内に収める方法」でもバイアスが入ることがあります。
例えば「0〜9 のランダム整数が欲しい」時、悪い実装:
// ❌ modulo bias あり
const buf = new Uint8Array(1)
crypto.getRandomValues(buf) // 0〜255 の乱数
const n = buf[0] % 10 // 0〜9 にしたいこの方法、見た目は問題ないですが偏りがあります。0〜255 の256個の値を10で割ると:
- 0, 1, 2, 3, 4, 5 → 26回ずつ出る(256÷10 = 25.6)
- 6, 7, 8, 9 → 25回ずつ
小さい値(0〜5)が4%多く出る偏りが発生します。10要素なら誤差程度ですが、「62文字(A-Za-z0-9)から1文字選ぶ」場合、256 ÷ 62 = 4.13… でかなりの偏りが出ます。これはパスワードのエントロピーを実質的に下げます。
正しい方法: rejection sampling
// ✅ 偏りなし(rejection sampling)
function randomInt(max) {
const buf = new Uint8Array(1)
let n
do {
crypto.getRandomValues(buf)
n = buf[0]
} while (n >= 256 - (256 % max)) // 余りが出る範囲を弾く
return n % max
}余りが出る範囲(256 - 256 % 10 = 250 以上)に当たったらその乱数を捨ててもう一度引く。これで完全に均等な分布になります。本サイトの Password Generator も rejection sampling を使っています。
crypto.getRandomValues + % で済ませてしまう実装は世の中に多いですが、セキュリティ用途では bias が長期的に攻撃可能性を高めるので、ライブラリ(secrets.randbelow, SecureRandom.uniform 等)を使うか、自前で書くなら rejection sampling を理解した上で書きましょう。
シードの問題: そもそもエントロピーがない時
CSPRNG もシード(初期エントロピー)がなければ無力です。組み込み機器、起動直後の VM、コンテナの大量複製などで「OS のエントロピープールが空」になり、CSPRNG が予測可能な状態に陥ることがあります。
対策:
- Linux では
getrandom(2)を使う(/dev/urandomを直接読まない、ブロッキング含めて適切に処理) - VM/コンテナでは Hardware RNG(
RDSEED等)またはvirtio-rngを有効化 - 起動直後にエントロピーが必要な処理は遅延実行
Web アプリの開発で意識する場面は少ないですが、IoT / 組み込み / クラウドネイティブな環境では実際に問題になる箇所です。
実用チェックリスト
コードレビューで「この乱数は危ないかも」と判断する時の早見表:
| 用途 | CSPRNG必須? | 備考 |
|---|---|---|
| パスワード生成 | 必須 ✅ | + rejection sampling |
| セッションID | 必須 ✅ | 16バイト以上 |
| CSRFトークン | 必須 ✅ | 32バイト推奨 |
| パスワードリセットトークン | 必須 ✅ | 有効期限を短く |
| API キー | 必須 ✅ | 長い文字列 |
| UUID v4 / v7 | 必須 ✅ | crypto.randomUUID で OK |
| 暗号鍵 / IV / nonce | 必須 ✅ | 絶対に再利用しない |
| シャッフル(音楽アプリ等) | 不要 | Math.randomで十分 |
| ゲームのダメージ判定 | 不要 | 速度優先 |
| シミュレーション | 不要 | 再現性を取りたい時はシード固定 |
おわりに
「乱数」と一括りにせず、「予測可能でいい乱数」と「予測不可能でないと困る乱数」を区別するのがセキュリティの第一歩です。Math.random / random.randint はゲームやシミュレーション用、crypto.getRandomValues / secrets はセキュリティ用、と使い分けの習慣を最初に身につけてしまえば事故は防げます。
本サイトの Password Generator は crypto.getRandomValues + rejection sampling で実装していて、UUID Generator は crypto.randomUUID ベース。両方とも「サーバに送らずブラウザ完結」で、生成された値はあなただけのもの。実際に触って、暗号学的乱数の感触を試してみてください。
関連: パスワード強度はどう決まるか / パスワードハッシュの選び方 / 公開鍵暗号の基本