Callfuscated: как разобрать виртуализацию, MBA, opaque predicates и мусорные инструкции
Задание Callfuscated объединяет сразу несколько техник усложнения анализа: запутанные переходы, виртуальную машину со стековой моделью, смешанную булеву и арифметическую логику MBA, непрозрачные предикаты и большое количество бесполезных инструкций. Разбирать такой бинарник трассировкой можно, однако статический анализ позволяет лучше понять общую конструкцию и не зависеть от единственного пути выполнения.
Первый осмотр бинарника
После открытия файла в декомпиляторе бросается в глаза необычная картина: огромное количество функций с аргументами вида `arg_*`, а переходы из них ведут не в самостоятельные процедуры, а в середину одной и той же функции `main`. Это явный признак того, что декомпилятор неверно восстановил границы функций.
На уровне ассемблера регулярно встречается последовательность:
```asm
call ...
pop r8
...
```
На первый взгляд она выглядит как обычный вызов функции, но фактически выполняет роль безусловного перехода. Инструкция `call rel32` сначала помещает адрес возврата в стек и передаёт управление по указанному адресу. Следом `pop r8` извлекает этот адрес обратно. В результате стек возвращается в исходное состояние, а значение оказывается в `r8`.
Если содержимое `r8` после этого нигде не используется, вся конструкция эквивалентна переходу, реализованному в два шага. Проверка чтений регистра подтверждает, что он действительно не влияет на дальнейшую логику.
Есть и более простой способ исправить такую обфускацию. Опкоды `call rel32` и `jmp rel32` имеют одинаковый размер и рассчитывают относительный адрес от конца инструкции. Поэтому достаточно заменить байт `E8` на `E9`. Две байтовые инструкции `pop r8` можно заменить на `nop nop`. Автоматизировать такую обработку удобно небольшим скриптом, который проходит по нужным участкам кода и восстанавливает нормальные переходы.
После удаления этого слоя декомпиляция становится заметно понятнее. Однако вместо обычной программы обнаруживается цикл с постоянной проверкой одного значения. Оно сравнивается с набором констант, а для каждой константы предусмотрен отдельный блок обработки. Это характерная схема интерпретатора байткода.
Устройство виртуальной машины
Перед нами стековая виртуальная машина. Её обработчики получают текущий опкод, извлекают необходимые значения из виртуального стека, выполняют операцию и помещают результат обратно. Некоторые хендлеры содержат вызовы вспомогательных функций, внутри которых и спрятана основная математическая логика.
Такой подход затрудняет анализ по нескольким причинам:
- реальные операции заменены виртуальными опкодами;
- состояние программы хранится не в обычных регистрах, а в виртуальном стеке;
- каждый хендлер содержит дополнительный служебный код;
- декомпилятор видит множество косвенных переходов и ложных функций;
- часть аргументов используется только для создания видимости сложных вычислений.
Чтобы восстановить программу, сначала нужно определить назначение каждого обработчика. Обычно это можно сделать по характерным шаблонам: загрузка значения, сохранение в стек, арифметическая операция, сравнение, условный переход или завершение интерпретации.
Что такое MBA и зачем она нужна
MBA, или Mixed Boolean-Arithmetic, - это техника, при которой простая операция представляется громоздким выражением, объединяющим арифметику и побитовую логику. Например, обычное сложение может быть заменено деревом из XOR, AND, OR, сдвигов и дополнительных масок.
С точки зрения результата такая конструкция может быть полностью эквивалентна выражению `a + b`, но для человека и стандартного декомпилятора она выглядит как сотни бессмысленных операций. В рассматриваемом задании результат MBA-функции помещается на вершину виртуального стека, то есть фактически является реализацией одной простой инструкции виртуальной машины.
Именно поэтому вручную разбирать подобные деревья неэффективно. Нужно сначала поднять машинный код в математическое представление, а затем применить специализированный упрощатель.
Лифтинг кода и упрощение выражений
Для первых экспериментов можно использовать `simplify()` из Triton, однако Triton не является полноценным решателем MBA. GAMBA также подходит не всегда: этот инструмент рассчитан на определённые классы выражений.
Более универсальным вариантом оказывается CoBRA. Она поддерживает широкий набор MBA-конструкций и умеет сводить запутанные выражения к компактной форме.
Проблема состоит в том, что CoBRA принимает математическое выражение, а на входе имеется машинный код. Поэтому требуется промежуточный этап - лифтинг. Здесь удобно использовать Triton: он способен эмулировать инструкции и строить AST для содержимого регистра или ячейки памяти в заданный момент выполнения.
Общий конвейер выглядит так:
1. эмулировать функцию из обработчика;
2. получить AST для регистра `rax` или нужной области памяти;
3. преобразовать дерево Triton в формат, понятный CoBRA;
4. передать выражение на упрощение;
5. сопоставить исходную функцию с компактной операцией.
Triton в этом процессе используется именно как средство построения символьного выражения, а не как конечный MBA-солвер.
При обработке всех вспомогательных функций часть выражений упрощается до коротких арифметических или побитовых операций. Одна из функций может не поддаться сокращению, но позже выясняется, что она вообще не используется в рабочем пути.
Непрозрачные предикаты
В некоторых обработчиках встречаются вызовы `rand`, результаты которых передаются в параметры математических функций. Это выглядит так, будто результат вычисления зависит от случайных данных. Однако после символьного анализа и упрощения выясняется, что соответствующие аргументы полностью исчезают из итоговой формулы.
Так проявляются opaque predicates - непрозрачные предикаты. Их задача не в изменении результата, а в создании ложной зависимости и усложнении анализа. Внешне программа использует случайные значения, но после алгебраического преобразования становится понятно, что на конечный результат они не влияют.
Это важный момент: наличие `rand`, времени, счётчиков или других переменных ещё не означает, что алгоритм действительно недетерминирован. Нужно проверить, сохраняются ли эти значения в упрощённом выражении.
Восстановление байткода
После анализа обработчиков становится известна семантика опкодов. Следующий шаг - извлечь последовательность виртуальных инструкций. Сделать это можно двумя способами:
- вручную проследить запись байткода и восстановить массив значений;
- эмулировать выполнение записи с помощью Unicorn и снять результат из памяти.
Второй вариант удобнее для автоматизации и снижает риск ошибки при длинной последовательности. Важно контролировать адреса памяти, размеры ячеек и порядок байтов, поскольку виртуальная машина может использовать собственный формат хранения.
Одновременно следует восстановить виртуальный стек: какие операции извлекают аргументы, в каком порядке они помещаются обратно и какие значения считаются знаковыми или беззнаковыми.
Девиртуализация
Теоретически можно написать плагин для декомпилятора и добавить ему поддержку архитектуры этой виртуальной машины. На практике это трудоёмкий путь: придётся описывать регистры, инструкции, правила переходов, стек и особенности представления данных.
Проще перенести семантику виртуальных опкодов в обычную программу на C. Для каждого опкода создаётся соответствующая ветка или функция, а цикл интерпретатора заменяется последовательностью обычных операций. Затем полученный файл компилируется с оптимизациями.
Оптимизирующий компилятор сам удаляет значительную часть промежуточных переменных, объединяет арифметические выражения и сворачивает константы. В результате получается программа, которую уже можно открыть в декомпиляторе без исходной виртуализации.
Практически схема выглядит так:
```c
switch (opcode) {
case OP_ADD:
push(pop() + pop());
break;
case OP_XOR:
push(pop() ^ pop());
break;
case OP_COMPARE:
push(pop() == pop());
break;
}
```
Разумеется, реальные обработчики могут быть значительно сложнее, но принцип остаётся тем же: каждая виртуальная инструкция получает прямую реализацию.
Финальная проверка
После сборки девиртуализированного файла его снова анализируют в декомпиляторе. Теперь вместо цикла интерпретатора и MBA-деревьев видна обычная логика с понятными переменными и операциями.
В условии требуется подобрать такие входные данные, чтобы результат работы программы оказался равен нулю. На этом этапе уже не требуется разбирать виртуальную машину: задача сводится к решению восстановленного арифметического выражения или системы ограничений.
Для автоматизации можно использовать SMT-солвер. Переменные объявляются символическими, восстановленная логика переводится в ограничения, после чего решатель ищет подходящие значения. Если выражение содержит операции над 32- или 64-битными словами, важно использовать соответствующую битовую ширину и учитывать переполнение.
Практические рекомендации
При анализе подобных образцов полезно разделять работу на независимые уровни:
1. сначала убрать искусственные переходы и мусорные инструкции;
2. затем восстановить структуру виртуальной машины;
3. после этого исследовать отдельные обработчики;
4. преобразовать MBA в математические выражения;
5. извлечь байткод;
6. реализовать девиртуализированную версию;
7. только в конце решать итоговую проверку.
Не стоит пытаться сразу запускать весь бинарник под трассировщиком. Трасса показывает один маршрут, но не обязательно раскрывает все опкоды и ветви. Статический анализ обработчиков позволяет построить более полную модель.
Также желательно проверять каждую гипотезу небольшими тестами: сравнивать результат оригинальной функции и упрощённой версии на случайных входах, отдельно проверять знаковость операций и фиксировать поведение при переполнении. Даже небольшая ошибка в трактовке одного опкода способна привести к неверному байткоду и полностью сломать финальное решение.
Главный вывод заключается в том, что сочетание виртуализации, MBA и непрозрачных предикатов выглядит намного страшнее, чем оказывается после поэтапного снятия обфускации. Когда мусорные переходы удалены, машинный код поднят в AST, а хендлеры описаны обычными операциями, задача превращается из исследования неизвестной ВМ в стандартный анализ программы и решение набора ограничений.
