Содержание:
- Симметричные vs асимметричные алгоритмы
- Как работает атака
- PoC
- Формат ключа - критически важная деталь
- Где взять публичный ключ
- sig2n: когда публичный ключ недоступен
- ES256 тоже уязвим
- CVE: одиннадцать лет одного бага
- Защита
- Что дальше
alg:none из прошлой статьи - грубая сила: “не проверяй подпись”. Algorithm confusion - элегантнее. Ты берешь публичный ключ сервера (он лежит в открытом доступе), подписываешь им свой токен - и сервер его принимает. Подпись есть. Подпись корректна. Но токен поддельный.
Это все тот же фундаментальный дефект, о котором я говорил в первой статье: токен диктует серверу алгоритм проверки.
Симметричные vs асимметричные алгоритмы
Прежде чем показать атаку, надо понять разницу между двумя типами алгоритмов, которые использует JWT.
HS256 (HMAC-SHA256) - симметричный алгоритм. Один и тот же секретный ключ используется и для создания подписи, и для ее проверки. Знаешь секрет - можешь подписать любой токен. Именно поэтому секрет должен быть надежным, и именно поэтому HMAC-токены можно брутфорсить (об этом в статье 7).
RS256 (RSA-SHA256) - асимметричный алгоритм. Два ключа: приватный подписывает, публичный проверяет. Приватный ключ знает только сервер. Публичный ключ - на то и публичный, его может видеть кто угодно. Знание публичного ключа не позволяет подделать подпись. По крайней мере, не должно позволять.
Ключевое отличие: в HS256 один ключ делает все, в RS256 ключи разделены по функциям. И вот здесь начинается проблема.
Как работает атака
Нормальный поток с RS256 выглядит так:
- Сервер подписывает JWT приватным RSA-ключом
- Для проверки используется публичный ключ
- Функция верификации:
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.