R0 CREW

Hex-Rays Microcode API против обфусцирующего компилятора

Оригинал: hexblog.com

Этот пост, написанный Ролфом Роллесом из Mobius Strip Reverse Engineering – взгляд и мнение о Hex-Rays. Любые технические или эксплуатационные вопросы, касающиеся кода в данной статье, должны быть направлены ему.

В этом посте, мы будем исследовать пример вредоносной программы, которая была скомпилирована обфурцирующим компилятором для усложнения анализа. Для начала стоить изучить методы обфускации и сформулировать стратегию разбора. Следуя описанию Hex-Rays CTREE API, мы можем понять, что недавно выпущенный microcode API является более мощным инструментом для решения поставленной задачи. Мы изучим microcode API, а затем напишем Hex-Rays плагин для автоматизации распутывания обфускации и предоставления пользователю чистой декомпиляции.

Плагин является проектом с открытым исходным кодом, периодически обновляется, написан на C++, содержит примерно 4 тысячи строк и очень хорошо закомментирован. Кроме того, нами выпущен полезный плагин (Microcode Explorer) для начинающих разработчиков микрокодовых плагинов. Данный инструмент будет распространяться с Hex-Rays SDK в последующих релизах.

Для примера, мы рассмотрим, код на ассемблере:

Hex-Rays функция декомпиляции выглядит так:

Как только мы установим плагин деобфускации, он автоматически перепишет декомпиляцию:

Первоначальное обследование файла

Образец, который мы будем изучать, был предоставлен студентом моего курса бинарного анализа на основе SMT. На первый взгляд бинарник выглядит простым. Навигационная панель IDA не сразу указывает на контрольные признаки обфускации:

Бинарник связан со стандартной средой Microsoft Visual C, указывая нам, что он был скомпилирован с помощью Visual Studio:

Также, бинарник имеет заголовок RICH, указывающий, что он был связан с Microsoft Linker:

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

Обфускация на основе шаблонов

В листинге декомпиляции, мы видим повторяющиеся шаблоны:

Подчеркнутые элементы идентичны. Немного подумав, мы можем определить, что выделенная функция всегда равняется 0 во время выполнения, потому что:

  1. x может быть четным или нечетным, а x-1 имеет противоположную четность
  2. четное число умноженное на нечетное всегда четное
  3. четным числам всегда нужно меньше бит
  4. таким образом, конъюнкция (AND (&)) на 1 дает значение 0

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

Неявные предикаты

Скриншот выше также указывает нам, что первое появление шаблона x * (x-1) & 1 находится внутри if-оператора с условным оператором &&. Учитывая, что это выражение всегда равно нулю, && не выполняется, и оператор if никогда не будет исполнен. Этот способ обфускации, известный как неявный предикат: условный переход, который на самом деле не является условным, но может оценивать только одну или другую функцию во время исполнения.

Control-Flow Flattening

Обфусканные функции выдают нетипичный управляющий поток. Каждый из них содержит оператор switch в цикле (хотя «оператор switch» скомпилирован через бинарный поиск вместо таблиц). Это свидетельствует о хорошо известном методе обфускации, называемым «control flow flattening». Вкратце, он работает следующим образом:

  1. Назначение номера каждому базовому блоку.
  2. Обфускатор вводит переменную номера блока, указывающую, какой блок должен выполняться.
  3. Каждый блок, вместо того, чтоб передавать управление последующему блоку, обновляет переменную номера блока до выбранного блока-преемника.
  4. Классический управляющий поток заменяется оператором switch связанным с переменной номера блока внутри цикла.

Следующее видео иллюстрирует процесс control-flow flattening:

http://www.hexblog.com/wp-content/uploads/2018/09/Flatten.mp4

Ниже приведена реализация control-flow flattening для небольшой функции на ассемблере.

В первой строке переменная var_1C, инициализируется некоторым случайным числом. Сразу же после этого есть несколько сравнений var_1C с другими случайными числами. (var_1C копируется в var_20, а var_20 используется для сравнения).

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

Для блоков с одним преемником обфускатор просто назначает переменной var_1C постоянное значение, как на следующем скриншоте:

Для блоков с двумя возможными преемниками (например, оператор if) обфускатор вводит x86 инструкцию CMOV для установки var_1C одного из двух возможных значений, как изложено ниже:

Графически каждая функция выглядит так:

На рисунке выше красные и оранжевые элементы представляют собой реализацию switch-as-binary-search. Синие элементы являются исходными базовыми блоками из функции (при условии дальнейшей обфускации). Фиолетовый элемент внизу - цикл, отправляющий к началу конструкции switch-as-binary-search (красный элемент).

Странные манипуляции со стеком

