Как найти поверхность атаки незнакомых приложений с помощью Natch

8b1c1100cddfe016f65b7e330542340f.JPG

Поиск ошибок в программах дело творческое и интересное. Чаще всего мы ищем ошибки в своём коде, чтобы его починить. Кто-то может искать ошибки в чужом коде, чтобы его сломать или поучастовать в баунти-программе.

А вот где именно искать ошибки? Какие функции тестировать? Хорошо, если программа полностью ваша. Но что если вы занимаетесь тестированием, а эти программисты постоянно придумывают что-то новое? Никаких рук не напасёшься.

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

Динамический анализ — это исследование программы во время её выполнения. Valgrind, например, умеет находить ошибки переполнения буфера и утечки памяти. А что с поверхностью атаки?

Natch — инструмент для определения поверхности атаки

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

Виртуальная машина немного усложняет работу, но зато так можно анализировать системы, где обычный отладчик не справится. Одновременно запущенные программы, обменивающиеся данными, веб-сервисы, драйверы, NGFW и тому подобное. Там код на питоне может вызывать Java-код, а тот писать в сокет, к которому подключилась программа на C++. Natch отслеживает потоки данных в таких системах и отображает их в виде графов потоков данных и вызовов функций.

Иногда из Java-кода вызываются функции libc или других библиотек.

Иногда из Java-кода вызываются функции libc или других библиотек.

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

Тестируем парсер команд в статическом анализаторе

Для разминки победим статический анализ с помощью динамического. То есть найдём баги в Rizin с помощью Natch (+фаззер). Rizin — это фреймворк для статического анализа. Почти как IDA Pro, только открытый.

Rizin в визуальном режиме работы.

Rizin в визуальном режиме работы.

У Rizin есть удобный для нас командный интерфейс. Удобный в смысле фаззинга, потому что туда можно просто передать строку команд, чтобы посмотреть, будут ли они корректно обрабатываться:

$ rizin
 -- Thank you for using rizin. Have a nice night!
[0x00000000]> pa nop
90
[0x00000000]> pad 90
nop
[0x00000000]>

Чтобы найти, где обрабатываются входные данные (то есть поверхность атаки приложения), лучше всего использовать динамический анализ. Natch умеет отслеживать потоки данных только когда они поступают из файла или по сети. Хорошо, что у Rizin имеется опция -i, которая выполняет команды из переданного файла.

Сам сценарий работы с Rizin будет состоять из одной команды:

rizin -i sh.rizin -q

В файле sh.rizin находится несколько команд. Не имеет большого значения, что именно за команды там будут, так как ищем мы точку входа в парсер для них. Получив отчёты Natch о работе Rizin с файлом sh.rizin, можно приступать к их анализу.

Диаграмма потоков помеченных данных в Rizin.

Диаграмма потоков помеченных данных в Rizin.

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

Голубыми помечены функции, которые непосредственно работали со входными данными.

Голубыми помечены функции, которые непосредственно работали со входными данными.

Видим, что Natch пометил функцию core_cmd_tsrzcmd. Из исходного кода ясно, что именно она исполняет поступающие команды. Но это приватная функция, а значит придётся модифицировать код, если мы захотим её фаззить. Это неудобно, поэтому перейдём на уровень выше. Там находится функция rz_core_cmd_lines, которая служит частью интерфейса библиотеки rz_core и фактически является обёрткой для core_cmd_tsrzcmd. Для тестирования это намного проще, потому что можно подключить библиотеку, а не вытаскивать из неё функции.

Несложная обёртка для тестирования функции rz_core_cmd_lines с помощью libFuzzer

#include 

RzCore *core = NULL;

int LLVMFuzzerInitialize(int *argc, char ***argv) {
    core = rz_core_new();
    return 0;
}

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    char cmd[size + 1];
    memcpy(cmd, data, size);
    cmd[size] = '\0';

    if (!strstr(cmd, "v")) {
        rz_core_cmd_lines(core, cmd);
    }

    return 0;
}

Команду 'v' мы игнорируем, потому что она переводит Rizin в визуальный режим.

Довольно скоро фаззер подбирает ввод, при котором происходит утечка памяти: sh. Оказалось, что в обработчике этой команды не освобождается список.

