Содержание:

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 с ключами. Атака очевидна:

  1. Генерируем свою пару RSA-ключей (приватный + публичный)
  2. Создаем JWKS-файл с нашим публичным ключом
  3. Хостим его на своем сервере (attacker.com)
  4. В JWT-заголовке ставим "jku": "https://attacker.com/jwks.json"
  5. Подписываем токен своим приватным ключом
  6. Сервер идет на 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 services
  • http://127.0.0.1:6379/ - Redis
  • http://127.0.0.1:9200/ - Elasticsearch
  • http://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. Без сравнения с известными ключами. Просто: “О, в токене лежит ключ? Проверю-ка подпись этим ключом.”

Атака:

  1. Генерируем свою пару ключей
  2. Встраиваем публичный ключ в заголовок JWT через jwk
  3. Подписываем токен своим приватным ключом
  4. Сервер достает наш публичный ключ из заголовка, проверяет подпись - успех
# 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 миллионов попыток в секунду. Один перехваченный токен - и тебе не нужен доступ к серверу.