Этот пост в блоге необычный. Обычно я пишу посты о скрытых видах атак или интересном и сложном классе уязвимостей. На этот раз речь пойдёт о совершенно иной уязвимости. Впечатляет её простота. Её должны были заметить раньше, и я хочу выяснить, почему этого не произошло.

В 2021 году всем хорошим багам нужно цепляющее название, и у этой уязвимости появилось имя BigSig. Сначала объясню, как она нашлась, а затем попытаюсь понять, почему её так долго упускали.

Анализ

Network Security Services (NSS) — популярная кросс-платформенная криптографическая библиотека от Mozilla. Когда проверяется зашифрованная цифровая подпись ASN.1, в NSS создаётся структура VFYContext для хранения необходимых данных — открытого ключа, хеш-алгоритма и самой подписи.

struct VFYContextStr {
   SECOidTag hashAlg; /* the hash algorithm */
   SECKEYPublicKey *key;
   union {
       unsigned char buffer[1];
       unsigned char dsasig[DSA_MAX_SIGNATURE_LEN];
       unsigned char ecdsasig[2 * MAX_ECKEY_LEN];
       unsigned char rsasig[(RSA_MAX_MODULUS_BITS + 7) / 8];
   } u;
   unsigned int pkcs1RSADigestInfoLen;
   unsigned char *pkcs1RSADigestInfo;
   void *wincx;
   void *hashcx;
   const SECHashObject *hashobj;
   SECOidTag encAlg;    /* enc alg */
   PRBool hasSignature;
   SECItem *params;
};

Структура VFYContext из NSS

Сигнатура максимального размера, которую обрабатывает эта структура, равна наибольшему элементу объединения, здесь это RSA в 2048 байтов, то есть 16 384 бита. Это достаточно много, чтобы вместить сигнатуры даже невероятно больших ключей. А что, если сделать сигнатуру больше этой? Произойдёт повреждение памяти. Да, так есть. Ненадёжная сигнатура просто копируется в этот буфер фиксированного размера, перезаписывая соседние элементы произвольными данными, которые контролируются злоумышленником.

Баг прост в воспроизведении и влияет на несколько алгоритмов. Проще всего показать RSA-PSS:

# We need 16384 bits to fill the buffer, then 32 + 64 + 64 + 64 bits to overflow to hashobj,
# which contains function pointers (bigger would work too, but takes longer to generate).
$ openssl genpkey -algorithm rsa-pss -pkeyopt rsa_keygen_bits:$((16384 + 32 + 64 + 64 + 64)) -pkeyopt rsa_keygen_primes:5 -out bigsig.key
# Generate a self-signed certificate from that key
$ openssl req -x509 -new -key bigsig.key -subj "/CN=BigSig" -sha256 -out bigsig.cer
# Verify it with NSS...
$ vfychain -a bigsig.cer

Уязвимость BigSig за три простых команд

Код, который вызывает повреждение, зависит от алгоритма. Вот код для RSA-PSS. Баг заключается в том, что проверки границ просто нет вообще: sig и key — это большие двоичные объекты произвольной длины, контролируемые злоумышленником, а cx->u — это буфер фиксированного размера:

           case rsaPssKey:
               sigLen = SECKEY_SignatureLen(key);
               if (sigLen == 0) {
                   /* error set by SECKEY_SignatureLen */
                   rv = SECFailure;
                   break;
               }

               if (sig->len != sigLen) {
                   PORT_SetError(SEC_ERROR_BAD_SIGNATURE);
                   rv = SECFailure;
                   break;
               }

               PORT_Memcpy(cx->u.buffer, sig->data, sigLen);
               break;

Уязвимость вызывает ряд вопросов:

  • Связана ли она с недавним изменением кода или это регрессия, которая проявилась только сейчас? Нет, исходный код был проверен поддержкой ECC 17 октября 2003 года, но его нельзя было использовать до рефакторинга, проведённого в июне 2012 года. В 2017 году была добавили поддержка RSA-PSS, при этом допустили ту же ошибку.

  • Много ли времени требуется, чтобы сгенерировать ключ, который вызывает баг? Нет, в приведённом примере генерируются реальный ключ и сигнатура, но это может быть мусор: переполнение происходит до проверки сигнатуры. Несколько килобайтов символа А работают без проблем.

  • Требуется ли для доступа к уязвимому коду сложное состояние, с которым у техник тестирования и статических анализаторов были бы трудности при синтезе, например хеши или контрольные суммы? Нет, должен быть правильный DER, вот и всё.

  • Уязвимости трудно достичь в смысле кода? Нет, в Firefox этот путь кода не используется для сигнатур RSA-PSS, но точка входа по умолчанию для проверки сертификата в NSS — CERT_VerifyCertificate() — уязвима.

  • Она характерна исключительно для алгоритма RSA-PSS? Нет, она влияет и на сигнатуры DSA.

  • Этой уязвимостью нельзя воспользоваться или же она имеет ограниченное воздействие? Нет, может быть затёрт элемент hashobj. Этот объект содержит указатели функции, которые сразу же используются.

