Содержание:

До сих пор мы работали с JWS - подписанными токенами. Три части через точку. Payload виден всем, подпись гарантирует целостность, но не конфиденциальность. Любой может декодировать Base64url и прочитать содержимое - помним примеры из второй статьи?

JWE устроен иначе. Payload зашифрован. Ты не можешь просто декодировать и прочитать claims. Пять частей вместо трех, два уровня шифрования и набор криптоатак, о которых почти никто не говорит.

Как отличить JWE от JWS

Быстрый способ: посчитай точки.

# 2 точки, 3 части = JWS (подпись) header.payload.signature
# 4 точки, 5 частей = JWE (шифрование) header.key.iv.ciphertext.tag

Или декодируй заголовок (первая часть до первой точки, декодируется одинаково для JWS и JWE):

# base64url - base64: замена символов + padding
echo "$TOKEN" | cut -d. -f1 | tr -- '-_' '+/' | \
  awk '{while(length%4)$0=$0"=";print}' | base64 -d

JWS: {"alg":"RS256"} - только алгоритм подписи. JWE: {"alg":"RSA-OAEP","enc":"A256GCM"} - два алгоритма. Если видишь поле enc - это JWE.

Двухуровневое шифрование: зачем два алгоритма

JWE использует гибридное шифрование. Идея простая: асимметричные алгоритмы (RSA, ECDH) умеют шифровать только маленькие блоки данных (RSA-OAEP с 2048-битным ключом и SHA-1 - максимум 214 байт, RSA-OAEP-256 с SHA-256 - 190 байт), а payload JWT может быть значительно больше. Симметричные алгоритмы (AES) шифруют любой объем данных быстро, но требуют общего секрета.

Решение: совместить оба подхода.

  1. Генерируется случайный симметричный ключ - CEK (Content Encryption Key). Например, 32 случайных байта для AES-256-GCM. Для AES-CBC-HMAC режимов CEK длиннее: 256 бит для A128CBC-HS256 (128 на шифрование + 128 на MAC), 512 бит для A256CBC-HS512.
  2. CEK используется для шифрования payload алгоритмом AES. Это быстро и работает с данными любого размера.
  3. Сам CEK защищается: шифруется (RSA-OAEP), оборачивается (AES Key Wrap) или выводится через согласование ключей (ECDH-ES). Это безопасно, потому что CEK маленький.

Получатель достает CEK своим приватным ключом и расшифровывает payload. Два уровня - отсюда два поля в заголовке: alg для защиты CEK (key management), enc для шифрования payload (content encryption).

Формат JWE: пять частей

Header.EncryptedKey.IV.Ciphertext.Tag

Header - JSON с алгоритмами, закодированный в Base64url. Пример: {"alg":"RSA-OAEP","enc":"A256GCM"}. Этот заголовок не зашифрован (как и в JWS), но защищен от модификации: он включается в Additional Authenticated Data (AAD) при шифровании. Если кто-то изменит заголовок, Authentication Tag не сойдется.

Encrypted Key - CEK, зашифрованный алгоритмом из alg. Для прямого шифрования (алгоритм dir) или ECDH-ES (прямое согласование ключей) это поле пустое - CEK не передается, а вычисляется или используется напрямую.

IV (Initialization Vector) - вектор инициализации для симметричного шифрования. 12 байт для AES-GCM, 16 байт для AES-CBC.

Ciphertext - зашифрованный payload. Тут лежат claims, но прочитать их без ключа нельзя.

Tag (Authentication Tag) - тег аутентификации. Гарантирует, что ни ciphertext, ни заголовок не были модифицированы. 16 байт для AES-GCM.

Invalid Curve Attack на ECDH-ES

Март 2017 года. Antonio Sanso находит баг в пяти JWT-библиотеках одновременно: go-jose, node-jose, jose2go, Nimbus JOSE+JWT, jose4j. Каждая позволяет полностью восстановить приватный ключ сервера.

Контекст. ECDH-ES (Elliptic Curve Diffie-Hellman Ephemeral Static) - алгоритм согласования ключей. Клиент генерирует эфемерную (одноразовую) пару ключей и отправляет свой эфемерный публичный ключ в заголовке JWE через параметр epk (ephemeral public key). Сервер берет этот ключ и вычисляет общий секрет: shared_secret = server_private_key * client_ephemeral_public_key. Из общего секрета выводится CEK.

Проблема. Эллиптическая кривая определяется уравнением y^2 = x^3 + ax + b (mod p). Формулы сложения и удвоения точек используют параметр a, но игнорируют b. Это значит, что если подставить точку, которая лежит не на P-256 (правильной кривой), а на кривой с другим b, сервер все равно выполнит вычисление без ошибок. Библиотеки не проверяли, что точка из epk лежит на правильной кривой.

Атака по шагам:

Атакующий находит альтернативные кривые (с тем же a и p, но другим b'), у которых есть подгруппы малого порядка. Малый порядок - например, 7 или 11 элементов. Берет точку из такой подгруппы и подставляет в epk:

{
  "alg": "ECDH-ES",
  "enc": "A128GCM",
  "epk": {
    "kty": "EC", "crv": "P-256",
    "x": "<точка на невалидной кривой>",
    "y": "<...>"
  }
}

Сервер вычисляет d * P, где d - его приватный ключ, P - наша точка из подгруппы малого порядка n_i. Результат попадает в эту подгруппу. Перебираем n_i вариантов, для каждого создаем JWE, отправляем серверу. По ответу (200 vs 400) определяем, какой вариант правильный. Так узнаем d mod n_i.

Повторяем для разных кривых с разными малыми подгруппами. Когда собрали достаточно значений d mod n_1, d mod n_2, …, d mod n_k, применяем CRT (Китайскую теорему об остатках) и восстанавливаем полный приватный ключ d.

Сколько запросов? Jager et al. (2015) считали для аналогичной атаки на TLS-ECDH: ~3300 на Oracle/SunEC, ~17000 на Bouncy Castle. Для JWE-библиотек порядок тот же - тысячи запросов, не миллионы. Полное восстановление приватного ключа. Расшифровка всех прошлых и будущих JWE. А если ключ общий для подписи и шифрования (cross-protocol key reuse) - подделка подписей в придачу.

Уязвимые версии: go-jose < 1.0.5, node-jose < 0.9.3, Nimbus JOSE+JWT < 4.34.2, jose4j < 0.5.1, jose2go (исправлено без номера версии). Та же атака работает на ECDH-ES+A128KW и ECDH-ES+A256KW - те же библиотеки, та же проблема.

Защита: проверять y^2 == x^3 + ax + b (mod p) для каждой точки из epk перед ECDH. Все пять библиотек добавили эту проверку после раскрытия.

Bleichenbacher на RSA1_5

Атака 1998 года, которая до сих пор находит жертв.

alg: "RSA1_5" шифрует CEK через PKCS#1 v1.5 padding. Формат: 0x00 || 0x02 || [случайные ненулевые байты] || 0x00 || CEK. При расшифровке сервер проверяет, что первые два байта - 0x00 0x02. Если сервер по-разному реагирует на правильный и неправильный padding, это oracle.

CVE-2026-28490 (Authlib) - свежий пример. Python-библиотека cryptography при невалидном padding возвращает случайные байты (как и рекомендует RFC 3218 для защиты от Bleichenbacher). Но Authlib проверяла длину полученного CEK до AES-GCM расшифровки:

  • Padding валидный, но MAC не сходится: InvalidTag (ошибка AES-GCM)
  • Padding невалидный, случайный CEK неверной длины: ValueError (ошибка длины)

Разные исключения приводили к разным HTTP-кодам. Два разных ответа сервера - и oracle создан. ~14500 запросов для расшифровки CEK.

# Тестирование: модифицируем encrypted_key побитово
curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $JWE_ORIGINAL" \
  https://target/api
# vs
curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $JWE_MODIFIED" \
  https://target/api
# Разные HTTP-коды = oracle существует

Защита: использовать RSA-OAEP вместо RSA1_5. OAEP устойчив к классическому Bleichenbacher, но не неуязвим в принципе: Manger (2001) показал атаку на RSA-OAEP через различение ошибок декодирования - ~1000 запросов. Главное правило для любого alg - единый error response для всех криптографических ошибок. Атакующий не должен различать “плохой padding”, “плохой MAC” или “плохой OAEP”.

Padding Oracle на AES-CBC

enc: "A128CBC-HS256" - это AES в режиме CBC с HMAC для аутентификации. AES-CBC требует PKCS#7 padding: последний блок дополняется байтами, каждый из которых равен количеству дополненных байт (например, 4 байта со значением 0x04).

Если сервер различает “invalid padding” и “invalid MAC” - это классическая атака Vaudenay (2002):

  1. Берем блок ciphertext и меняем один байт в предыдущем блоке
  2. Отправляем серверу. 256 вариантов на каждый байт
  3. “Invalid MAC” вместо “Invalid padding” = мы угадали правильный padding
  4. Из свойства XOR в CBC вычисляем байт plaintext

4096 запросов на 16-байтный блок. Весь payload расшифровывается блок за блоком.

RFC 7518 (Section 5.2.2.2) защищает от этого: HMAC проверяется первым. Если HMAC не сходится, padding вообще не проверяется. Атака ломается, когда реализация проверяет padding до HMAC или возвращает разные коды ошибок.

Защита: использовать AES-GCM (A128GCM, A256GCM). GCM - нативный AEAD (Authenticated Encryption with Associated Data). Нет отдельного padding, нет отдельной MAC-проверки. Шифрование и аутентификация в одной операции. Padding oracle невозможен by design.

PBES2 DoS: одним запросом положить сервер

Самая простая атака из всех. CVE-2023-52428 (Nimbus JOSE+JWT).

alg: "PBES2-HS256+A128KW" - PBKDF2 (Password-Based Key Derivation Function 2). Алгоритм выводит ключ из пароля через многократное хеширование. Параметр p2c в заголовке JWE определяет количество итераций. И этот параметр контролируется атакующим:

import json, base64

def b64url(d):
    return base64.urlsafe_b64encode(d).rstrip(b'=').decode()

header = {
    "alg": "PBES2-HS256+A128KW",
    "enc": "A128GCM",
    "p2s": b64url(b"salt"),
    "p2c": 2147483647  # 2^31-1 итераций
}
h = b64url(json.dumps(header).encode())
print(f"{h}.AAAA.AAAA.AAAA.AAAA")

2.1 миллиарда итераций HMAC-SHA256. На одном ядре это ~1000 секунд процессорного времени. Один запрос = сервер занят 16 минут на одном ядре. Несколько параллельных запросов = полный DoS.

Защита: лимит на p2c. draft-ietf-oauth-rfc8725bis рекомендует не более 1 200 000 итераций (подробнее в статье 19). Если p2c больше лимита - отклонять токен до начала вычислений.

AES-GCM: повторный IV = катастрофа

AES-GCM с одним и тем же IV (nonce) и ключом на двух разных сообщениях - одна из самых недооцененных ошибок. Что происходит:

  1. XOR plaintext’ов. GCM шифрует через CTR-режим: ciphertext = plaintext XOR keystream. Два сообщения с тем же IV дают тот же keystream. XOR двух ciphertext’ов = XOR двух plaintext’ов. Это не сами plaintext’ы, но если один из них частично известен (а JWT payload предсказуем - {"sub":", {"iss":"), второй восстанавливается.

  2. Утечка ключа аутентификации. Joux (2006, “forbidden attack”) показал: при повторном nonce можно восстановить GHASH-ключ H. С этим ключом атакующий подделывает Authentication Tag для произвольных сообщений. Integrity protection больше нет.

Как проверить: собери несколько JWE-токенов от сервера, декодируй третью часть (IV) из Base64url. Совпадение = сервер использует статический или предсказуемый IV. Это бывает, когда разработчик захардкодил nonce или использует счетчик, который сбрасывается при рестарте.

Что ещё ломается в JWE

Algorithm downgrade. Те же грабли, что с JWS (статья 4). Если сервер принимает alg из заголовка без allowlist: RSA-OAEP -> RSA1_5 открывает Bleichenbacher, -> PBES2 открывает DoS, -> dir позволяет подставить свой ключ напрямую.

Compression bomb. JWE поддерживает "zip":"DEF" - сжатие payload перед шифрованием. Атакующий создает токен, где сжатые данные разворачиваются в гигабайты (CVE-2024-33664, CVE-2024-21319). Один запрос = OOM на сервере.

JWE-JWS confusion. Что если внутри зашифрованного JWE лежит plaintext JWT с alg: "none"? CVE-2026-29000 (pac4j-jwt, CVSS 10.0) - сервер расшифровывает JWE, получает вложенный JWT и принимает его без проверки подписи. Полный auth bypass.

Header injection. Всё из статьи 6 (kid, jku, x5u, jwk) работает и для JWE. Но blast radius выше: kid-инъекция в JWE может извлечь приватный ключ расшифровки, а не просто подменить ключ верификации.

Где встречается JWE

Не так часто, как JWS, но в серьезных местах: Azure AD Token Encryption, ADFS, OpenID Connect (encrypted ID Tokens), PSD2/Open Banking (PSD2 SCA), Apple APNs. Если на пентесте видишь токен с пятью частями - не пролистывай.

Инструменты

  • jwt_tool -E - манипуляции с JWE-токенами, выбор алгоритмов
  • PadBuster - автоматизация padding oracle на AES-CBC
  • ROBOT scanner / marvin-toolkit - детекция Bleichenbacher oracle на RSA
  • jose (CLI от panva/jose) - кодирование/декодирование JWE для ручных тестов

Чеклист JWE-атак

  1. Декодируй заголовок, определи alg и enc
  2. RSA1_5 - тестируй Bleichenbacher (разные коды ошибок при модификации encrypted_key)
  3. RSA-OAEP - тестируй Manger (разные ошибки при невалидном OAEP-декодировании)
  4. ECDH-ES / ECDH-ES+AxxxKW - invalid curve attack (подставь точку с невалидной кривой, проверь ответ)
  5. A128CBC-HS256 - padding oracle (разные ошибки при модификации ciphertext)
  6. PBES2-* - подставь p2c: 2147483647 и проверь, зависает ли сервер
  7. AES-GCM - проверь nonce reuse (собери несколько JWE, сравни IV - совпадение = XOR plaintext’ов + утечка GHASH-ключа)
  8. zip:DEF - compression bomb
  9. Algorithm downgrade - подмени alg на RSA1_5/PBES2/dir, проверь принимает ли сервер
  10. Header injection - kid/jku/x5u в JWE-контексте (статья 6)

Что дальше

Дальше - рейтинг дырявости JWT-библиотек, JWT в OAuth/OIDC, инструменты, методология пентеста. Фундамент - в этих десяти статьях.