Наконец, мы видим, что обфускатор манипулирует необычными способами с указателями стека. В частности, он использует __alloca_probe, чтоб резервировать пространство стека для аргументов функции и локальных переменных, где обычный компилятор будет использовать команду push и резервное пространство для всех локальных переменных сразу при вводе.

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

Еще такой вопрос, откуда появился этот файл?

Я точно не знаю, как был создан этот бинарник. Obfuscator-LLVM использует обфускацию на основе шаблонов и метод control flow flattening, но Obfuscator-LLVM имеет иные шаблоны, чем выявленные в этом примере, и есть некоторые несущественные отличия выполнения метода control flow flattening. Кроме того, Obfuscator-LLVM не генерирует неявные предикаты, а также не обфусцирует команды alloca. И, разумеется, озадачивает тот факт, что бинарник включает Microsoft CRT и заголовок RICH. Если у вас есть дополнительная информация об этом файле, свяжитесь со мной.

Обновление: после обсуждений в twitter’е с разработчиком Obfuscator-LLVM и другим знающим человеком, выяснилось что, бинарник был скомпилирован и обфусцирован Obfuscator-LLVM, который интегрирован с Microsoft Visual Studio. В абзаце выше ложно указано, что Obfuscator-LLVM использует разные шаблоны и не вставлет неявные предикаты. Автор сожалеет об этих ошибках. Теоретически, плагин, который мы разрабатываем в этой записи, может работать для других бинарников, созданных одним и тем же процессом компиляции, или даже для Obfuscator-LLVM в целом, но эта теория не была протестирована и никаких гарантий не дается.

План дальнейших действий

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

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

Исследование, приведенное выше, освещало четыре метода обфускации, которые необходимо обойти:

  • Обфускация на основе шаблонов
  • Неявные предикаты
  • Манипуляции с командами alloca в стеке
  • Control flow flattening

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

Проще всего обходить манипуляции с командами alloca в стеке.

Нестандартные конструкции обфускатора не поддаются обычному анализу IDA, окружающий вызовы __alloca_probe, и, следовательно, обфускация не позволила МАР должным образом учитывать разницу стека, вызванную этими вызовами. Чтобы реализовать наш обход, мы с помощью Hex-Rays делаем большую часть работы. Для каждой функции, которая вызывает __alloca_probe, мы будем использовать API для ее декомпиляции, а затем при каждом вызове __alloca_probe мы выберем числовое значение его единственного аргумента. Наконец, мы будем использовать эту информацию для создания правильных смещений стека в листинге дизасмеблера. Код этого очень прост.

Что касается control flow flattening, это достаточно сложное преобразование, поэтому мы перейдем к нему позже.

Первый подход: Использование CTREE API

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

Хотя позже я перешел на Hex-Rays microcode API, я начинал с CTREE API, который был доступен с первого релиза Hex-Rays SDK. Он в целом проще, чем microcode API, и имеет связь с IDAPython, где в настоящее время отсутствует microcode API.

CTREE API предоставляет структуру данных декомпилированного кода, из которого генерируется предоставляемый пользователю листинг декомпиляции. Таким образом, существует прямая связь между листом декомпиляции и CTREE представлением. Например, оператор if в листинге декомпиляции соответствует структуре данных CTREE типа cif_t, которая содержит указатель на структуру данных CTREE типа cexpr_t, представляющую условное выражение оператора if, а также указатель на структуры данных CTREE типа cinsn_t, представляющая тело оператора if.

Нам нужно будет понимать, как шаблоны представлены в виде структур данных CTREE. Плагин VDS5 для Hex-Rays SDK помогает отобразить график структур данных CTREE. (Сторонний плагин HexRaysCodeXplorer реализует эту функциональность с точки зрения встроенных возможностей графического интерфейса IDA, тогда как в примере VDS5 использует WinGraph) На следующем рисунке показан вывод декомпиляции (в левом верхнем углу) и соответствующее ему представление CTREE в графической форме. Надеюсь, параллели между ними понятны.

Чтобы реализовать правила деобфускации на основе шаблонов, нам просто нужно написать функции для поиска экземпляров CTREE-функций в типах данных, связанных с обфускационными шаблонами, и заменить их CTREE версиями их деобфусированных эквивалентов. Например, чтобы соответствовать шаблону (x-1) * x & 1, который мы видели ранее, мы определяем CTREE представление и записываем оператор if, который соответствует шаблону, следующим образом:

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

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

Первая основная проблема с CTREE: Оптимизации компилятора

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

http://www.hexblog.com/wp-content/uploads/2018/09/CompOpt.mp4

Сперва это озадачило меня. Я знал, что Hex-Rays уже реализовали оптимизацию компилятора, поэтому я был смущен тем, что они не применялись в этой ситуации. Игорь Скочинский предположил, что, хотя Hex-Rays действительно реализуют оптимизацию, она происходит во время микрокодовой фазы декомпиляции и что эта оптимизация больше не происходит после получения CTREE представления. Таким образом, я должен был бы либо перенести мой плагин в мир микрокодов, либо написать оптимизацию самостоятельно на уровне CTREE. Я отложил проблему в сторону и продолжил работу с другими частями проекта.