Это не сбой процесса: вендор всё сделал правильно. В Mozilla работают специалисты мирового класса по обеспечению безопасности. Они первыми внедрили программу вознаграждений за обнаруженные уязвимости, вкладываются в обеспечение безопасной работы с памятью, автоматизацию тестирования безопасности и покрытие кода тестами.

NSS был одним из первых проектов, включённых в oss-fuzz: по крайней мере поддерживался официально с октября 2014 года. В Mozilla сами также проводят автоматизацию тестирования безопасности NSS с помощью libFuzzer и представили собственную коллекцию методов-модификаторов, а также основу корпуса покрытия. Есть обширный тестовый комплект и ночные сборки ASAN.

Я в общем скептически отношусь к статическому анализу, но это похоже на простую недостающую проверку границ, которую должно быть легко найти. Coverity отслеживает NSS по крайней мере с декабря 2008 года и тоже, кажется, не смогла обнаружить уязвимость.

До 2015 года в Google Chrome использовали NSS и поддерживали собственную инфраструктуру тестового комплекта и автоматизации тестирования безопасности, независимую от Mozilla. Сегодня в платформах Chrome используется BoringSSL, но порт NSS ещё поддерживается.

  • Было ли в Mozilla хорошее тестовое покрытие уязвимых областей? Да.

  • Были ли в корпусе автоматизации тестирования безопасности Mozilla / Chrome / oss-fuzz соответствующие входные данные? Да.

  • Есть ли метод-модификатор, способный расширить эти ASN1_ITEM? Да.

  • Является ли это внутриобъектным переполнением или другой формой повреждения, которую ASAN было бы трудно обнаружить? Нет, это классическое переполнение буфера, которое ASAN может обнаружить легко.

Как я нашёл баг?

Экспериментировал с альтернативными методами измерения покрытия кода, чтобы узнать, можно ли их как-то использовать на практике в автоматизации тестирования безопасности. В технике тестирования, с помощью которой удалось обнаружить эту уязвимость, применялось сочетание двух подходов: покрытие стека и выделение объектов.

Покрытие стека

Самый распространённый метод измерения покрытия кода — покрытие блоков или покрытие границ, когда доступен исходный код. Интересно, всегда ли этого достаточно? Например, возьмём простую таблицу диспетчеризации с сочетанием надёжных и ненадёжных параметров, как показано в листинге:

#include <stdio.h>
#include <string.h>
#include <limits.h>
 
static char buf[128];
 
void cmd_handler_foo(int a, size_t b) { memset(buf, a, b); }
void cmd_handler_bar(int a, size_t b) { cmd_handler_foo('A', sizeof buf); }
void cmd_handler_baz(int a, size_t b) { cmd_handler_bar(a, sizeof buf); }
 
typedef void (* dispatch_t)(int, size_t);
 
dispatch_t handlers[UCHAR_MAX] = {
    cmd_handler_foo,
    cmd_handler_bar,
    cmd_handler_baz,
};
 
int main(int argc, char **argv)
{
    int cmd;
 
    while ((cmd = getchar()) != EOF) {
        if (handlers[cmd]) {
            handlers[cmd](getchar(), getchar());
        }
    }
}

Покрытие команды bar — это надмножество команды foo, поэтому ввод, содержащий foo, будет отброшен при минимизации корпуса. Есть уязвимость, недоступная через команду bar, которая может быть не обнаружена никогда. Покрытие стека корректно сохранит оба ввода1.

Чтобы решить эту проблему, я отслеживал стек вызовов во время выполнения.

Наивная реализация слишком медленная, но после многих оптимизаций я создал практичную библиотеку, достаточно быструю, чтобы интегрировать её в автоматизацию тестирования безопасности, ориентированную на покрытие. Я тестировал, как она работает с NSS и другими библиотеками.

Выделение объектов

