Содержание:

До сих пор мы подменяли алгоритмы (статьи 3-4) и инжектили параметры заголовка (статьи 5-6). Все эти атаки эксплуатируют логические ошибки в обработке токена. А что если логика правильная, подпись проверяется корректно - но секрет просто слабый?

Почему JWT уникален для брутфорса

Вот что делает JWT идеальной мишенью для офлайн-атаки: весь необходимый материал содержится в одном токене.

Когда ты брутфорсишь пароль к веб-форме, каждая попытка - это запрос к серверу. Rate limiting, блокировка аккаунта, CAPTCHA. Тысяча попыток в секунду - потолок.

С JWT все иначе. Как я показывал во второй статье, токен состоит из трех частей: header.payload.signature. Header и payload - это сообщение. Signature - это HMAC от этого сообщения с секретным ключом. У тебя есть и сообщение, и подпись. Осталось подобрать ключ. И для этого сервер не нужен. Все вычисления происходят локально, на твоем GPU.

Как определить, что алгоритм симметричный

Первый шаг - декодировать заголовок и проверить алгоритм:

echo -n "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null

Если видишь "alg": "HS256", "HS384" или "HS512" - алгоритм симметричный (HMAC). Брутфорс возможен. Если "RS256", "ES256", "PS256" - асимметричный. Брутить нечего, приватный ключ в токене не участвует. Для асимметричных алгоритмов нужны другие подходы: algorithm confusion (статья 4) или psychic signatures (статья 8).

HS256 - самый популярный алгоритм JWT. Он единственный обязательный для реализации по RFC 7518 (кроме none). Большинство tutorials, примеров на Stack Overflow и конфигов по умолчанию используют HS256. А значит - большинство JWT в дикой природе уязвимы для брутфорса, если секрет слабый.

Hashcat mode 16500

Hashcat - инструмент для GPU подбора паролей и ключей. Режим 16500 - это HMAC-SHA256 для JWT.

# Записываем токен в файл
echo -n "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYWRtaW4iOmZhbHNlfQ.signature_here" > jwt.txt

# Словарная атака
hashcat -a 0 -m 16500 jwt.txt \
  /usr/share/wordlists/jwt.secrets.list

# С правилами мутации - hashcat модифицирует каждое слово
# из словаря (добавляет цифры, меняет регистр и т.д.)
hashcat -a 0 -m 16500 jwt.txt wordlist.txt \
  -r /usr/share/hashcat/rules/best64.rule

# Маска: все 6-символьные комбинации в нижнем регистре
hashcat -a 3 -m 16500 jwt.txt ?l?l?l?l?l?l

# Маска: 8 символов, буквы + цифры + спецсимволы
hashcat -a 3 -m 16500 jwt.txt ?a?a?a?a?a?a?a?a

Скорости на конкретном железе

Вот реальные цифры, чтобы было понятно, насколько это быстро:

  • RTX 4090 (GPU): ~150 миллионов HS256/сек
  • RTX 3090 (GPU): ~100 миллионов HS256/сек
  • i9-13900K (CPU): ~5 миллионов HS256/сек
  • jwt_tool на Python: ~50 тысяч HS256/сек

А вот что эти скорости означают для подбора секрета на RTX 4090:

  • 6 символов, только строчные буквы [a-z]: 308 миллионов вариантов = 2 секунды
  • 8 символов, только строчные [a-z]: 208 миллиардов = 23 минуты
  • 8 символов, буквы + цифры [a-zA-Z0-9]: 218 триллионов = 17 дней
  • 32 байта из криптографически стойкого генератора: не подберешь никогда

Разница между “secret” и 32 случайных байта - это разница между 2 секундами и бесконечностью.

jwt.secrets.list - словарь от Wallarm

Первое, что я запускаю при тестировании JWT - словарную атаку со специализированным словарем jwt.secrets.list, собранным командой Wallarm. Он содержит реальные секреты из утечек, дефолтных конфигов и CVE:

  • secret, password, 123456 - классика
  • your-256-bit-secret - дефолт с jwt.io, который разработчики копируют в продакшн
  • a-string-secret-at-least-256-bits-long - ещё один популярный дефолт из туториалов
  • django-insecure-* - дефолтные секреты фреймворков
  • changeme, test, development - секреты, которые “потом поменяем”
  • notfound - тот самый секрет из CVE-2025-20188 (Cisco IOS XE, CVSS 10.0)

Важный момент: your-256-bit-secret и a-string-secret-at-least-256-bits-long содержат “256 bit” в названии и оба длиннее 32 байт. Разработчик видит требование RFC “ключ >= 256 бит”, берёт строку с “256-bit” в названии и думает что всё ок. Но 256 бит - это про энтропию, не про длину строки. a-string-secret-at-least-256-bits-long - это 38 ASCII-символов, формально 304 бита. Но это читаемая английская фраза, она в словаре и подбирается мгновенно. Когда RFC говорит “256 бит”, он имеет в виду openssl rand -base64 32 - 32 случайных байта, где каждый бит непредсказуем.

Словарная атака с этим словарем занимает доли секунды и часто срабатывает. Если не помогло - подключаем правила мутации, потом маски. И только потом переходим к полному перебору.

jwt_tool - встроенный крекер

Если нет GPU или hashcat - jwt_tool имеет встроенную функцию подбора:

python3 jwt_tool.py "$TOKEN" -C \
  -d /usr/share/wordlists/jwt.secrets.list

Работает на CPU, скорость на порядки ниже hashcat, но для словарных атак на слабые секреты вполне достаточно.

Что говорит RFC

RFC 7518 Section 3.2 содержит нормативное требование: ключ для HS256 MUST быть не менее 256 бит (32 байта). Для HS384 - 384 бита. Для HS512 - 512 бит.

RFC 8725 усиливает: ключ MUST NOT быть человеческим паролем. Только криптографически стойкий генератор случайных чисел (CSPRNG). То есть openssl rand -base64 32, а не mysupersecretkey.

Реальность: разработчики ставят "secret", "password", process.env.JWT_SECRET || "fallback" (где fallback - дефолтное значение, если переменная окружения не задана). И секрет "fallback" оказывается в продакшене.

Чеклист для тестирования

  1. Перехватил JWT с HS256 - сразу в hashcat с jwt.secrets.list
  2. Не нашел? jwt.secrets.list + best64.rule (правила мутации)
  3. Проверь дефолтные секреты конкретного фреймворка (Next.js, Django, Spring Boot)
  4. Если доступен .env файл (через LFI, git-утечку, docker inspect) - ищи JWT_SECRET, SECRET_KEY, SIGNING_KEY
  5. Docker-образ доступен? docker save image | tar -xO | grep -ri "secret\|jwt\|signing"
  6. Ничего не нашел? Маска на 8 символов [a-zA-Z0-9] - 17 дней на одном GPU

Что дальше

Мы ломали HMAC (этот пост) и RSA через algorithm confusion (статья 4). Осталось ECDSA. В следующей статье - Psychic Signatures: баг в Java, при котором подпись из одних нулей проходит верификацию для любого сообщения, с любым ключом. Пять строк Python - и ты admin на любом Java-сервисе с Java 15-18.