Содержание:

В 2010 году Sony потеряла контроль над PlayStation 3 - из-за одного повторяющегося числа в криптографии. В 2019-м исследователи вытащили приватный ключ VPN-сервера за 5 часов через утечку нескольких бит. Обе атаки объединяет одно: понимание математики, которая стоит под капотом подписей.

В статьях 3-8 мы атаковали JWT. Теперь разберём, почему эти атаки работают - какая математика стоит за HMAC, RSA и ECDSA, и где она ломается.

HMAC: почему два прохода

HS256 - это HMAC-SHA256. Формула:

HMAC(K, msg) = SHA256(
  (K xor opad) ||
  SHA256((K xor ipad) || msg)
)

Два прохода SHA256. Два разных padding-значения: ipad (каждый байт ключа XOR 0x36) и opad (каждый байт ключа XOR 0x5C). Выглядит как ненужное усложнение. Почему нельзя просто SHA256(key || message)?

Потому что наивный вариант уязвим к атаке удлинением сообщения (length extension attack). SHA-256 построен на конструкции Меркла-Дамгора - представь хеш как конвейер: каждый блок входных данных обрабатывается поочерёдно, и результат каждого блока становится начальным состоянием для следующего. Финальный хеш - просто состояние конвейера после последнего блока. Зная это состояние и длину входа, атакующий может «запустить конвейер дальше» с новыми данными - и вычислить SHA256(K || msg || padding || extension) без знания K. Если бы HMAC был наивным SHA256(key || message), атакующий мог бы дописать к payload дополнительные данные (скажем, ,"role":"admin") и вычислить валидный MAC без секрета.

Два прохода HMAC ломают эту атаку. Внутренний хеш (SHA256((K xor ipad) || msg)) создаёт промежуточное значение фиксированной длины - 32 байта. Внешний хеш (SHA256((K xor opad) || inner_hash)) «запечатывает» результат: он включает секретный ключ (через opad), и без знания ключа атакующий не может ни продолжить, ни воспроизвести внешний хеш.

Безопасность HMAC доказана математически (Bellare, Canetti, Krawczyk, 1996): она сводится к тому, что компрессионная функция хеша является хорошей псевдослучайной функцией (PRF) - неотличимой от случайной при неизвестном ключе. Это свойство не требует стойкости к коллизиям. Вот почему HMAC-MD5 не имеет известных практических атак, хотя коллизии MD5 найдены ещё в 2004 году. Впрочем, NIST deprecated MD5 (SP 800-131A), и RFC 6151 рекомендует не использовать HMAC-MD5 в новых протоколах - так что для JWT это академический факт, а не руководство к действию.

Как показано в статье 7, RFC 7518 требует ключ HS256 не менее 256 бит (32 байта), а RFC 8725 усиливает: ключ должен быть из криптографически стойкого генератора (CSPRNG), не человекочитаемый пароль. Строка "secret" - это 6 байт, потолок 2^48 вариантов при переборе случайных 6-байтовых последовательностей. Но реальная энтропия ещё ниже: это словарное слово, и словарная атака найдёт его за доли секунды, как это было в статье 7 с hashcat. Если ключ - 6 случайных байт из CSPRNG, 2^48 корректно. Если "secret" - забываем про 2^48.

Крайний случай - пустой ключ (0 байт). Он дополняется 64 нулевыми байтами. K' XOR ipad = ipad, K' XOR opad = opad. HMAC становится детерминированной функцией от сообщения - любой может вычислить «подпись» без секрета.

RSA: RS256 vs PS256

JWT поддерживает два варианта RSA-подписи, и разница между ними важна.