Многие типы данных создаются из записей меньшего размера. Файлы PNG состоят из фрагментов, файлы PDF — из потоков, файлы ELF — из разделов, а сертификаты X.509 — из элементов ASN.1 TLV. Если в технике тестирования заложено представление о базовом формате, то с её помощью можно выделить эти записи и извлечь те, что приводят к обнаружению новой трассировки стека.

Техника тестирования, которую я использовал, способна выделять и извлекать интересные новые идентификаторы объекта ASN.1, последовательности SEQUENCE, целые числа INTEGER и т. д. После извлечения они могут случайным образом комбинироваться или вставляться в данные шаблона. На самом деле идея не новая, новая — реализация. Планирую в будущем сделать этот код общедоступным.

Работают ли эти подходы?

Возможно, обнаружение этого бага подтверждает мои идеи, но я не уверен, что это так. Я проводил относительно новую автоматизацию тестирования безопасности, но не вижу причин, почему этот баг не мог быть обнаружен раньше даже с помощью простейших методов тестирования.

Что в итоге?

Как обширная, настраиваемая автоматизация тестирования безопасности с впечатляющими показателями покрытия не выявила этот баг?

Что пошло не так?

1. Нет сквозного тестирования

NSS — модульная библиотека. Многоуровневый дизайн отражён в подходе автоматизации тестирования безопасности, ведь каждый компонент тестируется независимо. Например, декодер QuickDER проходит расширенное тестирование, но при использовании техники тестирования объекты просто создаются, отбрасываются и никогда не используются:

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
 char *dest[2048];
  
 for (auto tpl : templates) {
   PORTCheapArenaPool pool;
   SECItem buf = {siBuffer, const_cast<unsigned char *>(Data),
                  static_cast<unsigned int>(Size)};
   
   PORT_InitCheapArena(&pool, DER_DEFAULT_CHUNKSIZE);
   (void)SEC_QuickDERDecodeItem(&pool.arena, dest, tpl, &buf);
   PORT_DestroyCheapArena(&pool);
 }

При использовании техники тестирования QuickDER объекты просто создаются и отбрасываются. Так проверяется синтаксический анализ ASN.1, но не корректность работы с получаемыми объектами других компонентов.

С этой техникой тестирования можно было создать SECKEYPublicKey, который бы достал в уязвимый код. Но баг так и не был бы обнаружен, ведь результат никогда не использовался для проверки сигнатуры.

2. Произвольные ограничения по размеру

Для ввода автоматизированного тестирования задаётся произвольное ограничение в 10 000 байтов. В NSS такого ограничения нет: у многих структур этот размер возможно превысить. В случае с этой уязвимостью ошибки происходят на границах, поэтому ограничение следует выбирать с умом.

Приемлемым вариантом может быть 224–1 байтов, т. е. максимально возможный сертификат, предоставляемый сервером во время согласования рукопожатия TLS.

Хотя в NSS обрабатываются объекты даже большего размера, TLS задействовать нельзя, а это снижает общую степень серьёзности любых пропущенных уязвимостей.

3. Метрики и заблуждения

Все техники тестирования NSS представлены в объединённых показателях покрытия oss-fuzz, а не в их индивидуальных покрытиях. Эти данные оказались неверными, так как уязвимый код проходит расширенное автоматизированное тестирование, но с помощью техник тестирования, которые не могли генерировать соответствующие входные данные.

Почему? Потому что в техниках тестирования типа tls_server_target используются фиксированные, жёстко заданные сертификаты. При этом выполняется код, относящийся к проверке сертификата, но проводится автоматизированное тестирование лишь сообщений TLS и изменений состояния протокола.

Что всё-таки сработало?

  • Благодаря дизайну библиотеки проверки корректности сертификатов mozilla::pkix не было допущено ухудшение ситуации с этим багом. К сожалению, она не используется вне Firefox и Thunderbird.

Сложно сказать, удача это или нет. Вероятно, RSA-PSS в mozilla::pkix будет разрешён.

Рекомендации

Эта проблема свидетельствует о том, что даже в очень хорошо поддерживаемом C/C++ могут быть фатальные, простейшие ошибки.

Краткосрочные рекомендации

  • Увеличить максимальный размер объектов ASN.1, создаваемых с помощью libFuzzer, с 10 000 до 224–1 = 16 777 215 байтов.

  • При использовании техники тестирования QuickDER должны вызываться соответствующие API с любыми успешно созданными объектами, прежде чем они будут уничтожены.

  • Показатели покрытия кода oss-fuzz нужно разделить по технике тестирования, а не по проекту.

