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

image

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

Зайдем издалека
По оценкам антивирусной компании McAfee, общемировой ущерб от киберпреступлений в 2017 году составил около $600 млрд, что эквивалентно 0.8% мирового ВВП. Мы живем в век информационных технологий, спецификой которого стала стремительная интеграция всемирной сети и интернет-технологий во все сферы деятельности человека. Сейчас киберпреступления уже не являются чем-то из ряда вон выходящим. Данные статистики демонстрируют рост киберпреступности в геометрической прогрессии.

Уязвимость приложений превратилась в серьезную проблему: по данным Министерства внутренней безопасности США, более 90% успешных кибератак реализуется с использованием различных брешей в приложениях. Самыми известными способами эксплуатации уязвимостей являются:

  • внедрение SQL-кода,
  • переполнение буфера,
  • межсайтовый скриптинг,
  • использование небезопасной конфигурации.

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

  • статический анализ кода (Static Application Security Testing);
  • динамический анализ кода (Dynamic Application Security Testing).


Есть еще IAST (интерактивный анализ), однако он по сути является динамическим (в процессе анализа дополнительный агент наблюдает, что происходит во время выполнения приложения). RASP (самозащита приложений Runtime), который также иногда упоминается в ряду средств анализа, является скорее средством защиты.

Динамический анализ (метод «Черного ящика») представляет собой проверку программы во время ее выполнения. У этого подхода можно выделить следующие преимущества.

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


Но есть и недостатки.

  1. Неполное покрытие кода, в связи с чем существуют риски пропущенных уязвимостей. Например, динамическим анализом не получится найти уязвимости, связанные с использование слабой криптографии или закладки типа «временная бомба».
  2. Необходимость запуска приложения, что в некоторых случаях может быть непросто. Для запуска приложения может потребоваться сложная конфигурация и настройка различных интеграций. Более того, чтобы результаты были максимально верными, необходимо воспроизвести «боевую среду», но полностью это реализовать, не навредив при этом ПО, сложно.


Статический анализ (метод «Белого ящика») представляет собой тип тестирования программы, при котором не происходит выполнение программы.
Перечислим преимущества.

  1. Полное покрытие кода, что приводит к поиску большего количества уязвимостей.
  2. Отсутствие зависимости от среды, в которой будет производиться выполнение программы.
  3. Возможность внедрения тестирования на начальных этапах написания кода модуля или программы при отсутствии исполняемых файлов. Это позволяет уже на старте разработки гибко интегрировать подобное решение в SDLC (Software Development Life Cycle ꟷ жизненный цикл разработки ПО).


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

Как мы видим, у обоих методов анализа есть как преимущества, так и недостатки. Однако возможно ли каким-либо образом использовать плюсы этих методов, минимизируя при этом недостатки? Да, если применить бинарный анализ — поиск уязвимостей в исполняемых файлах методом статического анализа.

Бинарный анализ или технология анализа исполняемых файлов


image

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

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

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

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


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

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

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

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

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


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

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

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

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

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

image

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

  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-конструкций.

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

Что дальше


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

image

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

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

Выводы


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

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

© Habrahabr.ru