
Последние три CTF подряд — и на каждом минимум две web-задачи на JWT. Из шести задач четыре решились тупой подстановкой alg: none в заголовок, одна — брутфорсом HMAC-секрета через hashcat за 30 секунд, последняя потребовала algorithm confusion с подменой RS256 на HS256 и подписью публичным ключом. JWT-атаки в CTF появляются с предсказуемой регулярностью, а русскоязычных разборов, которые покрывают все три вектора от начала до флага с конкретными командами, — почти нет. Ниже — полный workflow для CTF-плеера: от обнаружения токена до подписи поддельного JWT с разбором механики каждой атаки. Не writeup одной задачи, а decision tree, который работает на любом соревновании.
JWT — строка из трёх частей, разделённых точками: header.payload.signature. Каждая часть закодирована в base64url. Токен прячется в одном из четырёх мест: cookie (имена jwt, token, session, auth), заголовок Authorization: Bearer <token>, параметр URL (?token=eyJ...) или тело POST-запроса. На CTF чаще всего — cookie или Authorization-заголовок. Открываем DevTools → Application → Cookies, или перехватываем запрос в Burp Proxy и ищем строку, начинающуюся с eyJ (base64url от {" — характерная сигнатура JWT). Подробнее — в нашем обзоре атаки на аутентификацию.
Декодируем header и payload прямо в терминале: echo '<часть>' | tr '_-' '/+' | base64 -d. Быстрее — вставить весь токен в jwt.io или запустить python3 jwt_tool.py <token> без флагов. jwt_tool покажет декодированный header, payload и информацию о подписи.
В header смотрим поле alg: значение HS256 — симметричный HMAC-SHA256 (один секрет и для подписи, и для проверки), RS256 — асимметричный RSA-SHA256 (приватный ключ подписывает, публичный проверяет), none — подписи нет вообще. Каждое значение открывает свой вектор атаки.
В payload ищем кастомные claims: "role": "user", "isAdmin": false, "username": "guest". Payload не зашифрован — base64url-декодирование показывает все данные открытым текстом. Подделка JWT токена сводится к изменению этих полей и повторной подписи (или отказу от подписи) токена.
Если сопоставить этапы JWT-атаки с MITRE ATT&CK (в авторской интерпретации, не официальная цепочка): обнаружение уязвимости в обработке токенов — Exploit Public-Facing Application (T1190, Initial Access), создание поддельного токена — Forge Web Credentials (T1606, Credential Access), использование подделки для доступа к защищённым ресурсам — Application Access Token (T1550.001, Defense Evasion / Lateral Movement). Для эксплуатации криптографических слабостей JWT также релевантна Exploitation for Credential Access (T1212).
За пределами CTF подделка токена с "role": "admin" в продакшен-приложении — захват аккаунта, доступ к админке, утечка данных клиентов. По OWASP Top 10 2021 уязвимости JWT аутентификации попадают сразу под три категории: A01 (Broken Access Control) — повышение привилегий через подмену claims, A02 (Cryptographic Failures) — слабые ключи и отсутствие криптографической защиты, A05 (Security Misconfiguration) — принятие алгоритма none без whitelist. Каждая CTF-задача на JWT — тренировка на реальных уязвимостях веб-приложений.
Спецификация JWT (RFC 7518) допускает значение "alg": "none" — токен без подписи. Если серверная библиотека доверяет полю alg из самого токена и не проверяет алгоритм по whitelist, подпись убирается полностью. Сервер прочитает "alg": "none", пропустит верификацию и примет любые claims. Как описано в разделе JWT attacks PortSwigger Web Security Academy (portswigger.net/web-security/jwt), проблема фундаментальна: сервер «вынужден» доверять метаданным из токена, и без явного ограничения допустимых алгоритмов открывает дверь для alg none bypass.
Самая частая уязвимость в CTF уровня Easy и Medium. И самая обидная, когда не проверишь первой.
Допустим, оригинальный токен:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Imd1ZXN0Iiwicm9sZSI6InVzZXIifQ.ZvkYYnyM929FM4NW9_hSis7_x3_9rymsDAx9yuOcc1I
Декодируем header: {"typ":"JWT","alg":"HS256"}. Декодируем payload: {"username":"guest","role":"user"}. Задача очевидна — сменить role на admin и убрать подпись.
# Шаг 1: кодируем новый header с alg:none
echo -n '{"typ":"JWT","alg":"none"}' | base64 | tr '+/' '-_' | tr -d '='
# Результат: eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0
# Шаг 2: кодируем payload с нужными claims
echo -n '{"username":"admin","role":"admin"}' | base64 | tr '+/' '-_' | tr -d '='
# Результат: eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIn0
# Шаг 3: собираем токен с пустой подписью (trailing dot обязателен)
# eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIn0.
Отправляем через curl -H "Cookie: jwt=<token>" <URL> или подставляем в Burp Repeater. Если в ответе статус 200 и содержимое изменилось — обход авторизации сработал.
Через jwt_tool весь процесс — одна команда: python3 jwt_tool.py <JWT> -X a. Флаг -X a запускает атаку alg:none и автоматически генерирует варианты токена с модифицированным алгоритмом.
Некоторые серверы блокируют строку none, но проверяют без учёта регистра. Согласно OWASP Web Security Testing Guide, обход достигается альтернативными написаниями: None, NONE, nOnE, noNe. jwt_tool при флаге -X a перебирает эти варианты автоматически — руками подставлять каждый не нужно.
Формат подписи тоже влияет: одни реализации ожидают header.payload. (trailing dot, пустая подпись), другие — header.payload (без финальной точки). Не прошёл первый вариант — пробуем второй.
Когда none-алгоритм не сработает: современные библиотеки (PyJWT >= 2.x, jsonwebtoken >= 9.x для Node.js) отклоняют none по умолчанию. На CTF уровня Hard автор задачи обычно использует свежую библиотеку с whitelist алгоритмов. Если none не прошёл — переходим к брутфорсу секрета.
Сервер корректно отклоняет none, но использует HS256 с коротким или словарным секретом — ключ подбирается офлайн за секунды. По MITRE ATT&CK это Password Cracking (T1110.002, Credential Access): атакующий извлекает подпись JWT и подбирает секретный ключ по словарю. Взлом JWT токенов через брутфорс — один из самых надёжных векторов, потому что авторы CTF-задач часто закладывают слабые секреты намеренно: secret, password, admin123, your-256-bit-secret (дефолт jwt.io), iloveyou.
В реальных приложениях ситуация ненамного лучше. Комбо-лист Exploit.In 2016 года (593 427 119 пар email+пароль, агрегированных из различных утечек) показывает масштаб проблемы слабых и повторно используемых паролей — тех же, что разработчики нередко задают как HMAC-секреты. По данным IBM X-Force Threat Intelligence Index 2025, рост атак с использованием действительных учётных данных составил 71% год к году — в том числе из-за слабых ключей подписи.
hashcat — основной инструмент для брутфорса секретного ключа JWT. Режим -m 16500 предназначен для JWT (покрывает HS256, HS384, HS512).
echo -n "<JWT_TOKEN>" > jwt.txt && hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
Флаг -a 0 — атака по словарю. На GPU уровня RTX 3060 перебор rockyou.txt (14 миллионов паролей) занимает секунды. На CPU — от минуты до получаса в зависимости от железа. Успешный результат выглядит как <JWT>:secret123 — hashcat выводит найденный секрет через двоеточие после токена.
Альтернатива без GPU: python3 jwt_tool.py <JWT> -C -d /usr/share/wordlists/rockyou.txt. Флаг -C запускает режим crack. Работает медленнее hashcat, но не требует GPU-драйверов. Для словарей до 100 тысяч строк — хватает.
Ещё вариант — John the Ripper: конвертируем JWT в формат john через jwt2john.py (скрипт из репозитория john jumbo), затем john --wordlist=rockyou.txt --format=HMAC-SHA256 jwt_john.txt. На CTF с ограничением по времени hashcat с GPU — приоритет, jwt_tool — запасной план если GPU нет.
Получив секрет (допустим, secret123), генерируем новый токен с нужными claims. Через jwt_tool: python3 jwt_tool.py <JWT> -T -S hs256 -p "secret123". Флаг -T открывает интерактивный режим tamper — меняем role на admin, username на нужное значение, подтверждаем. Инструмент подписывает токен найденным ключом и выводит готовый JWT.
Второй способ — jwt.io: вставляем токен, редактируем payload в визуальном редакторе, вводим найденный секрет в поле "Verify Signature". Готовый подписанный токен — слева.
Третий — одна строка Python: import jwt; print(jwt.encode({"username": "admin", "role": "admin"}, "secret123", algorithm="HS256")). На CTF, где нужна скорость, выбирайте тот способ, который быстрее набрать. Я обычно начинаю с jwt_tool — он под рукой и не надо открывать браузер.
Algorithm confusion (key confusion) — самая злая атака на JWT и главный пробел русскоязычных разборов: большинство статей её упоминают, но не показывают пошаговую эксплуатацию. HMAC RSA confusion атака эксплуатирует фундаментальное противоречие в дизайне JWT: токен сам сообщает серверу, как проверять подпись. Если сервер доверяет этому указанию — атакующий контролирует весь процесс верификации.
Суть: сервер использует RS256 (асимметричный алгоритм). Приватный ключ подписывает токен, публичный — проверяет. Атакующий меняет alg в заголовке на HS256 (симметричный). Библиотека на сервере видит HS256 и ищет один общий ключ. В уязвимых реализациях переменная с ключом содержит тот самый публичный RSA-ключ. Атакующий подписывает токен через HMAC-SHA256, используя публичный ключ как HMAC-секрет. Сервер проверяет тем же ключом. Подписи совпадают. Токен проходит верификацию.
Почему это работает: HMAC-SHA256 принимает любую строку байт как ключ. Ему без разницы, что байты — это RSA public key. Вся безопасность HS256 держится на секретности ключа, а RSA public key по определению публичный. По документам — публичный ключ для проверки, не для подписи. На практике — HMAC подпишет им без вопросов. Согласно исследованию PortSwigger Web Security Academy, проблема возникает из-за того, что многие JWT-библиотеки предоставляют единый метод verify(), который выбирает алгоритм на основе заголовка токена, а разработчики не передают параметр algorithms с явным whitelist.
Уязвимости этого класса широко упоминаются для Node.js jsonwebtoken (предположительно до версии 4.2.2) и Python PyJWT (предположительно до 1.5.0), хотя конкретные CVE для algorithm confusion в этих библиотеках в NVD не зафиксированы и точные версии требуют независимой проверки; аналогичный паттерн предположительно затрагивал ruby-jwt и php-jwt. Паттерн один: API по умолчанию доверяет alg из заголовка, а разработчик не ограничивает допустимые алгоритмы.
Для подписи поддельного токена нужен точный публичный ключ сервера. Где искать:
Стандартные эндпоинты: /.well-known/jwks.json, /jwks.json, /.well-known/openid-configuration (содержит URL на JWKS). Ключ в формате JWK — JSON-объект с параметрами n (modulus) и e (exponent) для RSA. Запрос: curl -s https://target/.well-known/jwks.json.
TLS-сертификат: иногда тот же RSA-ключ используется и для TLS, и для JWT (да, бывает и такое). Утянуть можно через openssl s_client -connect <host>:443 | openssl x509 -pubkey -noout.
Вычисление из пары токенов: если публичный ключ не доступен напрямую, PortSwigger предоставляет инструмент sig2n: docker run --rm -it portswigger/sig2n <token1> <token2>. Скрипт рассчитает возможные значения modulus n и выдаст PEM-ключи для каждого варианта плюс поддельные токены, подписанные каждым из них. Отправляем каждый поддельный токен через Burp Repeater — тот, который сервер принял, соответствует правильному ключу.
# 1. Стягиваем JWK с сервера
curl -s https://target/.well-known/jwks.json > jwks.json
# 2. Конвертируем JWK → PEM через Burp JWT Editor:
# JWT Editor Keys → New RSA Key → вставить JWK → PEM radio → скопировать
# Или через jwt_tool/openssl
# 3. Подписываем токен HS256 с PEM-ключом как секретом
python3 jwt_tool.py <JWT> -X k -pk public.pem
# jwt_tool подменит alg на HS256 и подпишет токен публичным ключом
# 4. Альтернатива: ручная подмена + интерактивный режим
python3 jwt_tool.py <JWT> -S hs256 -k public.pem -T
Критический момент (и на нём горят чаще всего): публичный ключ, которым подписываем, должен побайтово совпадать с ключом на сервере. Формат (X.509 PEM vs PKCS1), переносы строк, trailing newline — всё имеет значение. Не сработала первая попытка — пробуем другой формат. На практике приходится экспериментировать: скопировать JWK из JWKS, конвертировать в PEM через расширение JWT Editor в Burp Suite (вкладка JWT Editor Keys → New RSA Key → вставить JWK → скопировать PEM), затем base64-кодировать и использовать как значение k в новом симметричном JWK-ключе. Муторно, но работает.
Помимо трёх основных атак есть продвинутые техники, связанные с параметрами заголовка JWT. Встречаются на CTF уровня Hard и в bug bounty.
Параметр kid (Key ID) — необязательное поле заголовка, указывающее серверу, какой ключ из хранилища использовать для проверки подписи. Если значение kid не валидируется — оно становится вектором инъекции.
Path traversal через kid: атакующий устанавливает "kid": "../../dev/null". Файл /dev/null на Unix-системах возвращает пустую строку. Команда: python3 jwt_tool.py <JWT> -I -hc kid -hv "../../dev/null" -S hs256 -p "". Токен подписывается пустым секретом, сервер берёт «ключ» из /dev/null — тоже пустую строку. Подписи совпадают. Альтернатива — указать путь к любому предсказуемому файлу в веб-каталоге (CSS, JS) и использовать его содержимое как секрет: python3 jwt_tool.py <JWT> -I -hc kid -hv "path/to/file" -S hs256 -p "содержимое_файла".
SQL-инъекция через kid: если сервер хранит ключи в БД и строит SQL-запрос из значения kid без параметризации, атакующий подставляет "kid": "key1' UNION SELECT 'known_secret' -- " и заставляет сервер использовать произвольный секрет для проверки. Два бага в одном поле — красота.
JWK spoofing — CVE-2018-0114: уязвимость в библиотеке Cisco node-jose до версии 0.11.0 (CVSS 7.5 HIGH, вектор CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N, CWE-347 — Improper Verification of Cryptographic Signature). Библиотека позволяла встраивать произвольный публичный ключ JWK прямо в заголовок токена и доверяла ему для верификации. Атакующий генерировал свою RSA-пару, подписывал токен приватным ключом и вставлял публичный ключ в заголовок — библиотека принимала без вопросов. CISA оценивает CVE-2018-0114 как poc по параметру Exploitation с возможностью автоматизации (Automatable: yes). EPSS-оценка — 0.4265 (Top 5%, очень высокая вероятность эксплуатации). PoC доступен в репозитории zi0Black/POC-CVE-2018-0114 на GitHub.
Аналогичный вектор — параметр jku (JWK Set URL): если сервер загружает ключи по URL из заголовка токена без проверки allowlist, атакующий указывает URL своего сервера с собственным JWKS. Сервер скачивает ключ атакующего, проверяет подпись — всё совпадает.
Порядок проверки JWT, который экономит время на соревновании. Каждый следующий шаг — если предыдущий не дал результата:
| Шаг | Атака | Команда | Предусловие |
|---|---|---|---|
| 1 | Найти и декодировать токен | jwt_tool.py <JWT> |
Cookie, Authorization, URL |
| 2 | alg: none | jwt_tool.py <JWT> -X a |
Сервер принимает none |
| 3 | Подмена claims без подписи | jwt_tool.py <JWT> -I -pc role -pv admin |
Сервер не проверяет подпись |
| 4 | Брутфорс секрета (HS256) | hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt |
Словарный/короткий секрет |
| 5 | Algorithm confusion (RS256) | jwt_tool.py <JWT> -X k -pk public.pem |
Доступен публичный ключ |
| 6 | kid injection | jwt_tool.py <JWT> -I -hc kid -hv "../../dev/null" -S hs256 -p "" |
kid без валидации |
| 7 | JWK/jku spoofing | Ручная модификация header | Библиотека доверяет встроенным ключам |
Шаг 3 звучит абсурдно, но по данным PortSwigger, разработчики путают метод verify() с decode() в JWT-библиотеках. decode() только декодирует base64url без проверки подписи. Если сервер использует decode() вместо verify() — подпись вообще не проверяется, и можно менять любые claims без ограничений. Я видел это на реальных CTF минимум дважды — оба раза задача была помечена как Medium, хотя решалась за 30 секунд.
Инструментарий: jwt_tool (git clone https://github.com/ticarpi/jwt_tool && pip3 install -r requirements.txt), hashcat (режим 16500), Burp Suite с расширением JWT Editor для визуальной работы с токенами, jwt.io для быстрого дебага. Словарь rockyou.txt (14 миллионов записей, ~140 МБ) обычно валяется в /usr/share/wordlists/ на Kali Linux. Отдельно — Python-библиотека PyJWT (pip install pyjwt>=2.10.1) для скриптования одноразовых эксплойтов (версии ниже 2.10.1 подвержены GHSA-75c5-xw7c-p5pm — некорректная обработка неизвестных расширений в заголовке crit).
Большинство CTF-задач на JWT закрываются на шагах 2-4. Algorithm confusion и kid-инъекции — уровень Hard и финалов. Знание полной цепочки экономит время: вместо бессистемного перебора проходите чёткий decision tree и переходите к следующей задаче, а не зависаете на одном таске 40 минут.
Отдельно стоит проверить temporal claims: exp (время истечения), iat (время создания), nbf (not before). Если подпись не проверяется (шаг 3 сработал) или секрет подобран (шаг 4), достаточно выставить exp далеко в будущее, чтобы токен не протух во время эксплуатации.
Три CTF-команды из четырёх, с которыми я разбирал JWT-задачи на дебрифах, знали команду jwt_tool -X a и формулу hashcat -m 16500, но не могли объяснить, почему algorithm confusion работает. Когда задача отклоняется от шаблона — другой формат ключа, нестандартное имя claim, кастомная обёртка над библиотекой — начинается ступор. Корень проблемы: JWT-атаки учат по writeup'ам, копируя команды без разбора механики. А механика тонкая. HMAC не знает и не проверяет, что строка байт, переданная как секрет, — это RSA public key. Метод verify() в jsonwebtoken до версии 4.2.2 не различал RSA-объект от буфера с байтами. Значение alg: none допускается RFC 7518 — осознанное решение в спецификации, которое становится дырой при наивной реализации. Пока не разберёте, на каком уровне абстракции ломается безопасность — спецификация, API библиотеки или серверный код — вы будете привязаны к конкретным командам и конкретным writeup'ам. А задачи на CTF пишут новые каждый сезон. Если хочешь не просто writeup, а пройти всю атаку самому — на WAPT есть лаба с каждым из этих векторов и ментор в чате при затыке.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...