Путешествие туда и обратно за безопасным ELF-парсером
Жил-был в норе под землей… разработчик группы разработки защитных решений безопасной платформы. Привет! Я Максим Жуков, занимаюсь безопасностью различных аспектов 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, или просто Мелькор.
В соответствии с заветами Толкина Мелькор извращает ELF-файлы. Делает он это с помощью случайных мутаций, того самого фазза. Получающиеся в результате искаженные файлы создатель Мелькора любовно называет орками.
В Мелькоре можно задать, какой тип данных будем фаззить, сколько орков нужно получить из исходного эльфа, с какой вероятностью применять каждое правило фаззинга.
Кроме того, в отличие от generic фаззеров, которые меняют случайные байты, Melkor понимает структуру ELF, а следовательно, делает более осознанные правки. Это позволяет исключить длительные попытки фаззера пройти все валидации.
Стандартному фаззеру нужно долго набирать корпус, чтобы проверки дошли глубже. Мелькор сразу тестит парсер на сложную логику: модифицирует готового эльфа, чтобы тот падал где-нибудь глубоко.
По ссылке — исследование многих заметных 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, если мой рассказ вас заинтриговал.