Control Flow Unflattening через CTREE API

Затем я начал работать над частью “распутывания” метода “control flow flattening”. Я предположил, что это решается в три этапа. Мое окончательное решение не включало ни один из этих этапов, поэтому я не буду посвящать много текста ранее первичному плану. Я хочу лишь осветить оригинальную идею и проблемы, которые привели меня к итоговому результату.

  1. Начиная с реализации switch-as-binary-search, рекомпилируется актуальный оператор switch (вместо беспорядка вложенных операторов if и goto).
  2. Необходимо изучить, как каждый случай коммутации обновляет переменную номера блока, чтобы восстановить исходный граф потока управления. То есть, каждое обновление переменной номера блока соответствует ребру от одного блока до его числовой “цели”.
  3. С учетом графа управляющего потока рекомпилируется высокоуровневые структуры управляющего, такие как циклы, операторы if/else/break/continue/return и т. д.

Я начал с написание компонента на основе CTREE для восстановления операторов switch из обфусцированных функций. Основная идея, (вдохновленная реализацией языка ассемблера), заключается в том, чтобы идентифицировать переменную, которая представляет номер блока выполнения, найти сравнения этой переменной с постоянными числами и извлечь эти числа (это ярлыки case), а также адрес кода, который выполняется, если сравнение совпадает (это тела операторов case).

Это оказалось сложнее, чем я ожидал. Хотя реализация языка ассемблера имела предсказуемую структуру, Hex-Rays применил преобразования к потоку управления высокого уровня, из-за чего было сложно извлечь информацию, которую я получил после этого, как мы можем видеть на следующем рисунке.

Мы видим выше появление странного цикла while внутри switch, и окончательный оператор if инвертируется в условие a !=, а не условие a ==, что может показаться более логичным интерпретацией ассемблерного кода. Приведенный выше пример не показывает этого, но иногда Hex-Rays восстанавливает небольшие операторы switch, которые являются частями более крупного switch. Таким образом, наша логика восстановления switch должна учитывать, что эти преобразования могли иметь место.

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

Реконструкция Control Flow

Тем не менее, я поторопился. Я начал думать о том, как перестроить структуру высокоуровневого управляющего потока (if-операторы, циклы while, returns, и т. д.) из восстановленного графа управляющего потока. Мне показалось это забавной задачей, но я быстро понял, что Hex-Rays, очевидно, уже включает эту функциональность. Но могу ли я использовать существующие алгоритмы Hex-Rays?

Еще один разговор с Игорем привел к аналогичному ответу, как и прежде: для того, чтобы воспользоваться встроенными алгоритмами структурирования управляющего потока Hex-Rays, мне нужно будет работать на уровне microcode вместо уровня CTREE. На данный момент все мои проблемы, казалось, указывали на новый microcode API. Я был готов к трудностям и начал проект используя microcode API.

Знакомство с Hex-Rays Microcode API

Моим первым шагом было изучение SDK файла hexrays.hpp, который включает в себя microcode API. Здесь я кратко изложу некоторые мои выводы (дополнительная информация предоставлена здесь).
По предложению Игоря я скомпилировал плагин VDS9, включенный в SDK Hex-Rays. Этот плагин демонстрирует, как генерировать микрокод для данной функции (используя gen_microcode() API) и вывод (используя mbl_array_t::print()).

Microcode API Data Structures

Для моих целей наиболее важными вещами в процессе разбора microcode API были четыре основные структуры данных:

  • minsn_t, инструктирование microcode.
  • mop_t, операнды для инструктирования microcode.
  • mbl_array_t, содержит графы функций microcode.
  • mblock_t, базовые блоки в графах microcode, содержащие инструкции, и ребра между блоками.

По первым двум пунктам Илфак Гильфанов дал обзорную презентацию о наборе команд microcode. По вторым двум пунктам он опубликовал запись в блоге, в которой графически показано, как все эти структуры данных относятся друг к другу. Разработчикам плагинов для microcode API было бы полезно прочитать эти записи; последняя включает в себя множество хороших схем, таких как эта:

Фазы зрелости в микрокоде (Microcode Maturity)

Так как microcode оптимизируется и трансформируется, внутри Hex-Rays, он проходит через так называемую “фазу зрелости” (maturity phases) и определяется перечислением mba_mature_t. Например, сразу после генерации microcode переходит в состояние MMAT_GENERATED. После того, как локальная оптимизация выполнена, microcode переходит к следующему MMAT_LOCOPT. После выполнения анализа вызовов функций (таких как определение того, какие нажатия на стек соответствуют соответствующей функции), microcode переходит в MMAT_CALLS. При генерации microcode через gen_microcode() API пользователь может указать желаемую фазу зрелости, на которую следует оптимизировать microcode.

