Как стать автором
Обновить

Путешествие туда и обратно за безопасным ELF-парсером

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров1.2K

Жил-был в норе под землей… разработчик группы разработки защитных решений безопасной платформы. Привет! Я Максим Жуков, занимаюсь безопасностью различных аспектов KasperskyOS. Расскажу про один из них, ELF-парсер.

Эта история не про то, как мы в «Лаборатории Касперского» сделали парсер с нуля. А про то, как я отправился в долгое исследовательское путешествие в поисках способа сделать наш существующий парсер безопаснее, что узнал о разных инструментах в пути и какую неожиданную помощь получил от Темного Владыки Мелькора.

Интересно будет тем, кто работает с бинарными данными, занимается безопасностью или просто хочет сделать свой код надежнее. Особое приглашение под кат — фанатам Rust, ему уделю немало внимания. Поехали!

Контекст задачи

Прежде чем перейти непосредственно к заявленной теме, считаю нужным рассказать немного про KasperskyOS и немного про формат ELF.

Немного про Kaspersky OS

Что это за ось такая? Это собственная разработка «Лаборатории Касперского» на основе нашего микроядра. Ни в коем случае не еще одна сборка Linux. Мы ее разработали для создания конструктивно безопасных, в том числе кибериммунных решений.

Немного про формат ELF

ELF — формат исполняемых и связываемых файлов: он определяет структуру бинарных файлов, библиотек, позволяет операционной системе корректно интерпретировать содержащиеся в файле машинные команды.

Каждый ELF состоит из четырех основных разделов:

  • ELF Header (он же Executable Header) содержит метаданные: magic number, первые четыре байта которого подсказывают системе, что это именно ELF, тип файла — библиотека это, исполняемый файл или что-то иное, платформу, на которой файл можно запускать, информацию о соседних хедерах.

  • Program Header сообщает системе, как правильно грузить сегменты ELF-файла в виртуальную память.

  • Section Header описывает логическое разделение файла на блоки, которыми будет оперировать линкер: .text, .data, .bss и так далее.

  • Наконец, раздел с секциями, содержащими данные.

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

Вопрос доверия

Итак, есть KasperskyOS, есть ELF-файлы, которые надо безопасно парсить. На момент начала этой истории в нашей оси уже существовал ELF-парсер, написанный на C. И расположенный (внезапно) на user space, то есть отделенный от (микро)ядра. Он за время разработки и доработки прошел огонь, воду и:

  • фаззинг через libFuzzer;

  • статический анализ с PVS-Studio и Svace;

  • динамический анализ с помощью Clang-стека и санитайзеров: AddressSanitizer, UndefinedBehaviorSanitizer;

  • пентесты.

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

Что нужно от безопасного парсера

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

Риски при парсинге бинарных данных

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

Во-первых, buffer overflow: парсим файл, доверяем данным в нем, переходим по смещению, оказываемся вообще в другом разделе памяти. Вот и готовый эксплойт.

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

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

Это реальные риски?

Вот лишь несколько CVE’шек, не связанных напрямую с ядром.

  • CVE-2012-1429: пропуск файлов во время антивирусного сканирования.

  • CVE-2018-6924: DoS-атака на ядро, результат — краш или доступ к памяти.

  • CVE-2013-2196: доступ к правам local administrator.

  • CVE-2023-1157: DoS-атака.

Играем в загадки

Перед тем как двинемся дальше, максимально наглядно покажу, почему внешним данным не стоит доверять, какие существуют риски. Посмотрите на этот код:

void *parse_data(const void *src, size_t size, size_t offset) {
        if (size + offset > MAX_SIZE || size < sizeof(Section))
                return NULL;
        void *buf = malloc(size + offset);
        if (!buf)
                return NULL;

        memcpy((char *)buf + offset, src, sizeof(Section));
        return buf;
}

Какие проблемы вы в нем видите?

Ответ
void *parse_data(const void *src, size_t size, size_t offset) {
        if (size + offset > MAX_SIZE || size < sizeof(Section))      // при сложении может произойти переполнение
                return NULL;
        void *buf = malloc(size + offset); // выделяется малый размер памяти
        if (!buf)
                return NULL;

        memcpy((char *)buf + offset, src, sizeof(Section)); // записываем куда хотим
        return buf;
}

