Содержание:
- Три куска через точку
- Почему Base64url, а не обычный Base64
- Полный пример: создаем JWT от JSON до финальной строки
- Разбираем header
- Разбираем payload и claims
- Что видит хакер, декодируя чужой JWT
- Edge cases: где JWT ломается неожиданно
- Декодируем JWT на Python
- Что дальше
В прошлой статье я показал, почему JWT дырявый по дизайну. Теперь берем реальный токен и разбираем как патологоанатом - до последнего байта.
Три куска через точку
JWT - это три Base64url-строки, склеенные точками:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.signature
|__________header___________|.|_________________________payload__________________________|.|___sig___|
Header. Payload. Signature. Точка (байт 0x2E) выбрана разделителем потому что она не входит в алфавит Base64url. Это значит, что простой split('.') гарантированно корректно разделит токен на три части - никаких edge cases.
Почему Base64url, а не обычный Base64
Обычный Base64 использует символы +, / и =. Проблема в том, что каждый из них имеет специальное значение в URL: + превращается в пробел при парсинге параметров, / является разделителем пути, а = используется для разделения ключа и значения в query string.
Base64url решает это тремя заменами:
+заменен на-(дефис)/заменен на_(подчеркивание)=(padding в конце) просто убирается
Вот как это выглядит на конкретном примере. Строка "foobar" в обоих вариантах кодируется одинаково: Zm9vYmFy. Но строка "fo" в стандартном Base64 дает Zm8=, а в Base64url - Zm8 (без padding). Строка, содержащая байты с определенными значениями, может дать a+b/c== в Base64 и a-b_c в Base64url.
Как работает padding и почему его убирают. Base64 кодирует данные группами по 3 байта в 4 символа. Если входные данные не кратны трем байтам, в конце добавляется = или ==, чтобы дополнить до четырех символов. В JWT padding не нужен, потому что при декодировании длина строки однозначно определяет, сколько = надо добавить: если длина mod 4 равна 2 - добавляем ==, если 3 - добавляем =, если 0 - ничего не добавляем.
Это важно понимать, потому что Base64url-декодирование будет встречаться в каждой следующей статье. Дальше я буду просто писать “декодируем” - имея в виду именно Base64url.
Полный пример: создаем JWT от JSON до финальной строки
Давай пройдем весь путь от JSON-данных до готового токена. Это поможет понять, что именно подписывается и как.
Шаг 1. Создаем JSON-заголовок:
{"alg":"HS256","typ":"JWT"}
alg указывает алгоритм подписи (HMAC-SHA256, симметричный - один секрет для подписи и проверки). typ говорит “это JWT” - поле необязательное, но его ставят почти все.
Шаг 2. Кодируем заголовок в Base64url:
{"alg":"HS256","typ":"JWT"} --> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Шаг 3. Создаем payload с данными пользователя:
{"sub":"user123","role":"admin","exp":1700000000}
Шаг 4. Кодируем payload в Base64url:
{"sub":"user123","role":"admin","exp":1700000000} --> eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ
Шаг 5. Формируем входные данные для подписи.
Конкатенируем закодированный заголовок и закодированный payload через точку:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ
Подписывается именно эта строка - не исходный JSON, а уже закодированные данные. Это критически важный момент: если пересобрать JSON с другим порядком ключей (например, {"typ":"JWT","alg":"HS256"} вместо {"alg":"HS256","typ":"JWT"}), Base64url будет другим, и подпись изменится. JSON формально неупорядочен, но порядок фиксируется в момент кодирования.
Шаг 6. Вычисляем подпись:
HMAC-SHA256(signing_input, secret_key)
Результат - 32 байта, которые кодируются в Base64url.
Шаг 7. Собираем финальный токен:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Три куска через точку. Весь токен - это ASCII-строка, каждый символ из алфавита A-Z a-z 0-9 - _ .. URL-safe, помещается в HTTP-заголовок, можно вставить в query string.
Разбираем header
Минимальный header - {"alg":"HS256"}. Поле alg обязательно. Но помимо alg и typ, в заголовке JWT могут быть параметры, каждый из которых потенциально уязвим:
- kid - идентификатор ключа. Произвольная строка, сервер по ней находит нужный ключ. SQL Injection, path traversal, command injection - все через kid. Разберу в статье 5.
- jku - URL, откуда сервер должен скачать ключ для проверки. SSRF и подмена ключа. Статья 6.
- jwk - публичный ключ, встроенный прямо в заголовок токена. Подмена ключа. Статья 6.
- x5u - URL для загрузки X.509 сертификата. Аналогично jku - SSRF. Статья 6.
- x5c - цепочка X.509 сертификатов прямо в токене. Самоподписанный сертификат. Статья 6.
Каждый из этих параметров - отдельный вектор атаки. По сути, весь заголовок JWT - это attack surface.
Разбираем payload и claims
Payload - обычный JSON. Как я говорил в первой статье, он закодирован, но не зашифрован. Хочешь прочитать чужой токен - просто декодируй:
echo -n "eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ" | base64 -d
Результат: {"sub":"user123","role":"admin","exp":1700000000}
RFC 7519 определяет семь стандартных claims. Ни один из них не обязателен - все OPTIONAL. Но у каждого есть четкая семантика, и если claim присутствует, сервер должен его обрабатывать.
iss (Issuer) - кто выдал токен. Строка, case-sensitive. Обычно URL IdP: "iss": "https://auth.example.com". Если у тебя несколько IdP, без проверки iss сервер не может определить, каким ключом верифицировать подпись.
sub (Subject) - о ком токен. Обычно user ID: "sub": "user123". Должен быть уникальным в контексте issuer-а.
aud (Audience) - для кого токен предназначен. Единственный claim с жестким правилом: если aud есть, а получатель себя в нем не нашел - токен MUST быть отклонен. Может быть строкой или массивом:
"aud": "https://api.example.com"
"aud": ["https://api1.example.com", "https://api2.example.com"]
Сервер обязан поддерживать оба формата. Поддерживает только строку, пришел массив - падение или обход. Почему aud критически важен для безопасности - расскажу чуть ниже.
exp (Expiration Time) - когда токен истекает. Unix timestamp в секундах. Не в миллисекундах - это частая ошибка. Пример: "exp": 1700000000 - это 14 ноября 2023 года, 22:13:20 UTC. Текущее время должно быть строго меньше exp, иначе токен отклоняется. Сервера обычно допускают небольшой clock skew - несколько минут погрешности из-за рассинхронизации часов.
nbf (Not Before) - когда токен начинает действовать. Тоже Unix timestamp. Пример: "nbf": 1699900000 значит “не принимать этот токен раньше 13 ноября 2023”. Обращаю внимание на асимметрию: для nbf проверка “больше или равно”, для exp - строго “меньше”.
iat (Issued At) - когда выдан. Unix timestamp. Используется для определения возраста токена, но формально не подразумевает валидационной проверки.
jti (JWT ID) - уникальный идентификатор токена. Предназначен для защиты от replay-атак: сервер запоминает jti использованных токенов и отклоняет повторы. На практике требует хранилище для отслеживания - что возвращает нас к stateful модели, от которой JWT должен был нас избавить.
Что видит хакер, декодируя чужой JWT
Перехватил токен. Декодировал. Что теперь?
Payload открывает тебе внутреннюю кухню приложения. Помимо стандартных claims, разработчики складывают туда все подряд:
{
"sub": "user_42",
"email": "admin@company.com",
"role": "admin",
"org_id": "org_17",
"permissions": ["read", "write", "delete"],
"plan": "enterprise",
"internal_user_id": 42
}
Роли, email-адреса, идентификаторы организаций, уровни подписок, внутренние ID - все в открытом виде. Это не баг, это by design: JWT подписывает данные, но не шифрует их.
А заголовок рассказывает, какие атаки пробовать. "alg": "HS256" - значит симметричный секрет, можно попробовать брутфорс (статья 7). "alg": "RS256" - асимметричная подпись, ищем публичный ключ для algorithm confusion (статья 4). Есть kid - потенциал для инъекций (статья 5). Есть jku - вектор для SSRF (статья 6).
Edge cases: где JWT ломается неожиданно
Дубликаты ключей в JSON. JSON RFC говорит: ключи SHOULD быть уникальны. Не MUST, а SHOULD - то есть дубликаты формально допустимы. Что произойдет с таким payload?
{"role": "user", "role": "admin"}
Зависит от парсера. Одни берут первое значение, другие - последнее. Если proxy видит "user", а бэкенд видит "admin", получаем privilege escalation через parser differential. RFC 7515 требует от JWT-парсеров либо отклонять дубликаты, либо использовать последнее значение. Но далеко не все это соблюдают.
Пустой payload. Две точки подряд - пустой payload:
eyJhbGciOiJIUzI1NiJ9..signature
Это синтаксически валидный JWS, но не валидный JWT, потому что JWT требует JSON-объект в payload. Некоторые библиотеки пропускают.
Проблема 2038 года. Claim exp - Unix timestamp. На системах с 32-битными целыми максимальное значение: 2147483647 = 19 января 2038 года. Что если поставить "exp": 2147483648? Переполнение, значение становится отрицательным, проверка now < exp всегда true. Бессмертный токен.
import struct
val = 2147483648
packed = struct.pack('>i', val & 0xFFFFFFFF)
print(struct.unpack('>i', packed)[0]) # -2147483648
Unicode-ловушки. "sub": "cafe\u0301" (e + combining accent) и "sub": "caf\u00e9" (precomposed e) выглядят одинаково, но это разные последовательности байт. Разные байты значит разные Base64url, разные подписи. Кириллическое “а” (U+0430) и латинское “a” (U+0061) визуально неотличимы, но для парсера - разные символы. Homoglyph-атака на sub или iss может привести к обходу проверки.
Декодируем JWT на Python
Вот минимальный скрипт для разбора любого JWT:
import base64, json
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.xxx"
for i, part in enumerate(token.split(".")):
pad = part + "=" * (4 - len(part) % 4)
try:
data = base64.urlsafe_b64decode(pad)
print(f"Part {i}: {json.loads(data)}")
except:
print(f"Part {i} (raw): {data.hex()}")
И bash-однострочник для быстрой проверки:
echo -n "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | python3 -m json.tool
echo -n "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
Что дальше
Анатомию разобрали. Ты знаешь, из чего состоит JWT, как он создается, что внутри каждой части. Теперь - атаки.
Помнишь, в первой статье я говорил, что токен сам указывает алгоритм проверки? В следующей статье - конкретная эксплуатация этого дефекта: alg:none, одна строчка и ты admin.