Плагин Microcode Explorer

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

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

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

Как только пользователь сделает выбор, плагин покажет просмотрщик IDA с выгрузкой микрокода на выбранной фазе зрелости.

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

Во-вторых, Microcode Explorer может отображать граф для выбранной микроинструкции и ее операндов, сродни плагину VDS5, который мы видели ранее, который отображает график представления функции CTREE. Просто поместите курсор на любую строку в среде просмотра и нажмите клавишу I.

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

Деобфускация паттернов с помощью Microcode API

После того, как у меня была базовый дескриптор набора инструкций microcode API, я начал с переноса моего кода поиска соответствий и замены на CTREE на microcode API. Это было более трудоемким из-за более сложной структуры microcode API, а также стоит учесть факт, что мне пришлось писать его на C ++ вместо Python. В целом процесс переноса был в основном прост. Код можно найти здесь, и вот пример соответствия шаблона и его замены.

Кроме того, мне нужно было знать, как интегрировать мою замену шаблонов с остальной инфраструктурой декомпилятора Hex-Rays. Достаточно просто написать и проверить код замены шаблона на данные, возвращенные gen_microcode() API, но это не влияет на листинг декомпиляции, который в конечном счете видит пользователь (поскольку декомпилятор вызывает gen_microcode() фоново, мы не имеем доступа к mbl_array_t, который он генерирует).

В примере VDS10 SDK показано, как интегрировать замену шаблонов в инфраструктуру Hex-Rays. В частности, SDK определяет тип данных «оптимизатор команд» (instruction optimizer), называемый optinsn_t. Виртуальная функция optinsn_t::func() получает микроинструкцию в качестве входных данных. Эта функция должна проверить предоставленную микроинструкцию и попытаться ее оптимизировать, возвращая ненулевое значение, если это возможно. После того как пользователь установит оптимизатор своих команд с помощью функции SDK install_optinsn_handler(), их пользовательский оптимизатор будет периодически вызываться ядром декомпилятора Hex-Rays, тем самым обеспечивая интеграцию, которая в конечном итоге влияет на представление пользователя о листинге декомпиляции.

Вы можете вспомнить, что основной идеей для переноса соответствия шаблонов в мир микрокодов было то, что после того, как замены были произведены, Hex-Rays получил возможность дополнительно улучшать код с помощью стандартных оптимизаций компилятора. Мы показали, что мы ожидали результата таких оптимизаций, но никаких оптимизаций не применялось, когда мы писали нашу замену шаблонов с помощью API CTREE. Перейдя в мир микрокодов, теперь мы получаем оптимизацию компилятора, которую нам бы хотелось видеть.

После установки нашего хука замены шаблонов, листинг декомпиляции для визуализации оптимизации компилятора, показанной ранее, выглядит так:

Это именно тот результат, которого мы ожидали. Отлично! В конце концов, мне не пришлось самому писать эту оптимизацию.

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

Когда мы написали наш код соответствия и код замены CTREE, мы нацелились на определенную фазу зрелости CTREE, которая приводит к предсказуемым структурам данных CTREE, реализующим шаблоны. В мире микрокодов, как более подробно описано здесь, реализация микрокода резко меняется по мере “движения по фазам зрелости”. Кроме того, наш обратный вызов оптимизатора команд вызывается на протяжении всего жизненного цикла зрелости. Некоторые из наших шаблонов еще не готовы к сравнению на более ранних фазах зрелости; мы должны будем написать наши шаблоны, ориентированные на самый низкий уровень зрелости, на котором мы сможем разумно сравнивать их.

При переносе кода замены CTREE в мир микрокодов, сначала я использовал свою старую стратегию из мира CTREE по созданию объектов замены шаблонов с нуля и вставки их в микрокод поверх заменяемых термов. Однако, я испытал много трудностей в этом. Поскольку я был новичком в microcode API, у меня не было четкой картинки о том, что внутренние объекты Hex-Rays ожидали от моих объектов микрокода, что приводило к ошибкам (внутренние ошибки и несколько сбоев). Я быстро изменил стратегию таким образом, чтобы мои замены меняли существующие объекты микроинструкций и микроопераций, а не генерировали мои собственные, что уменьшило нагрузку на создание правильных объектов minsn_t и mop_t (так эта стратегия позволила мне начать с действительных объектов).

Обзор Control Flow Unflattening