Да, здесь происходит unsigned overflow. При переполнении получается маленькое число, мы выделяем на его обработку небольшой кусочек данных, забываем провалидировать и далее используем offset, и можем записать данные куда угодно. Неплохой такой write primitive.

Больше примеров Эру Илуватару примеров: конкретно с unsigned overflow связана также CWE-502. Это базовая уязвимость: десериализация недоверенных данных.

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

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

А вот целый топ уязвимостей за 2023 год, преобладают как раз связанные с небезопасной памятью:

Как защититься от угроз: правило двух

Простой свод правил, созданный Google. Есть три основных критерия:

  • Парсинг может проходить в unsafe-языке, например C или C++.

  • Код может работать с недоверенными данными.

  • Процесс парсинга может происходить без сандбокса.

Соответствовать всем трем критериям — это то же самое, что надеть Кольцо Всевластия посреди Мордора и беззаботно дожидаться назгулов. Отсюда и правило двух — не более.

Где взять безопасного переводчика с эльфийского

У меня образовался целый ряд вариантов, как сделать наш ELF-парсер максимально безопасным.

  • Заменить нашу разработку на open-source-вариант безопасного парсера.

  • Переписать весь код парсера на Rust.

  • Использовать генератор парсеров.

  • Применить формальные методы, чтобы доказывать безопасность.

  • Вооружиться методом model checking и дополнительно тестировать код.

Что мне было нужно от open-source-библиотек

Первым делом я погрузился в опенсорс. К идеалу были серьезные требования. Искал парсер с большой аудиторией: чем она больше и чем активнее проект, тем меньше багов и безопаснее код. При этом лицензия у него должна была быть не GPL, потому что у нас в KasperskyOS проприетарный код, и точно не нужны лишние проблемы с юридической стороны. Обязательны обширные тесты и покрытие: код должен доказывать, что работает и не содержит багов.

В плюс возможным кандидатам шло прохождение security-аудитов, прогоны через статический и динамический анализ.

На каждый идеал найдется свое но

Лень — двигатель прогресса: я загуглил «safe ELF-parsers» и начал изучать результаты.

rust-elf

Библиотека для парсинга ELF-файлов для Rust. Это меня сразу привлекло, хотелось поиграться с Rust в своем коде. Увы, смутило маленькое тестовое покрытие и то, что проект не развивается.

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

libelfmaster

Безопасная библиотека для ELF-парсинга/загрузки, подходит для реконструкции вредоносов в форензике, содержит обширный инструментарий для реверс-инжиниринга. Написан на C, лицензия — BSD, однако последний факт удалось выяснить только из комментов.

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

elfutils

Коллекция утилит и библиотек для чтения, создания и модификации ELF-бинарников, также находит DWARF-данные дебагов, символы, поточные состояния, трассировку стека для процессов и файлов ядра и управляет ими.

Во всем замечательный проект: написан на C, популярен, развивается. Но под лицензией GPL, так что пришлось искать дальше.

LIEF

Библиотека для инструментации исполняемых форматов (C++, Python, Rust). Фреймворк написан на C++, в нашем случае нужно было бы делать биддинги для C-кода. Плюс, из-за того, что решение работает и с другими форматами, в нем было много лишнего для нас. А ранняя версия 0.15 обещала много ломающих изменений в кодовой базе, что явно не пойдет на пользу дальнейшему сопровождению парсера.

Итоги поисков

Дальше я искать не стал, экстраполировал проблему с rust-elf на все open-source-решения. Любая зависимость — потенциальная цель для атаки на цепочку поставок. Через малоиспользуемый компонент можно поменять код и встроить эксплойт в большой проект. Зависимости нужно будет контролировать, это дополнительные задачи и время.

Кроме того, многие проекты были еще в формате Proof of Concept, а каждое масштабное изменение — новая порция работы для нас.

Знакомство с Дарующим Безопасность Фаззером Всего

Вносить хаос в музыку Айнур — это так, для души. А на самом деле я в инфобезе работаю
Вносить хаос в музыку Айнур — это так, для души. А на самом деле я в инфобезе работаю

Зато в процессе поисков я познакомился с совершенно замечательным решением. Встречайте, Melkor ELF Fuzzer, или просто Мелькор.

Так он выглядит в ASCII внутри самой программы
Так он выглядит в ASCII внутри самой программы

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

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

Кроме того, в отличие от generic фаззеров, которые меняют случайные байты, Melkor понимает структуру ELF, а следовательно, делает более осознанные правки. Это позволяет исключить длительные попытки фаззера пройти все валидации.

