«И невозможное возможно»: превращаем черный ящик в белый с помощью бинарного анализа

«И невозможное возможно»: превращаем черный ящик в белый с помощью бинарного анализа


На данный момент существует два основных подхода к поиску уязвимостей в приложениях — статический и динамический анализ. У обоих подходов есть свои плюсы и минусы. Рынок приходит к тому, что использовать надо оба подхода — они решают немного разные задачи с разным результатом. Однако в некоторых случаях применение статического анализа ограничено — например, когда нет исходного кода. В этой статье мы расскажем про довольно редкую, но очень полезную технологию, которая позволяет совместить плюсы статического и динамического подходов — статический анализ исполняемого кода.
в предыдущей статье. В отличие от анализа исходного кода, в котором при построении модели используются элементы теории компиляции (лексический, синтаксический анализ), в бинарном анализе для построения модели используется теория обратной трансляции – дизассемблирование, декомпиляция, деобфускация.

Немного про термины


Мы говорим про анализ исполняемых файлов, в которых нет информации отладки (debug info). С debug info задача серьезно упрощается, но если есть debug info, то и исходный код, скорее всего, тоже есть, и задача становится неактуальной.

В этой статье мы называем анализ байткода Java также бинарным анализом, хотя это не совсем корректно. Делаем это для упрощения текста. Безусловно, задача анализа байткода JVM проще анализа бинарного кода C/C++ и Objective-C/Swift. Но общая схема анализа схожа в случае байткода и бинарного кода. Основные сложности, описанные в статье, относятся именно к анализу бинарного кода.

Декомпиляция – это процесс восстановления исходного кода из бинарного кода. Можно говорить про элементы обратной трансляции — дизассемблирование (получение ассемблерного кода из двоичного образа), трансляция ассемблера в трехадресный код или другое представление, восстановление конструкций уровня исходного кода.

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

Как смотреть результаты?


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

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

В процессе декомпиляции, даже если мы декомпилируем байткод JVM, часть информации восстанавливается некорректно, поэтому сам анализ происходит на представлении, близком к бинарному коду. Соответственно, встает вопрос: как, находя уязвимости в бинарном коде, локализовать их в исходнике? Решение задачи для байткода JVM мы описывали в статье о поиске уязвимостей в байткоде Java. Решение для бинарного кода аналогичное, то есть вопрос технический.

Повторим важную оговорку — мы говорим про анализ бинарного кода без debug info. В случае присутствия debug info задача существенно упрощается.

Основной вопрос, который нам задают про отображение результатов – достаточно ли декомпилированного кода для того, чтобы понять и локализовать уязвимость?



Ниже приведем несколько мыслей на этот счет.

  1. Если мы говорим про байткод JVM, то в общем случае ответ «да» – качество декомпиляции для байткода велико. Практически всегда можно разобраться, в чем уязвимость.
  2. Что может помешать качественной локализации уязвимости, так это простая обфускация типа переименования имен классов и функций. Однако, на практике часто оказывается, что важнее понять уязвимость, чем определить, в каком она файле. Локализация нужна тогда, когда кто-то может исправить уязвимость, но в таком случае разработчик и по декомпилированному коду поймет, где уязвимость.
  3. Когда мы говорим про анализ бинарного кода (например, C++), конечно, все гораздо сложнее. Не существует инструмента, который полностью восстанавливает случайный C++ код. Однако особенность нашего случая в том, что нам не нужно потом компилировать код: нам нужно качество, достаточное для понимания уязвимости.
  4. Чаще всего можно добиться качества декомпиляции, достаточного для понимания найденной уязвимости. Для этого нужно решать немало сложных задач, но решить их можно (ниже кратко про это расскажем).
  5. Для C/C++ еще сложнее с локализацией уязвимости — названия символов во многом утрачиваются в процессе компиляции, восстановить их нельзя.
  6. Чуть лучше ситуация в Objective-C – там имена функций есть, и локализовать уязвимость проще.
  7. Особняком стоят вопросы обфускации. Есть ряд сложных преобразований, которые могут усложнить декомпиляцию и отображение уязвимостей. На практике оказывается, что хороший декомпилятор справляется с большей частью запутывающих преобразований (помним, что нам нужно качество кода, достаточного для понимания уязвимости).