Напомню, control flow flattening исключает прямую передачу потока от блока к блоку. Процесс flattening вводит «переменную номер блока», которая определяет блок, который должен выполняться на каждом этапе выполнения функции. Структура управляющего потока каждой упорядоченной (flattened) функции была изменена на переключение по переменной номера блока, что в конечном итоге сводит управление к конкретному блоку. Каждый блок должен обновить переменную номера блока, чтобы указать блок, который должен выполнить следующий после текущего (где условный переход реализован с помощью условных команд перемещения, обновления переменной номера блока до номера блока либо принимаемого перехода, не принимаемого перехода).

Процесс unflattening управляющего потока концептуально прост. Проще говоря, наша задача состоит в том, чтобы восстановить прямое управление потоком от блока к блоку и тем самым устранить механизм переключения в управляющем потоке. Внедрение, unflattening интегрировано с ядром декомпилятора Hex-Rays подобно тому, как мы интегрировали сопоставление шаблонов. В частности, мы регистрируем объект обратного вызова optblock_t с помощью Hex-Rays, так что наш unflattener будет автоматически вызываться ядром Hex-Rays, обеспечивая полностью автоматизированный подход для пользователя. В следующей главе мы рассмотрим более подробную реализацию.

Далее мы покажем обзор процесса наглядно. Всего три шага - все, что нужно, чтобы избавится от control flow flattening. После того, как мы восстановим исходные механизмы переключения управляющего потока, все существующие механизмы Hex-Rays для реструктуризации потока управления сделают остальную часть работы за нас. Это, пожалуй, мой лучший результат в этом проекте; все, что мне нужно было сделать, это снова выставить правильное переключение в управляющем потоке, и Hex-Rays сделал все остальное за меня автоматически.

Step #1: Determine Flattened Block Number to Hex-Rays Block Number Mapping

Наша первая задача - определить, какой упорядоченный номер блока соответствует тому, что определяет Hex-Rays в mblock_t. Следующий скриншот представляет собой представление уровня микрокода для переключения управляющего потока небольшой функции:

Hex-Rays в настоящее время вызывает переменную номера блока ST14_4.4. Если эта переменная соответствует 0xCBAD6A23, команда jz блоку @2 передает управление блоку @6. Аналогично, 0x25F52EB5 соответствует блоку @9, а 0x31B8F0BC – блоку @10. Данная информация является сопоставлением между упорядоченными номерами блоков и номерами блоков Hex-Rays. (Конечно, нашему плагину нужно будет делать это автоматически.)

Step #2: Determine Each Flattened Block’s Successors

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

В строке 9.4 видно, что этот блок обновляет переменную номера блока до 0xCBAD6A23, прежде чем выполнить переход обратно к переключателю потока управления (в блоке Hex-Rays с номером @2). Из шага 1, мы знаем, что, установив переменную номера блока на это значение, следующее прохождение через переключатель потока управления будет выполнять Hex-Rays функцию mblock_t с номером @6.Второй случай - когда блок имеет два возможных преемника, как и Hex-Rays блок @6 на следующем скриншоте.

Строка 8.0 обновляет переменную номера блока со значением eax, прежде чем строка 8.1 выполнит переход обратно к переключателю потока управления в Hex-Rays блоке @2. Если взять команду jz в строке 6.4, то eax будет иметь значение 0x31B8F0BC (полученное в строке 6.1). Если команда jz не будет исполнена, то eax будет содержать значение 0x25F52EB5 из команды в строке 7.0. Принимая во внимание полученную информацию на первом шаге, этот блок передаст управление Hex-Rays блоку @10 или @9 в течение следующего перехода через переключатель управляющего потока.

Step #3: Insert Control Transfers Directly from Source Blocks to Destinations

Наконец, когда мы знаем номера mblock_t в Hex-Rays, которым должен проходить контроль каждого упорядоченного блока, мы можем модифицировать инструкции управления потоком в микрокоде, указывая непосредственно на преемников, вместо того, чтобы проходить через переключение управляющего потока. Если мы сделаем это для всех упорядоченных блоков, то переключение управляющего потока больше не будет доступно, и мы можем удалить его, оставив только исходную функцию неупорядоченного управляющего потока.

Продолжая вышеизложенный пример, в анализе второго шага мы определили, что Hex-Rays блок @9 в конечном итоге передал управление Hex-Rays блоку @6. Блок @9 отправил конструкцию goto обратно в переключатель управляющего потока, расположенный в блоке @2. Мы просто модифицируем цель существующего оператора goto, чтобы указать на блок @6 вместо блока @2, как на следующем скриншоте. (Обратите внимание, что мы удалили присвоение переменной номера блока, поскольку это больше не нужно.)

Случай, когда блок имеет два потенциальных преемника, немного сложнее, но основная идея точно такая же: изменение существующего потока управления обратно на переключение управляющего потока, чтобы напрямую указывать на целевые блоки Hex-Rays. Посмотрим на Hex-Rays блок @6, с двумя возможными преемниками.