В интерфейсе жертва фаззинга креативно сообщает о количестве своих грядущих «падений», тоже с помощью ASCII-арта
В интерфейсе жертва фаззинга креативно сообщает о количестве своих грядущих «падений», тоже с помощью ASCII-арта

Стандартному фаззеру нужно долго набирать корпус, чтобы проверки дошли глубже. Мелькор сразу тестит парсер на сложную логику: модифицирует готового эльфа, чтобы тот падал где-нибудь глубоко.

По ссылке — исследование многих заметных open-source-парсеров Мелькором от создателя решения и подборка интересных багов, которые с ним удалось найти.

Ну а я продолжил поиски оптимального решения.

Зачем переписывать код на Rust

Потому что Rust обеспечивает безопасность памяти, Memory Safety, которая складывается из ряда безопасностей поменьше.

  • Пространственная безопасность (spatial safety): код не должен выходить за границы разрешенной ему памяти, самый банальный вариант такой памяти — буфер. В рантайме Rust при выходе за границы массива просто упадет.

  • Временная безопасность (temporal safety): объект после освобождения не может быть исполнен. Borrow Checker трекает время исполнения объектов, если ссылки на объект переживают сам объект, происходит ошибка компиляции.

  • Безопасность типов (type safety): код защищен от invasive cast. Rust запрещает неявное приведение типов.

  • Безопасность до инициализации (define initialization safety): запрет на доступ к неинициализированным данным. Rust запрещает доступ к данным, которые не были явно проинициализированы, их даже указать нельзя, нужно сперва инициализировать.

  • Поточная безопасность (thread safety): исключен доступ к одним и тем же данным одновременно из нескольких сегментов файла. С помощью системы типов и Borrow Checker’а Rust это гарантирует.

Погружение в каждый тип безопасности подробнее — в этом исследовании от Apple.

Итак, Rust — это идеал? Нууу…

Почему я не стал переписывать код на Rust

Мигрировать на новый стек дорого, долго и многозадачно. Так как сам компонент будет представлять собой библиотеку, которая внедряется в C-код, то внедрение становится заметно труднее.

Помимо того, что необходимо это как-то собрать, придется сделать биндинги C->Rust / Rust->C, после чего наладить запуск тестов в таком гибридном проекте с корректным покрытием. А также не забыть про харденинги, которые переходят через FFI-границу.

На этом слоеном торте из задач не одна и не две, а сразу три вишенки.

Биндинги с высокой вероятностью так или иначе отвалятся, и мы получим UB (Undefined Behavior). Проблема в том, что при генерации вызовов через FFI у нас нет четких гарантий, что интерфейс будет именно такой. Если у нас где-то есть объявление, что void foo(void ), а после foo будет void foo(void , char *), то вот тут и возникнет UB. А можно еще неправильно что-либо написать и сломать алиасинг.

Rust исключает только проблемы с memory safety, проблемы с logic safety останутся в программе. Ту же проблему с пересечением сегментов памяти Rust автоматом не проверит. Следовательно, ручная проверка инвариантов ELF останется на программисте.

Новый код — плацдарм для багов: в коде возрастом 5 лет и более в 3,4 раза меньше ошибок, чем в новом.

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

Будущее за генераторами парсеров?

В поисках решения я наткнулся на статью от Microsoft. У них были многочисленные уязвимости в сетевом стеке ядра. В какой-то момент это их так достало, что они сделали EverParse3D.

Эта штучка с помощью C-подобных структур и инвариантов генерирует парсер. Пример структуры вот такой:

typedef struct _OrderedPair {
    UINT32 fst;
    UINT32 snd { fst <= snd };
} OrderedPair;

В статье обещали столь желанную memory safety, а еще arithmetic safety и полную функциональную корректность, никаких UB!

Однако бежать за трендом мне не захотелось, и вот почему. Для этого нужен был новый стек на F*. Дебажить сгенерированный код больно. Вносить правки — безумные затраты по времени, особенно с учетом функциональной корректности, для которой некоторые модели нужно было бы править отдельно. Не говоря уж о возможных закладках и уязвимостях. Проблема с инвариантами ELF при этом никуда не денется: сгенерированный парсер пропустит уязвимость, если мы в одном из инвариантов допустим ошибку.

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

Формальные методы