Также фаззер нашёл ещё 4 случая, при которых происходили утечки памяти. Это сравнительно безобидные баги. Значительно серьёзнее те сбои, при которых происходит ошибка сегментации. Ведь иногда они приводят к возможности выполнить произвольный код.

И входную строку, роняющую Rizin, тоже удалось найти. Сбой происходит при передаче команды sG из-за того, что до её исполнения мы не загружали в Rizin никакой файл, а в обработчике происходило обращение к core→file→fd безо всякой проверки. Также фаззер обнаружил деление на ноль, к нему приводит ввод s-- 0 и s++ 0.

Итак, с помощью фаззинга мы нашли ошибку сегментации, деление на ноль и пять утечек памяти. Очень опасно быть скрипт-кидди и грузить в Rizin непроверенные файлы. Для исправления ошибок мы создали несколько пулреквестов (и их уже даже смёржили):

Вы можете сказать, что функцию-парсер легко можно было найти с помощью gdb. И это действительно так, пример же был разминочный. Дальше будет поинтереснее, когда фаззинг парсера целиком уже не даёт результата.

Приложение, работающее с одним входным файлом

01733231298120518c63b92eb0446262.png

Assimp (Open Asset Import Library) — это кроссплатформенная библиотека импорта 3D моделей, поддерживающая внушительный список форматов файлов. Assimp используется во многих проектах, таких как adobe/lagrange, panda3d/panda3d, google/filament, fougue/mayo.

В Assimp есть свой фаззер конструкторов двух основных классов API проекта: Importer и Exporter. С помощью него библиотека тестируется почти целиком. Звучит неплохо, но так очень легко пропустить отдельные баги во внутренних функциях. Ведь если на работу внутренней функции влияет один байт из всего файла, то вероятность, что изменится только он, невелика.

Сейчас мы и проверим, насколько хорошо этот проект был протестирован таким «грубым» способом. Будем искать внутренние функции, которые можно было бы фаззить отдельно. Желательно, чтобы они не требовали сложной настройки окружения, а на вход принимали простую последовательность байтов или символов.

Выловить такие функции отладчиком будет непросто, ведь обработка сколь-нибудь сложного файла будет состоять из тысяч вызовов. А часть найденных функций будет делать работу, которая практически не зависит от обрабатываемого файла. Поэтому применим Natch, чтобы он подсветил нужный код.

Сборка Assimp в виртуальной машине

$ git clone https://github.com/assimp/assimp
$ cd assimp
$ mkdir build && cd build
$ cmake .. -DASSIMP_BUILD_ASSIMP_TOOLS=ON

Опция ASSIMP_BUILD_ASSIMP_TOOLS нужна для сборки утилиты, работу которой мы будем анализировать.

Выгрузка скомпилированной программы из виртуальной машины

mkdir assimp
sudo natch_scripts/guest_system/copy_files.py lubuntu.qcow2 assimp /home/user/assimp/build

Также нам понадобятся некоторые входные данные. Анализируемая программа принимает на вход 3D-модели. Возьмём какую-нибудь отсюда.

Поверхность атаки будем искать для следующего несложного сценария. На вход программе передаётся 3D-модель в текстовом формате, а преобразованная модель записывается в файл:

$ ./assimp dump model.fbx

Часть данных из загруженной модели в неизменном виде сохраняется в файл model.assxml. А если это были бы пароли?

Часть данных из загруженной модели в неизменном виде сохраняется в файл model.assxml. А если это были бы пароли?

Natch генерирует граф вызовов функций, которые обрабатывали переданный файл. Тут же будет и Assimp::Importer, который засветился в собственном фаззере проекта. Но нас в первую очередь интересует что-то неочевидное, что нельзя найти беглым взглядом по исходному коду.

Работа приложения начинается с вызова методов класса Importer.

Работа приложения начинается с вызова методов класса Importer.

Где-то в глубинах графа вызовов нашлась одна функция, которую довольно легко тестировать:

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

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

DecodeBase64 принимает на вход указатели на буферы и их размер. И если открыть её код, то сразу видно, что прибегать к тестированию вовсе не обязательно.

Функция DecodeBase64