Решение

Эта уязвимость, CVE-2021-43527, закрыта в NSS 3.73.0. Если вы вендор, распространяющий NSS в своих продуктах, то вам, скорее всего, потребуется заменить библиотеку или применить патч.

Благодарности

Я бы не смог найти этот баг без помощи коллег из Chrome, Райана Слеви и Дэвида Бенджамина, которые помогли ответить на мои вопросы о кодировании ASN.1 и приняли участие в содержательном обсуждении этой темы.

Спасибо команде NSS, которая помогла проанализировать и разобраться с уязвимостью.

Сноска

[1] В этом игрушечном примере решением, если бы был доступен исходник, имело бы место комбинирование опций инструментирования потока данных sancov, но в более сложных вариантах это тоже не срабатывает.

Научиться решать проблемы безопасности и работать с С++ вы сможете на наших курсах:

Узнайте подробности акции.

Профессии и курсы

Комментарии (9)


  1. zvszvs
    30.12.2021 17:53
    +1

    Прошу прощения за глупый вопрос (к тем, кто понимает).
    Ясно, что это перевод, но вот это:

    Покрытие команды bar — это расширенный набор команды foo, поэтому ввод, содержащий foo, будет отброшен при минимизации корпуса. Есть уязвимость, недоступная через команду bar, которая может быть не обнаружена никогда.

    я не понял от слова "совсем". Да, я не тестер, но по коду выглядит как из входного файла, перенаправляемого на консоль ввода (потому что getchar и вдуг EOF, а не fgetc) берется тестовая последовательность.

    Ну и, если последовательность байтов такая "0,32,200" то что и почему это будет "отброшено"? Почему она не будет обнаружена никогда? Это проблема "Edge coverage"?
    Если есть ссылка на "почитать", буду благодарен.


    1. stranger777
      30.12.2021 18:17

      Насколько я понимаю, под минимизацией корпуса автор имеет в виду технику, когда программа тестирования сокращает количество входных данных, ориентируясь на сам код. Минимизация по определению направлена на сокращение чего-либо, поэтому программу не интересуют надмножества, расширенные наборы и т.п., она сделана так, чтобы обращать внимание на подмножества, затачивая подаваемые данные под них.

      То есть руками вы, конечно, придумаете нужную комбинацию, а вот автоматы эту штуку упустят из-за оптимизаций. Как у Кнута: «Преждевременная оптимизация — корень всех зол». Думаю, «никогда» — это преувеличение, чтобы обратить внимание на проблему. Иначе бы автор сам ничего не нашёл.


      1. fougasse
        30.12.2021 18:52
        +1

        Правильная минимизация не просто уменьшает количество различных наборов данных, а минимизирует количество, необходимое для (полного) покрытия.


      1. zvszvs
        30.12.2021 19:22

        Ну это я понимаю. Я не вижу здесь почему подмножество "bar" является расширенным для "foo". Мне, как программисту и не тестеру видится как раз наоборот. Потому и вопрос - или я что-то не понимаю, или что-то не так с методом отбора тестирующих последовательностей.

        Ваше мнение?


    1. SnakeSolid
      30.12.2021 21:35

      Как я это понимаю. Пусть функцияf1определена какf1(int8 x) = f2(x), а функцияf2f2(int16 x) -> bool. Тогда, в тестах, включающихf1,f2будет тестироваться только с парамерамиint8, не смотря на то, что одно из значений int16 может привести к багу.


      1. zvszvs
        30.12.2021 22:52

        Хорошо, так логично. Но, в том примере можно использовать (по аналогии с вашим примером) и непосредственно f2 без f1.


        1. SnakeSolid
          30.12.2021 23:49

          Это был очень упрощенный пример. В жизниf1может быть некой оберткой, выполняющей дополнительные проверки.

          Например,f1может проверять, чтохявляется сущностью классаenemy(которых не более 100 на сцену), аf2— может "убивать" переданный объект по индексу. Тогда при вызовеf2на объекте 9999 может происходить запись за пределы массива объектов.


          1. zvszvs
            31.12.2021 01:14

            Да я не про ваш пример. С ним все понятно. Я про пример в тексте, о котором мой первый комент.


  1. Wesha
    30.12.2021 20:36
    +15

    Алё, PVS-Studio, вы файрфокс проверяли? Это переполнение находили? Если нет, то почему?