Чтобы разупорядочить это, нам необходимо:

  1. Скопировать команды из блока @8 в конец блока @7.
  2. Изменить команду goto в блоке @7 (которая была просто скопирована из блока @8), чтобы указать на блок @9 (так как на первом этапе мы узнали, что 0x25F52EB5 соответствует блоку @9).
  3. Обновить цель goto в блоке @8, чтобы заблокировать @10 (поскольку на первом этапе мы узнали, что 0x31B8F0BC соответствует блоку @10).

Мы также можем исключить обновление для переменной номера блока в строке 8.0 и присвоения eax в строках 6.1 и 7.0.

Есть! Когда мы делаем эти изменения для каждого базового блока, на который нацеливается переключение управляющего потока, диспетчер переключателей управляющего потока теряет все входящие ссылки, и в этот момент мы можем отсечь его в графе микрокода Hex-Rays, а затем упорядоченность пропадет навсегда.

Control Flow Unflattening, In More Detail

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

Heuristically Identifying Flattened Functions

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

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

И мне пришло в голову следующее:

  1. Функции сравнивают одну переменную - переменную номера блока - против числовых констант в командах jz и jg
  2. Эти числовые константы имеют высокую энтропию, и судя по всему, они были псевдослучайно сгенерированы

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

  1. Итерировать через все микроинструкции внутри функции. Для этого SDK удобно предоставляет функцию mbl_array_t::for_all_topinsns, которая будет использоваться с классом minsn_visitor_t.
  2. Для каждой команды jz и jg, которая сравнивает переменную с номером, записывайте эту информацию в список.
  3. После итерации выберите переменную, которая была сравнена с наибольшим числом констант.
  4. Выполните проверку энтропии для констант. В частности, подсчитайте количество установленных бит, и деленное на общее количество бит. Если было установлено около 50% бит, функция была упорядочена.

Вы можете увидеть реализацию в этом коде - в частности, обратите внимание на метод JZInfo::ShouldBlacklist().

Упрощение структуры графа

У упорядоченных функций иногда есть “прыжки”, ведущие непосредственно к другим “прыжкам”, или иногда транслятор микрокода вставляет команды goto, которые нацелены на другие команды goto. Например, на следующем рисунке блок 4 содержит одну команду goto для блока 8, которая, в свою очередь, имеет команду goto для блока 15.

Это усложняет наш более поздний учет ведение регистров, поэтому я решил исключить передачу goto к goto. То есть если блок @X заканчивается командой goto @N, а блок @N содержит одну команду goto @M, обновите goto @N до goto @M. Фактически, мы применяем этот процесс рекурсивно; если в блоке @M содержится единственный goto @P, мы будем обновлять goto @N до goto @P и т. д. для любого количества цепочек.

Пример SDK с Hex-Rays VDS11 делает то, что было описано в последнем абзаце. Мой код похож, но он более общий, и поэтому немного сложнее. Он также обрабатывает случай, когда блок попадает в блок с одним goto - в этом случае он вставляет новый goto в конец ведущего блока с тем же адресом назначения, что и исходная команда goto в следящим (трейлинг) блоке.

Извлечение информации о номере блока

На первом шаге процедуры разупорядочения, описанной ранее, нам необходимо знать:

  • Какая переменная содержит номер блока
  • Какому номеру блока соответствует блок микрокода Hex-Rays

Когда эвристически определяется, была ли функция упорядочена, нашли ли мы переменную с наиболее условными сравнениями и номерами, с которыми она сравнивалась?

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

Например:

В приведенном выше примере блок @1 является вводной частью функции. Переключатель управляющего потока начинается с блока @2. Обратите внимание, что блок @1 присваивает числовое значение переменной ST18_4.4. Обратите внимание, что первое сравнение в переключателе управляющего потока в строке 2.3 сравнивается с этой переменной. Также обратите внимание, что строка 2.1 копирует эту переменную в другую переменную ST14_4.4, которая затем используется для последующих сравнений (как в строке 3.1, так и после всех сравнений переключателя потока управления).

Затем упорядоченные блоки функции обновляют переменную ST18_4:

(Странно, что упорядоченные блоки функции обновляют обе переменные - однако используется только назначение переменной обновления блока ST18_4.4. Переменная сравнения блоков ST14_4.4 переопределена в строке 2.1 выше, прежде чем используется ее значение.)

Итак, у нас есть три задачи:

  1. Определите, какая переменная является переменной сравнения блоков (которую мы уже имеем после проверки энтропии).
  2. Определите, есть ли переменная обновления блока, и если да, то какой переменной она является.
  3. Извлеките числовые константы из сравнения jz с переменной сравнения блоков, чтобы определить номер упорядоченного блока, чтобы отобразить номер mblock_t в Hex-Rays.

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

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

Unflattening

