Опубліковано: · Оновлено: · автор: kodewright
Що таке CSPRNG і чому Math.random() небезпечний (2026)
CSPRNG — джерело випадковості, яке не передбачити за його виходом. Я відновив стан Math.random() з 4 значень у Node 22: як це працює і що брати натомість.

CSPRNG (криптографічно стійкий генератор псевдовипадкових чисел) — це джерело випадковості, побудоване так, що жоден обсяг його виходу не допоможе передбачити наступне значення чи відновити попередні. Math.random() до них не належить. У Node 22 мені вистачило чотирьох послідовних значень Math.random(), щоб відновити внутрішній стан генератора й точно передбачити наступні дев'ять; скрипт і вивід нижче. Для паролів, токенів, солі та ідентифікаторів сесій беріть crypto.getRandomValues() у браузері та crypto.randomBytes() у Node.
PRNG чи CSPRNG: що їх насправді відрізняє
Будь-який програмний генератор псевдовипадковий: це детермінований алгоритм, який перетворює зерно (seed) на потік чисел, що виглядає випадковим. Різниця в тому, що може зробити спостерігач, який бачив частину цього потоку.
| Властивість | PRNG (Math.random() у V8) | CSPRNG (crypto.getRandomValues()) |
|---|---|---|
| Алгоритм | xorshift128+ | DRBG операційної системи або браузера (ChaCha20, AES-CTR DRBG, Hash_DRBG) |
| Стан | 128 біт | 256 біт і більше, з перезасіванням з ентропії ОС |
| Чи можна передбачити за виходом? | Так, приблизно з чотирьох значень | Ні |
| Що розкриває викрадений стан | Усі минулі й майбутні значення | Нічого з того, що було до витоку; перезасівання обмежує те, що буде після |
| Для чого створений | Швидкість: ігри, джитер, вибірки | Ключі, токени, сіль, nonce |
Формально CSPRNG має пройти тест наступного біта (знаючи перші k бітів, ви вгадуєте біт k+1 з імовірністю не кращою за 50%) і витримувати компрометацію стану (викрадений сьогодні стан не розкриває вчорашній вихід). xorshift128+ не проходить жодної з двох вимог. Це не вада V8: специфікація ніколи не вимагала від Math.random() безпечності, і щоб перемішати плейлист, це правильний інструмент.
Я передбачив Math.random() за чотирма значеннями
V8, рушій Chrome, Node і Deno, використовує xorshift128+ для Math.random() з 2015 року. Кожен крок — кілька зсувів і XOR над двома 64-бітними словами, а кожне повернене число double будується з верхніх 52 бітів одного з них. Отже, кожен вихід розкриває 52 зі 128 бітів стану, а сам алгоритм публічний. Для атаки важлива ще одна деталь: V8 генерує значення пачками по 64 у кеш і видає їх у зворотному порядку.
Я написав короткий скрипт на Python із SMT-розв'язувачем Z3 (z3-solver 5.3.1 з pip). Він вважає стан двома невідомими 64-бітними числами, символьно проганяє через них xorshift128+, вимагає, щоб верхні 52 біти кожного кроку збігалися зі спостереженими значеннями (у зворотному порядку, через кеш), і просить Z3 знайти стан, що підходить. Якщо прокрутити знайдений стан назад, отримуємо значення, які Math.random() поверне далі. Ядро скрипта:
a, b = BitVec('a', 64), BitVec('b', 64)
s0, s1 = a, b
for v in reversed(observed):
s0, s1 = s1, xorshift(s0, s1) # one xorshift128+ step, symbolic
S.add(LShR(s0, 12) == mantissa(v)) # top 52 bits = the double's mantissa
S.check() # sat: the state is now known
Запуск на свіжому процесі Node, скорочено:
$ node -e 'console.log(JSON.stringify(Array.from({length: 13}, () => Math.random())))'
[0.48530045300870905,0.4535024111129262,0.22613694600298828,0.6116499683877723,0.27607344271000467, ...]
$ python3 predict.py local.json 4
Node v22.23.2: 4 outputs observed, state recovered in 0.02s
predicted 0.27607344271000467 actual 0.27607344271000467 match
predicted 0.71206622404905096 actual 0.71206622404905096 match
predicted 0.26313849711769510 actual 0.26313849711769510 match
...
predicted 0.03887366515100310 actual 0.03887366515100310 match
9/9 exact
Я повторив це на дев'яти свіжих процесах у Node 22.22 і 22.23. З чотирма спостереженими значеннями кожен запуск точно передбачив усі наступні значення в кеші, і розв'язувачу ніколи не знадобилося більше кількох сотих секунди. З трьома значеннями (156 спостережених бітів проти 128 невідомих) Z3 знаходив стан, який підходив до вхідних даних, але не вгадував жодного наступного значення, і так в усіх чотирьох спробах.
Межа цього тесту: я давав розв'язувачу «сирі» числа double. Реальний код зазвичай розкриває щось на кшталт Math.floor(Math.random() * 62), тобто близько 6 бітів за виклик замість 52. Атакувальнику тоді потрібно більше спостережень, і доведеться враховувати перезаповнення 64-значного кешу. Таку версію я не запускав. Вона змінює кількість значень, які треба побачити, але не те, чи можна відновити стан. Тому будь-який секрет, створений через Math.random(), я вважаю вже публічним.
Звідки CSPRNG бере непередбачуваність
CSPRNG — теж алгоритм, тож непередбачуваність має походити із зерна. Операційна система збирає її з фізичного шуму: таймінгів переривань, джитеру пристроїв і вводу-виводу, а на більшості сучасних процесорів ще й з апаратного джерела на кшталт Intel RDRAND. Ця ентропія засіває детермінований генератор (ChaCha20 у ядрі Linux починаючи з 4.8, AES-CTR DRBG за BCryptGenRandom у Windows), який розтягує 256-бітне зерно на будь-який потрібний обсяг виходу й з часом перезасівається.