RS256 (PKCS#1 v1.5) - детерминированная подпись. Одно и то же сообщение с одним ключом всегда даёт одну и ту же подпись. Внутренний формат:

EM = 0x00 || 0x01 || [0xFF повторяется для заполнения] || 0x00 || DigestInfo

DigestInfo содержит хеш сообщения. Никакого случайного компонента. RS256 имеет доказательство безопасности в модели случайного оракула (Jonsson, 2002), но не в стандартной модели - модели без идеализированных предположений о хеш-функции. На практике это означает что RS256 работает 30 лет и никто его не сломал на сильных ключах, но если завтра найдут атаку, безусловного математического аргумента «почему она невозможна» - нет.

PS256 (RSA-PSS) - рандомизированная подпись. При каждом подписании добавляется случайная соль (32 байта для PS256, 48 для PS384, 64 для PS512). Результат: каждый раз разная подпись для одного и того же сообщения.

M' = 0x00*8 || Hash(msg) || salt
EM = maskedDB || Hash(M') || 0xBC

Bellare и Rogaway в 1996 году доказали безопасность PSS - тоже в модели случайного оракула, но с более плотной редукцией (tight reduction). Разница между RS256 и PS256 не «доказано vs не доказано» - оба имеют доказательства в модели случайного оракула. PS256 получил доказательство раньше и с лучшими параметрами, плюс имеет конструктивные преимущества.

Почему это важно для атак:

Во-первых, RS256 детерминирован: подписав одно сообщение дважды, ты получишь одинаковые подписи. Атакующий может это детектировать и использовать для анализа. PS256 рандомизирован - каждая подпись уникальна.

Во-вторых, RS256 исторически уязвим к атаке Блейхенбахера на подпись (Bleichenbacher, 2006) при маленькой экспоненте (e=3) и слабом парсере DigestInfo. Суть: атакующий подбирает значение, кубический корень которого при парсинге padding выглядит как валидная подпись. Если парсер не проверяет все байты DigestInfo до конца, «мусор» в хвосте остаётся незамеченным. PSS к этому иммунен по конструкции - его padding верифицируется полностью, включая восстановление соли и проверку маски.

Отдельно вспомни algorithm confusion из статьи 4. Эта атака - не свойство RS256 или PS256, а архитектурная проблема: единая функция verify() выбирает алгоритм из заголовка токена. Атакующий меняет alg с любого асимметричного алгоритма (RS256, PS256, ES256) на HS256 - и публичный ключ (PEM-строка) подаётся как HMAC-секрет. При эксплуатации критичен точный формат PEM-файла, потому что HMAC оперирует его сырыми байтами как ключом. Если библиотека не пинит алгоритм - уязвимость работает независимо от выбранного RSA-варианта.

ECDSA: одна подпись - один nonce

ECDSA - алгоритм цифровой подписи, о котором я рассказал в контексте Psychic Signatures (статья 8). Теперь разберём его глубже.

Подпись ECDSA: s = k^(-1) * (Hash(msg) + r * d) mod n, где:

  • k - случайный nonce (одноразовое число)
  • d - приватный ключ
  • r - x-координата точки k*G на кривой (G - базовая точка)
  • n - порядок базовой точки (количество точек в подгруппе; для P-256 это ~2^256)

Критическое правило: каждая подпись требует уникальный случайный nonce k. Если k известен, приватный ключ вычисляется тривиально - достаточно развернуть формулу подписи:

s = k^(-1) * (Hash(msg) + r * d) mod n
# Умножаем обе стороны на k:
s * k = Hash(msg) + r * d
# Выделяем d:
r * d = s * k - Hash(msg)
d = r^(-1) * (s * k - Hash(msg)) mod n

Три строки алгебры - и приватный ключ у нас.

Как Sony потеряла PlayStation 3

29 декабря 2010 года, 27-й Chaos Communication Congress (27C3). Группа fail0verflow выходит на сцену и демонстрирует восстановление приватных ключей Sony, которыми подписаны прошивки PlayStation 3. Одна ошибка в криптографии - и вся система безопасности рассыпалась.

Sony использовала ECDSA для подписи прошивок. И использовала одно и то же значение k для всех подписей. Фиксированный nonce вместо случайного.

Две подписи с одним nonce k имеют одинаковый r (потому что r вычисляется из k*G, а k одинаковый). fail0verflow заметили это и применили простую математику:

s1 = k^(-1) * (z1 + r*d)     # z1 = Hash(message1)
s2 = k^(-1) * (z2 + r*d)     # z2 = Hash(message2)

# Вычитаем одно из другого:
s1 - s2 = k^(-1) * (z1 - z2)

# Восстанавливаем k:
k = (z1 - z2) * (s1 - s2)^(-1) mod n

# Зная k, восстанавливаем приватный ключ:
d = (s1*k - z1) * r^(-1) mod n

Скомпрометированный ключ нельзя «отозвать» на миллионах существующих консолей. Sony пришлось перестраивать систему безопасности с нуля на новых прошивках, добавляя дополнительные уровни верификации. Одно фиксированное значение nonce - и приватный ключ восстанавливается из двух подписей.

Применимость к JWT

Если сервер подписывает JWT алгоритмом ES256 и использует генератор случайных чисел, который допускает повторение nonce, тот же сценарий применим. Ты перехватываешь JWT-токены, подписанные ES256. Каждый токен содержит подпись (r, s) в последних 64 байтах. Если найдёшь два токена с одинаковым r-компонентом - nonce повторился, и ключ восстановим. Вот функция для проверки:

import base64

def get_r(token):
    sig = base64.urlsafe_b64decode(
        token.split('.')[2] + '==')
    return sig[:32]  # первые 32 байта для ES256

# Собери N токенов, сравни значения r:
# Если get_r(token1) == get_r(token2):
#   nonce переиспользован, ключ восстановим

На практике повторение nonce в JWT-подписях встречается редко - современные библиотеки используют RFC 6979 (детерминированный nonce). RFC 6979 вычисляет k = HMAC(private_key, hash(message)): для разных сообщений nonce разный (потому что hash(message) разный), а для одного сообщения - одинаковый, что даёт ту же подпись и не создаёт утечки. Но в кастомных реализациях и старых библиотеках повторение nonce - вполне реальный сценарий.

Ещё один быстрый тест: подпиши одно и то же сообщение дважды одним ключом. Если подписи одинаковые - библиотека использует RFC 6979 (детерминированный nonce). Если разные - случайный nonce, и стоит проверить качество генератора.

Запомним функцию get_r() - она пригодится в статье 14, когда перейдём к lattice-атакам на ECDSA.

EdDSA: решает проблему nonce по конструкции

Ed25519 (алгоритм EdDSA для кривой Curve25519) вычисляет nonce детерминистически:

r = SHA-512(prefix || message)

Где prefix - вторые 32 байта от SHA-512(private_seed). Nonce определяется приватным ключом и сообщением. Повторить нельзя (разные сообщения дают разные r). Предсказать нельзя (нужен приватный ключ). Повторение nonce и смещение генератора случайных чисел - невозможны по конструкции, потому что случайный генератор вообще не участвует в подписании.

Но «по конструкции» - не значит «неуязвим вообще». Детерминированный nonce не защищает от fault injection: если аппаратный сбой (Rowhammer, voltage glitching) меняет состояние при вычислении SHA-512(prefix || message), одно и то же сообщение получит два разных nonce - и мы возвращаемся к сценарию nonce reuse. Подробнее об этом - в статье 14.

В JWT EdDSA используется с "alg": "EdDSA" и "crv": "Ed25519" в JWK. На 2026 год это наиболее безопасный выбор для новых систем из доступных алгоритмов подписи: Ed25519 работает быстрее ECDSA P-256 в программных реализациях, и целый класс атак на nonce (повторение, смещение генератора, timing-утечки при генерации) просто не существует. Размер подписи - 64 байта, как и у ES256.

Даже частичная утечка nonce опасна

Полный nonce reuse - это идеальный случай. Но даже утечка нескольких бит nonce из каждой подписи позволяет восстановить ключ.

Если генератор случайных чисел имеет смещение (bias) и nonce систематически короче на несколько бит, это создаёт утечку информации. Представим, что каждая утечка бит - это приблизительное уравнение с неизвестным (приватным ключом). Одного уравнения мало, но из сотен или тысяч приблизительных уравнений можно точно восстановить неизвестное - математически это называется задача скрытого числа (Hidden Number Problem), и решается она алгоритмами редукции решёток (LLL/BKZ). Подробный разбор с кодом - в статье 14.

Три примера из реальной жизни:

  • Minerva (CVE-2019-15809): утечка длины nonce в битах через timing на смарт-картах Athena SCS. От ~500 подписей в лабораторных условиях до ~2100 на реальных смарт-картах - и приватный ключ восстановлен.
  • TPM-FAIL (CVE-2019-11090): timing-атака на Intel fTPM. Ключ VPN-сервера - за 5 часов.
  • EUCLEAK (CVE-2024-45678): электромагнитный побочный канал (side-channel) на YubiKey 5 Series. Извлечение ECDSA-ключа через неконстантное время модулярной инверсии.

Все три атаки требуют физического доступа или замеров на уровне наносекунд - для стандартного веб-пентеста JWT они нетипичны. Но они демонстрируют принцип: любая утечка информации о nonce - путь к приватному ключу.

Итого

АлгоритмТипNonceДоказательство (ROM)Главная угроза
HS256 (HMAC)СимметричныйНетДа (PRF)Слабый ключ -> hashcat за секунды (статья 7)
RS256 (PKCS#1 v1.5)АсимметричныйНетДа (Jonsson ‘02)Bleichenbacher forgery; algorithm confusion (статья 4)
PS256 (RSA-PSS)АсимметричныйСольДа (Bellare-Rogaway ‘96)-
ES256 (ECDSA)АсимметричныйСлучайныйДаПовторение/утечка nonce; Psychic Signatures (статья 8)
EdDSA (Ed25519)АсимметричныйДетерм.ДаFault injection (статья 14)

Больше случайности при подписании - шире поверхность атаки. HMAC и RS256 детерминированы. ECDSA требует идеального nonce для каждой подписи, и любая слабость - полная компрометация. EdDSA устраняет проблему, делая nonce детерминированным.

Что дальше

Все предыдущие атаки были на JWS - подписанные токены. Payload виден всем, подпись гарантирует целостность. Но есть ещё JWE - зашифрованные токены. Пять частей вместо трёх, два уровня шифрования - и атака, где сервер сам расшифровывает для тебя любой ciphertext, побайтово подсказывая правильный ответ. В следующей статье входим на территорию JWE.