size_t DecodeBase64(const char* in, size_t inLength, uint8_t* out, size_t maxOutLength)
{
    if (maxOutLength == 0 || inLength < 2) {
        return 0;
    }
    const size_t realLength = inLength - size_t(in[inLength - 1] == '=') - size_t(in[inLength - 2] == '=');
    size_t dst_offset = 0;
    int val = 0, valb = -8;
    for (size_t src_offset = 0; src_offset < realLength; ++src_offset)
    {
        const uint8_t table_value = Util::DecodeBase64(in[src_offset]);
        if (table_value == 255)
        {
            return 0;
        }
        val = (val << 6) + table_value;
        valb += 6;
        if (valb >= 0)
        {
            out[dst_offset++] = static_cast((val >> valb) & 0xFF);
            valb -= 8;
            val &= 0xFFF;
        }
    }
    return dst_offset;
}

Функция декодирует строку in длины inLength и записывает результат в буфер out, причём максимальная длина результата, которая может быть записана, ограничена параметром maxOutLength.

Однако, maxOutLength (почти) не используется в теле функции, и выходит, что запись в буфер out никак не ограничена.

Убедиться в этом можно профаззив следующий код:

Обёртка для фаззинга DecodeBase64

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t dataSize) {
    FuzzedDataProvider p(data, dataSize);

    while (p.remaining_bytes() > 0) {
        std::string str = p.ConsumeRandomLengthString();
        std::vector out;
        size_t maxOutSize = p.ConsumeIntegralInRange(0, 256);
        out.resize(maxOutSize);

        FBX::Util::DecodeBase64(str.c_str(), str.length(),
                                out.data(), maxOutSize);
    }

    return 0;
}

Довольно скоро фаззер подберёт ввод, при котором произойдёт heap buffer overflow. Вот так легко мы нашли забытую функцию с ошибкой.

Конечно, выбор входных данных также влияет на найденную поверхность атаки. После того, как виртуальная машина настроена, мы могли бы протестировать Assimp и с бинарными файлами на входе. Это не потребовало бы много времени, но так как мы изучаем разные возможности Natch, то перейдём к другому приложению, которое работает с сетью. А это намного опаснее, ведь неизвестно, что пользователь захочет прислать.

Поверхность атаки для компонентов веб-сервиса

ONLYOFFICE Docs — это офисный пакет, включающий в себя онлайн редакторы текстовых документов, презентаций и электронных таблиц, а также поддерживающий совместную работу над ними. Он предлагает множество готовых вариантов для интеграции, например с Moodle, Nextcloud, ownCloud.

Графический интерфейс ONLYOFFICE Docs.

Графический интерфейс ONLYOFFICE Docs.

У проекта сложная структура — он состоит из нескольких частей: server, core, sdkjs, web-apps и dictionaries. Действия пользователя обрабатывает компонент server. При этом часть операций делает компонент core, например, занимается конвертацией загружаемых документов во внутренний формат. Код конвертации — это удобная цель для фаззинга, потому что он часто содержит ошибки вроде пропущенных проверок каких-либо ограничений.

Можно попробовать поработать с конвертером отдельно, чтобы поискать там ошибки. Но сила Natch именно в возможности отслеживать потоки данных во всей системе между приложениями, библиотеками и скриптами. Поэтому попробуем использовать ONLYOFFICE Docs целиком.

Установить этот пакет непросто. Сначала надо его собрать (в виртуальной машине, конечно), а затем интегрировать в веб-приложение. Потом нужно выгрузить бинарные файлы из каталога виртуальной машины build_tools/out/linux_64/. Отладочная информация из них понадобится Natch для вывода имён функций и номеров строк в исходном коде.

Создадим проект Natch и пробросим в виртуальную машину порты 8080 и 8000. Они нужны, чтобы мы могли использовать ONLYOFFICE Docs, работающий в виртуальной машине, через браузер на хосте. Это также позволит выключить за ненадобностью графический интерфейс гостевой ОС, что значительно ускорит работу.

Загружать в ONLYOFFICE Docs будем HTML-файл в надежде, что это затронет много разных функций внутри. А мы их заметим и протестируем.

Сначала надо запустить сервис в виртуальной машине:

cd build_tools/out/linux_64/onlyoffice/documentserver/server/FileConverter
LD_LIBRARY_PATH=$PWD/bin NODE_ENV=development-linux NODE_CONFIG_DIR=$PWD/../Common/config ./converter

cd ../DocService
NODE_ENV=development-linux NODE_CONFIG_DIR=$PWD/../Common/config ./docservice

ADDRESS=0.0.0.0 PORT=8080 DOCUMENT_SERVER_PRIVATE_URL=http://localhost:8000 EXAMPLE_URL=http://localhost:8080 DOCUMENT_SERVER_PUBLIC_URL=http://localhost:49153 make server-dev

Потом открыть интерфейс приложения в браузере хостовой машины. Через этот интерфейс загружаем наш тестовый HTML-файл. После загрузки ONLYOFFICE Docs конвертирует его в свой внутренний формат и сохраняет в файле. А мы завершаем работу эмулятора и запускаем анализ.

При желании можно поковыряться в питоновских компонентах ONLYOFFICE Docs, но сейчас этого делать не будем, а сосредоточимся на компилируемом коде.

При желании можно поковыряться в питоновских компонентах ONLYOFFICE Docs, но сейчас этого делать не будем, а сосредоточимся на компилируемом коде.

Ошибка в разборе XML файла

Парсер XML состоит из множества интересных функций.

Парсер XML состоит из множества интересных функций.

Начнём с XmlUtils::CXmlLiteReader_Private::FromFile. CXmlLiteReader_Private является реализацией класса CXmlLiteReader, так что, дабы избежать модификации кода, будем фаззить CXmlLiteReader::FromFile. В итоге, фаззер нашёл heap-buffer-overflow в следующем участке кода:

...
pos_start = 0;

while (pos_start < lLen)
{
    if (pData [pos_start] == '>')
        break;
    pos_start++;
}
pos_start++;
std::string start_utf8("");

delete []m_pStream;
m_lStreamLen = lLen + start_utf8.size() - pos_start;
m_pStream = new BYTE[m_lStreamLen];

memcpy(m_pStream, start_utf8.c_str(), start_utf8.size());
...

Тут если цикл while не находит >, то после его завершения pos_start увеличивается лишний раз. В результате m_lStreamLen становится на 1 меньше, чем нужно, выделяется слишком мало памяти, а из-за этого уже происходит переполнение вmemcpy. Лучше было бы выполнять инкремент непосредственно перед break.

Вызов strstr с буфером, не оканчивающимся нулём

0a0a63248b166f49a54bea7fc8fc621a.png

Здесь очень интересно выглядят функции COfficeFileFormatChecker::isXXXFormatFile. Все они вызываются из isOfficeFile, поэтому можно попробовать протестировать сразу её.

Фаззер нашёл несколько ошибок heap-buffer-overflow. Внутри анализируемой функции есть множество вызовов strstr с pBuffer в качестве первого аргумента. Вроде таких:

BYTE *pBuffer = NULL;
HRESULT hresult = OfficeUtils.LoadFileFromArchive(fileName, L"documentID", &pBuffer, nBufferSize);
...
if (NULL != strstr((char *)pBuffer, "fixedrepresentation"))
...

Однако функция чтения буфера не гарантирует, что там окажется терминальный ноль. Буфер целиком заполняется из файла, что и приводит к ошибке, если нуля в файле нет. Такая недостаточная проверка корректности загружаемых файлов может приводить к возможности выполнения произвольного кода пользователем.

Этот пример был самым сложным — большая система из нескольких частей. Один сервис получает данные из сети, а потом отправляет их другим сервисам. А мы пытаемся найти для всей этой системы поверхность атаки, не ознакомившись с кодом заранее. Тем не менее, Natch помогает найти подходящие функции для анализа и тестирования, чтобы обезопасить веб-сервис.

Выводы и результаты

Во время исследований проектов, поучаствовавших в примерах для статьи, мы нашли 10 ошибок (семь в rizin, одну в Assimp, две в ONLYOFFICE Docs). Они не очень серьёзные, но и ведь и мы почти не старались, а тестировали поверхностно. Основная цель-то была в проверке возможностей Natch.

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

Ссылки

  1. Руководство пользователя Natch

  2. Телеграм-канал поддержки Natch

© Habrahabr.ru