crypto.getRandomValues() зрештою звертається саме до цього механізму ОС. Деякі браузери передають виклик напряму, а Firefox запускає власний DRBG у NSS, засіяний від ОС. В обох випадках немає «швидкого, але слабкого» режиму, який можна випадково ввімкнути, а саме це потрібно від API, яким користуються, не читаючи документацію.
Як перетворити випадкові байти на символи без зміщення
Усе в SecuredKit працює у вашому браузері, і нічого згенерованого не надсилається на сервер, тож генераторам потрібне було безпечне джерело випадковості, доступне локально. Це crypto.getRandomValues(). Проте отримати безпечні байти — простіша половина справи. Типова помилка ховається в наступному рядку, де байт або 32-бітне слово втискають у менший діапазон через %.
Якщо взяти один байт і алфавіт із 62 символів, byte % 62 розкладає 256 значень на 62 позиції. 256 не ділиться на 62 без остачі, тож перші 8 символів отримують 5 шансів із 256, а решта 54 — по 4. Ці 8 символів з'являються на 25% частіше. На такому розмірі втрата ентропії невелика, приблизно 0,0045 біта на символ (20-символьний ключ падає зі 119,08 до 118,99 біта), але вона зростає, коли діапазон наближається до розміру джерела. Я заміряв byte % 200 на 2 000 000 вибірок у Node 22: значення 0 випало 15 637 разів, а значення 199 — 7 886 разів, майже рівно передбачені 2:1.
Виправлення — вибірка з відхиленням (rejection sampling): відкидаєте значення з нерівного «хвоста» й тягнете ще раз. Ось допоміжна функція, якою користуються генератори паролів, парольних фраз, PIN-кодів і алфавітно-цифрових ключів на сайті (скорочено):
const UINT32_RANGE = 2 ** 32
export function randomInts(max: number, count: number): number[] {
// Largest multiple of max that fits in 32 bits; words at or above it are rejected.
const limit = UINT32_RANGE - (UINT32_RANGE % max)
const out: number[] = []
while (out.length < count) {
const words = crypto.getRandomValues(new Uint32Array(Math.min(16_384, count - out.length + 4)))
for (const word of words) {
if (word >= limit) continue
out.push(word % max)
if (out.length === count) break
}
}
return out
}
З 32-бітними словами та алфавітом із 62 символів 2³² mod 62 дорівнює 4, тобто слово відкидається 4 рази на приблизно 4,3 мільярда. Аргументу про продуктивність, щоб цього не робити, просто немає. Моя позиція: для алфавіту з 62 символів зміщення за модулем — це помилка коректності, а не практичний злам, але я все одно виправляв би її на будь-якому код-рев'ю, бо той самий шаблон % n дає реальний перекіс того дня, коли хтось застосує його до діапазону на кшталт 200. Генератор випадкових чисел використовує ту саму ідею з BigInt для діапазонів понад 2⁵³.
Скільки випадкових байтів достатньо
Коли джерело — справжній CSPRNG, лишається одна змінна: скільки бітів ви берете. Формула та сама, що й для стійкості пароля:
H = L × log₂(R)
H — ентропія в бітах, L — кількість символів, R — кількість можливих значень одного символу. Для сирих байтів R = 256, тож кожен байт дає 8 біт: 16 байтів — 128 біт, 32 байти — 256. Формула припускає, що R чесне. Токен, зібраний із Math.random(), може мати 40 символів і все одно нести майже нуль бітів проти того, хто бачив чотири інші виходи того самого процесу.
| Вихід CSPRNG | Ентропія | Для чого |
|---|---|---|
| 8 байтів | 64 біти | Закоротко для будь-якого секрету |
| 16 байтів | 128 біт | Ідентифікатори сесій, токени скидання пароля, CSRF-токени |
| 32 байти | 256 біт | API-ключі, секрети для підписів, усе довгоживуче |
Для масштабу: одна RTX 4090 перебирає близько 22 мільярдів хешів SHA-256 за секунду (hashcat 6.2.6). Якби перевірка одного кандидата коштувала один SHA-256, пошук у половині 64-бітного простору забрав би на цій карті близько 13 років, а в половині 128-бітного — близько 2,5 × 10²⁰ років. 128-бітний токен ніхто не атакує в лоб. Шукають Math.random(), що стоїть за ним. Ту саму математику для паролів я розбираю в статті про те, скільки ентропії потрібно паролю.
Як перевірити власний код
Почніть із grep. Він за кілька секунд знаходить місця викликів, які мають значення:
grep -rnE "Math\.random\(\)" src/ | grep -iE "token|secret|pass|key|otp|salt|nonce|id"
У власних components/, composables/, utils/ і pages/ SecuredKit пошук Math.random() навіть без фільтра нічого не знаходить, і саме такий результат вам потрібен. Для кожного збігу у вашому коді перевірте дві речі: джерело (crypto.getRandomValues() або crypto.randomUUID() у браузері, crypto.randomBytes() або crypto.randomInt() у Node) і те, як байти перетворюються на діапазон (% n без відхилення дає описане вище зміщення).
Статистичні набори тестів на кшталт NIST SP 800-22 (15 тестів) чи dieharder питання не закриють. xorshift128+ проходить більшість із них і все одно повністю передбачуваний, як показує запуск розв'язувача. Вони корисні, щоб зловити зламаний крок перетворення, але не доводять, що джерело безпечне.
Якщо знайшли секрет, створений через Math.random(), замініть його, а не з'ясовуйте, чи хтось це помітив: нові паролі з Генератора паролів, нові ключі з Генератора секретних ключів або crypto.randomBytes(32), а старі значення анулюйте.
FAQ
Скільки випадкових байтів потрібно для безпечного токена?
16 байтів (128 біт) — практичний мінімум для токенів сесій та ідентифікаторів. Для API-ключів і довгоживучих секретів беріть 32 байти (256 біт). У base64url 16 байтів дають 22 символи, а 32 байти — 43.
Чи можна використовувати crypto.randomUUID() як токен?
Щодо непередбачуваності так: специфікація Web Crypto вимагає криптографічно стійкого джерела, а UUID v4 містить 122 випадкові біти. Для довгоживучих API-ключів я все одно брав би 32 випадкові байти, бо додатковий запас нічого не коштує.
Чи є обмеження на обсяг, який crypto.getRandomValues() повертає за раз?
Так: 65 536 байтів за виклик. У Node 22 запит на 65 537 байтів кидає QuotaExceededError, тож великі буфери заповнюйте частинами. crypto.randomBytes() у Node такого обмеження на звичайних розмірах не має.