Содержание:
- Зачем существует alg:none
- Как это работает
- Как проверить, уязвим ли сервер
- Case variations: обход фильтров
- Null byte трюк
- decode() vs verify()
- CVE: одиннадцать лет одного бага
- Защита
- Что дальше
В первой статье я говорил, что токен сам указывает серверу, как проверять подпись. Во второй мы разобрали поле alg в заголовке JWT. Теперь - конкретный пример того, как этот дизайн ломается.
Зачем существует alg:none
RFC 7518 определяет алгоритм none и требует его поддержки от каждой JWT-библиотеки. Казалось бы, зачем стандарт требует реализовать токены без подписи? У авторов RFC была конкретная идея: Unsecured JWT нужен в ситуациях, когда целостность обеспечивается на другом уровне. Например, JWT передается внутри TLS-канала и больше никуда не выходит. Или JWT является частью другой, уже подписанной структуры данных. В таких случаях двойная подпись - лишний overhead.
Звучит разумно в теории. На практике это создало бомбу замедленного действия. Если библиотека читает alg из заголовка токена и видит none - она пропускает верификацию. А заголовок контролирует тот, кто создает токен. В том числе - атакующий.
Как это работает
Допустим, у нас есть легитимный JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ.RSA_signature_here
Декодируем заголовок: {"alg":"RS256","typ":"JWT"}. Декодируем payload: {"sub":"user","role":"user"}. Подпись - RSA-SHA256.
Теперь атака. Мы хотим стать админом:
- Создаем новый payload:
{"sub":"admin","role":"superuser"} - В заголовке ставим:
{"alg":"none","typ":"JWT"} - Кодируем оба в Base64url
- Склеиваем через точку, оставляя пустую подпись (но точку сохраняем):
header.payload.
Отправляем серверу. Уязвимая библиотека видит alg: none, пропускает верификацию, принимает токен. Мы - admin.
Вот весь процесс в Python:
import base64, json
def b64(data):
return base64.urlsafe_b64encode(
json.dumps(data, separators=(',',':')).encode()
).rstrip(b'=').decode()
h = b64({"alg":"none","typ":"JWT"})
p = b64({"sub":"admin","role":"superuser","exp":1999999999})
print(f'{h}.{p}.')
Скопировал вывод, вставил в Authorization: Bearer <token> - готово.
Как проверить, уязвим ли сервер
Самый простой тест: возьми свой легитимный токен и инвертируй один бит в подписи. Если сервер все равно принимает его - подпись вообще не проверяется. Это даже хуже, чем alg:none - это decode() вместо verify().
jwt_tool (github.com/ticarpi/jwt_tool) - стандартная утилита для тестирования JWT, как sqlmap для SQL-инъекций. Если подпись проверяется, пробуем alg:none. jwt_tool делает это одной командой:
python3 jwt_tool.py "$TOKEN" -X a
Флаг -X a автоматически генерирует и тестирует все вариации none. Если хотя бы одна вернула 200 вместо 401 - сервер уязвим.
Case variations: обход фильтров
Первый очевидный фикс - проверять if alg == "none": reject(). Проблема в том, что это case-sensitive сравнение. Строка "None" не равна "none" в Python, JavaScript, Go - в большинстве языков. А библиотека при обработке может привести значение к нижнему регистру или обработать его case-insensitive.
Получается забавная ситуация: фильтр отклоняет "none", но пропускает "None". Библиотека принимает "None" как валидный алгоритм и обрабатывает его как none. Полный список для фаззинга:
none, None, NONE, nOnE, NoNe, nonE, noNe, nONE, NONe, NOne
jwt_tool перебирает их все автоматически.
Null byte трюк
"alg": "none\x00HS256" - нулевой байт в середине строки. Почему это работает?
В языках C и C++ строки заканчиваются нулевым байтом (null-terminated strings). Если сервер использует C-расширения для парсинга JSON или валидации алгоритма, нативный код прочитает строку до нулевого байта и увидит none. Высокоуровневый язык (Python, JavaScript) видит полную строку none\x00HS256. Фильтр проверяет полную строку, не находит в ней none (потому что после none есть еще символы), и пропускает. Парсер обрезает на нулевом байте и обрабатывает как none.
decode() vs verify()
Классическая ошибка разработчика - вызвать jwt.decode() вместо jwt.verify(). Функция decode извлекает payload, но не проверяет подпись. Разработчик думает: “я же декодировал токен, значит он валидный”. Нет. Декодирование - это Base64url. Верификация - это проверка криптографической подписи. Два совершенно разных действия.
Тест на эту ошибку тривиален: возьми любой токен, измени один символ в подписи, отправь серверу. Если принят - подпись не проверяется. Не нужен даже alg:none.
CVE: одиннадцать лет одного бага
CVE-2015-9235 (jsonwebtoken, Node.js, CVSS 9.8) - это та самая уязвимость, которую раскрыл Тим Маклин в 2015 году. Библиотека с 17 миллионами загрузок в неделю принимала alg: none по умолчанию. Один из самых резонансных security-дискложуров в истории веб-безопасности.
CVE-2016-10555 (jwt-simple, Node.js) - функция jwt.decode() не проверяла алгоритм вообще.
CVE-2026-23993 (HarbourJwt, Go) - 2026 год. Библиотека использовала switch-case по алгоритмам, и при неизвестном алгоритме (включая none) падала в default-ветку, которая возвращала пустую подпись. Одиннадцать лет после первого CVE - тот же баг.
Защита
Единственная надежная защита - серверный allowlist алгоритмов. Никогда не доверять alg из токена:
jwt.decode(token, key, algorithms=["RS256"])
Если указан конкретный алгоритм при вызове decode или verify, библиотека проигнорирует alg из заголовка и использует только разрешенный. none не в списке - отклонен. HS256 не в списке - отклонен. Только RS256 - только RSA.
Дополнительно: блокировка none case-insensitive: alg.lower().strip() == "none". И проверка, что подпись не пустая, прежде чем обрабатывать payload.
Что дальше
alg:none - это грубая сила: “не проверяй подпись вообще”. В следующей статье я покажу algorithm confusion - атаку куда элегантнее. Ты берешь публичный ключ сервера, который лежит в открытом доступе, подписываешь им свой токен - и сервер его принимает. Подпись есть, подпись правильная, а токен все равно поддельный.