В качестве вывода — чаще всего получается показать уязвимость так, чтобы ее можно было понять и верифицировать.

Сложности и детали бинарного анализа


Тут уже не будем говорить про байткод: все интересное про него уже сказали выше. Самое интересное – это анализ настоящего бинарного кода. Здесь мы будем говорить про анализ C/C++, Objective-C и Swift как пример.

Существенные сложности возникают уже при дизассемблировании. Важнейший этап – разделение бинарного образа на подпрограммы. Далее выделить в подпрограммах инструкции ассемблера – дело техники. Подробно мы писали про это в статье для журнала «Вопросы кибербезопасности №1(14) – 2016» , здесь опишем кратко.

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

Наиболее распространены два метода решения задачи определения стартовых адресов подпрограмм. В первом методе адреса подпрограмм определяются по стандартному прологу (для архитектуры x86 – это push ebp; mov ebp, esp). Во втором методе происходит рекурсивный обход секции кода от точки входа с распознаванием инструкций вызова подпрограмм. Обход осуществляется за счет распознавания инструкций перехода. Также применяют комбинации описанных методов, когда рекурсивный обход запускается из найденных по прологу стартовых адресов.

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

Базовые алгоритмы можно улучшить следующими эвристиками.

  1. На большой тестовой базе образов найти более точный список прологов (новые прологи или вариации стандартных).
  2. Можно автоматически находить таблицы виртуальных функций, и из них забирать стартовые адреса подпрограмм.
  3. Стартовые адреса подпрограмм и некоторые другие конструкции можно находить, исходя из участков бинарного кода, связанного с механизмом обработки исключений.
  4. Верифицировать стартовые адреса можно с помощью поиска этих адресов в образе и с помощью распознавания инструкций вызова.
  5. Для поиска границ можно делать рекурсивный обход подпрограммы с распознаванием инструкций от стартового адреса. Есть сложность с косвенными переходами и no-return функциями. Помочь может анализ таблицы импортов и рапознавание switch-конструкций.


Еще одна важная штука, которую нужно делать при обратной трансляции, чтобы потом нормально искать уязвимость, – это распознавать стандартные функции в бинарном образе. Стандартные функции могут быть статически линкованы в образ, а могут быть даже заинлайнены. Основной алгоритм распознавания – это поиск по сигнатурам с вариациями, для решения можно предложить адаптированный алгоритм Ахо-Корасик. Чтобы собрать сигнатуры, нужно заранее проанализировать образы библиотек, собранные с разными условиями, и выделить их них неизменяющиеся байты.

Что дальше


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



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

  1. Преобразование ассемблерного кода в промежуточное представление, на котором можно проводить анализ. Можно использовать различные байткоды. Для C-языков хорошим выбором кажется LLVM. LLVM активно поддерживается и разрабатывается сообществом, инфраструктура, в том числе полезная для статического анализа, на данный момент внушительно развита. На этом этапе есть огромное количество деталей, на которые нужно обратить внимание. Например, нужно детектировать, какие переменные адресуются в стеке, чтобы не множить сущности в полученном представлении. Нужно настроить оптимальное отображение наборов инструкций ассемблера в инструкции байткода.
  2. Восстановление конструкций высокого уровня (например, циклов, ветвлений). Чем точнее получится восстановить исходные конструкции из ассемблерного кода, тем лучше будет качество анализа. Восстановление таких конструкций происходит с помощью применения элементов теории графов на CFG (графе потока управления) и некоторых других графических представлений программы.
  3. Проведение алгоритмов статического анализа. Тут есть подробности. В целом, не очень важно, получили мы внутреннее представление из исходника или из бинарника — мы все также должны построить CFG, применить алгоритмы анализа потока данных и другие алгоритмы, свойственные статике. Есть некоторые особенности при анализе представления, полученного из бинарника, но они скорее технические.


Выводы


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

Статья подготовлена в соавторстве с Антоном Прокофьевым, аналитиком Solar appScreener
Alt text