Из вышеизложенного мы теперь знаем, какая переменная является переменной обновления блока (или переменной сравнения блоков, если ее нет). Мы также знаем, какой упорядоченный номер блока соответствует номеру mblock_t Hex-Rays.

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

Как описано ранее, упорядоченные блоки бывают двух типов:

  1. Упорядоченный блок всегда устанавливает переменную обновления блока в одно значение (соответствующее безусловной переходу).
  2. Упорядоченный блок использует x86 CMOV инструкцию для установки переменной обновления блока в одно из двух возможных значений (соответствующее условному переходу).

В первом случае наша задача - просто найти одно число. Например, следующий упорядоченный блок попадает в первый случай:

В этом случае переменная обновления блока ST14_4.4. Наша задача - найти числовое назначение в строке 9.4. В сочетании упорядоченным номером блока и Hex-Rays номером mblock_t, которое мы извлекли из предыдущего шага, теперь мы можем изменить goto на последней строке на соответствующий номер mblock_t Hex-Rays.

Следующий упорядоченный блок попадает во второй случай:

Наша задача - определить, что ST14_4.4 может быть обновлен до 0xCBAD6A23 или 0x25F52EB5 в строках 6.0 и 7.0 соответственно.

Трудность: Flattened блоки могут содержать много Hex-Rays блоков

Данная часть проекта вызывала у меня ряд проблем, которые не были освещены вышеизложенными примерами.

Одной из проблем является то, что упорядоченный блок может быть реализован более чем одним Hex-Rays mblock_t, как в первом случае выше, или более чем тремя объектами mblock_t Hex-Rays во втором случае выше. В частности, Hex-Rays разделяет базовые блоки на границах вызовов функций - поэтому для одного упорядоченного блока может быть любое количество объектов Hex-Rays mblock_t. Поскольку нам нужно работать в обратном порядке от упорядоченной области, откуда мы знаем, где находится конец области? Я решил эту проблему, вычислив дерево доминатора функции и найдя блок, в котором доминирует упорядоченный заголовок блока, который возвращается обратно к переключателю управляющего потока.

Трудность: Отслеживание потока данных (Data-Flow Tracking)

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

Иногда алгоритмы константного перемещения Hex-Rays облегчают нашу жизнь, создавая микроинструкцию, которая непосредственно перемещает числовую константу в переменную обновления блока. Менее простой случай, когда назначение переменной обновления блока связано с копированием числа между несколькими регистрами или переменными стека. До тех пор, пока в стеке не будет записана некорректная память для сохранения значений clobber, достаточно просто следовать цепочке команд mov обратно к исходному постоянному значению.

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

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

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

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

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

Проблема заключается в том, что нам, ну или Hex-Rays, нужно, формально доказать, что память, записанная в середине, не перезаписывала сохраненные номера обновлений блока. В общем, упорядочивание указателей является неразрешимой проблемой, то есть невозможно написать алгоритм для решения каждого случая.

Поэтому вместо этого я схитрил. Когда мой сканер с числовым определением встречает команду, побочные эффекты которой не могут быть ограничены, я перехожу к началу области упорядоченного блока и просматриваю дальше, ища числовые присвоения последним переменным, которые я отслеживал, прежде чем сталкиваться с динамической ссылкой на память. В трех приведенных выше фрагментах, я перехожу к началу и нахожу числовые присвоения var_B4 и var_BC.

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

Приложение: Подробнее о микрокоде

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

The Microcode Verifier

Скорее всего, если вы собираетесь использовать microcode API, вы, вероятно, будете модифицировать объекты микрокода, описанные в предыдущем разделе. Это плохая идея для сторонних разработчиков плагинов, особенно тех из нас, кто является новичком в microcode API, поскольку неправильное изменение объектов микрокода может привести к сбоям или внутренним ошибкам.

Чтобы помочь разработчикам плагинов в диагностике и отладке, вызванных неверными модификациями, microcode API предлагает «проверку», доступную в API через метод mbl_array_t::verify(). (Другие объекты также поддерживают проверку, но их отдельные методы verify() в настоящее время не отображаются через API.) В основном, mbl_array_t::verify() применяет комплексный набор тестовых наборов к объектам микрокода (таким как mblock_t, minsn_t, и mop_t).

Пример проверки Hex-Rays имеет ряд предположений о типах правильных операндов для своих микрокоманд. Команда m_add должна иметь как минимум два операнда, и эти операнды должны быть одного размера. m_add может дополнительно сохранить результат в операнде «destination»; если это так, некоторые типы назначения являются неверными (например, в C, нет смысла иметь номер в левой части оператора присваивания, как в 1 = x + y ;. Аналогичная концепция в мире микрокодов, сохранение результата добавления в число, также не имеет смысла и должно быть отклонено как неверное.)

