Содержание:

В первой статье я говорил, что токен сам указывает серверу, как проверять подпись. Во второй мы разобрали поле 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.

Теперь атака. Мы хотим стать админом:

  1. Создаем новый payload: {"sub":"admin","role":"superuser"}
  2. В заголовке ставим: {"alg":"none","typ":"JWT"}
  3. Кодируем оба в Base64url
  4. Склеиваем через точку, оставляя пустую подпись (но точку сохраняем): 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 - атаку куда элегантнее. Ты берешь публичный ключ сервера, который лежит в открытом доступе, подписываешь им свой токен - и сервер его принимает. Подпись есть, подпись правильная, а токен все равно поддельный.