Может, обвесить код нотацией и с помощью нее доказать, что все достаточно безопасно? Для этого есть фреймворк Frama-C. Он позволяет расставить инварианты в уже существующем коде. Тем самым мы добиваемся гарантий, поставляемых Rust и генераторами, при этом не переписывая весь код. В нотации можно указать эти самые инварианты от ожиданий наших функций. Выглядеть это может вот так:

/*@ requires n >= 0 && n < 100;
*/
int f(int n)
{
   int tmp = 100 − n;
   //@ assert tmp > 0;
   //@ assert tmp < 100;
   return tmp;
}

Но и тут минусы перевешивают плюсы. Формальные методы — сложный подход, это был бы новый для нашей команды и команды ядра инструмент. В «Лаборатории Касперского» есть отдельная команда формальных методов, однако человеку, не практикующему формальщину, вкатиться в этот подход непросто.

При этом у него есть заметные ограничения: Frama-C не работает с union’ами и динамической памятью. Для более сложных нотаций вообще требуется связка с Isabelle/HOL, а это еще один сложный инструмент.

Не говоря уж о том, что в нотации можно банально ошибиться, недоглядеть. Вот печальный кейс, авторы которого делали парсер для x.509-сертификатов. Как это всегда бывает, только в конце работы обнаружили баг в коде, из-за которого return value перезатирался. Всего объема нотаций не хватило, чтобы это своевременно выявить.

Model Checking

Последний из методов я исследовал с фокусом на опыт применения CBMC. С его помощью сотрудники Amazon валидировали свой код. CBMC работает с C17 и другими новейшими стандартами. С ним не нужно вводить лишние нотации: свойства программы проверяются с помощью unit-тестов. Проверяются memory safety, UB, user specified assert.

Вот примерчик unit-теста:

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

Есть ли подводные камни? И снова:

Оперативки он ест немало и работает долго. Но это были достаточно незначительные минусы по сравнению с другими подходами.

Что я сделал по итогам поисков

Оставил уже имеющийся ELF-парсер на C без радикальных изменений, но дополнил его проверки новыми этапами. Теперь их шесть:

  • фаззинг через libFuzzer;

  • статический анализ с PVS-Studio и Svace;

  • динамический анализ с Clang-стеком и санитайзерами;

  • пентесты;

  • продвинутый фаззинг с Melkor;

  • unit-тесты с CBMC.

Melkor использую для дополнительного тестирования и сравнения, а с помощью CBMC залезаю в потроха, нахожу неочевидные моменты, когда в функции что-то не до конца проверено.

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

Что делать вам

Значат ли итоги моих поисков, что всем компаниям и разработчикам надо строго дорабатывать старый парсер, а шаг вправо, шаг влево равен ереси? Нет, конечно. Я дал обзорный взгляд на целый ряд подходов к безопасности ELF-парсеров, у каждого свои плюсы. Теперь сформулирую ряд советов тем, кто решает подобную задачу у себя.

  • Погружайтесь в инструменты, уделяйте время исследованиям, копайте глубже. Я именно благодаря им встретился с Мелькором, который теперь стал важной частью нашей работы с ELF-парсером.

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

  • Подходите к коду планомерно и внимательно: для этого рекомендую почитать ГОСТ по разработке безопасного ПО. Шатаут моему коллеге Диме Шмойлову, принимавшему участие в создании стандарта.

Если собираетесь работать с внешними зависимостями:

  • Всегда помните об атаке на цепочку поставок. Не тяните код напрямую с Гитхаба, проверяйте, что подключаете.

  • Дополнительно тестируйте зависимости: статический и динамический анализ, фаззинг в помощь.

Если хотите применить Rust:

  • Используйте его в новых проектах, а не переписывайте старые, чтобы было поменьше багов.

  • Помните, что с ним нет гарантий функциональной корректности, ошибки логики он не закроет.

Если приглянулись формальные и полуформальные методы:

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

  • Помните, что заформалить код можно, но менять его будет нельзя, иначе рассинхрон с моделью и болезненный обратный синхрон.

Наконец, мой главный совет: не будьте, как Исильдур.

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

Ну а я пойду в очередной раз почитаю «Сильмариллион». Безопасность ELF-парсера выросла, а с ней в очередной раз возросло и мое доверие к нему. Так что пока можно спать спокойно.

А вы пока можете больше узнать о KasperskyOS, если мой рассказ вас заинтриговал.

Теги:
Хабы:
+7
Комментарии7

Публикации