Исходный код для методов verify() включен в SDK Hex-Rays (verifier\verify.cpp). (Существует аналогичная версия для CTREE API verifier\verify.cpp) Когда проверка обнаруживает неверное условие, она вызывает «внутреннюю ошибку» в IDA, как показано на следующем скриншоте. Разработчик плагина может искать номер ошибки в исходном коде верификатора, чтобы определить источник ошибки.

Исходный код верификатора - это, на мой взгляд, лучший и самый важный источник документации о внутренних ожиданиях Hex-Rays. Он затрагивает многие различные части microcode API и содержит примеры того, как вызывать определенные функции API, которые не могут быть охвачены другими плагинами в SDK. Вникнуть в внутренние ошибки, отслеживая их в верификаторе и узнавая о ожиданиях Hex-Rays о объектах микрокода (а также о проверках), это обряд прохождения для любого потенциального разработчика программного обеспечения microcode API.

Внутреннее представление и набор инструкций микрокода

Если вы когда-либо изучали компиляторы, вы наверняка знакомы с понятием промежуточного представления. Типы данных minsn_t и mop_t, взятые вместе, являются промежуточным представлением, используемым в разделениях микрокода декомпилятора Hex-Rays.

Если вы изучили компиляторы на продвинутом уровне, вы можете быть знакомы с идеей, что компиляторы часто используют более одного промежуточного представления. Например, Muchnick описывает архетип компилятора с использованием трех промежуточных представлений, которые он соответственно называет HIR («промежуточное представление высокого уровня»), MIR («средний уровень») и LIR («низкий уровень»).

HIR напоминает язык высокого уровня, такой как C, который поддерживает вложенные выражения. То есть, в C, можно выполнить несколько операций в одном выражении, например a = ((b + c) * d) / e. С другой стороны, языки низкого уровня, такие как LIR или ассемблер, как правило, могут выполнять только одну операцию для каждого оператора; для представления одного и того же кода на низкоуровневом языке нам понадобится как минимум три оператора (ADD, MUL и DIV). LIR - это, в основном, «язык псевдо-ассемблера».

Итак, учитывая, что microcode API Hex-Rays имеет только промежуточное представление, и какой это тип - он ближе к HIR, или он ближе к LIR? Ответ заключается в том, что он использует продуманную реализацию для имитации как HIR, так и LIR! По мере “зрелости” микрокода он постепенно преобразуется из LIR-подобного представления с одной операцией для каждого оператора в HIR-подобное представление с произвольным числом операций для каждого оператора. Давайте поближе познакомимся с microcode explorer.

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

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

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

Microinstructions и Microoperands

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

Соответственно, микроинструкции и микрооператоры реализуются через классы minsn_t и mop_t. Давайте посмотрим на графическое представление для микроинструкций:

На приведенном выше рисунке в верхнем узле показана команда микрокода верхнего уровня. Он представлен инструкцией типа m_and, которая в этом случае использует три разделенных запятыми операндов типа mop_d (результат другой команды), mop_n (число) и mop_r (destination - регистр). Оператор mop_d представляет собой составную инструкцию с двумя выражениями, соединенными вместе с бинарным OR, и, таким образом, он соответствует микроинструкции типа m_or, причем сами операнды являются соответственно результатом бинарного AND и бинарных операндов XOR и, как таковые, эти операнды являются mop_d инструкциями соответственно m_and и m_xor. Ввод в операторы AND и XOR представляют собой все переменные стека, то есть микро-операнды типа mop_S.

Теперь мы видим, как mircocode API поддерживает такие бросающиеся в глаза различия в представлении микрокодов, используя те же основные структуры данных. В частности, в приведенном выше примере используется тип mop_d микро-операднов, который относится к результату другой микроинструкции. Например, микроинструкции содержат микрооператоры, а микрооператоры могут содержать микроинструкции (которые затем содержат другие микрооператоры, которые могут рекурсивно содержать другие микроинструкции и т. д.). Этот метод позволяет тем же структурам данных представлять как HIR-, так и LIR-подобные представления. Начальная фаза генерации микрокода не генерирует операнды mop_d. Последующие преобразования зрелости вводят их для построения представления более высокого уровня.

Правильное название этой методики разработки языка - это взаимная рекурсия, где одна категория синтаксиса относится к другой категории, а вторая относится к первой. Я нашел эту технику структуры очень элегантной и разумной. Помимо использования разных структур данных на каждом уровне представления, я не могу придумать какие-либо более правильные способы размещения многоуровневых представлений. Тем не менее, этот тип программирования в основном распространен только среди людей с серьезным профессиональным опытом, с глубокими теоретическими познаниями языка программирования и хорошим пониманием внутренних компонентов компилятора. Обычным разработчикам было бы полезно изучить некоторые теории языка программирования, если они хотят хорошо использовать microcode API.

© Translated by turbobarsuchiha special for r0 Crew
2 Likes