Вырезаем 99% мусорного кода с помощью SSA в Binary Ninja (Flare-On 12)

Вырезаем 99% мусорного кода с помощью SSA в Binary Ninja (Flare-On 12)

Мы из вот такой огроменной (это лишь малая её часть) функции, в ней 4185 (!) строк декомпиляции

Получим вот это

А тут всего 35 строк

Проходя недавний Flare-On 12, я открыл 7 задание, нашел main и увидел следующую картину:

И ещё тысячи строк подобного бреда, а логики не видно. Так что весь этот мусор нужно как-то убрать.

Что делать?

Если посмотреть на граф потока управления, то видно, что он довольно простой (я его показывать не буду, так как он настолько длинный, что блоки там просто не видны), так что это не часть control flow flattening.

Можно посмотреть все вызовы функций через

(Я использую Binary Ninja, этот и последующие скрипты будут для него)

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

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

А ответ в том, что всё-таки эти вычисления влияют на глобальные переменные, но только чтоб это понять нужно полистать декомпиляцию. В мусорных кусках происходит примерно это:

Так что по-сути все эти вычисления могут влиять на поведение программы (запись в глобальные переменные), но вот только эти переменные скорее всего нужны только для запутывания кода

Однако я этот вывод сделал лишь на одном куске main, в других функциях и других кусках может быть эти вычисления всё-таки нужны.

Подготавливаемся проверять гипотезу

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

Перерыв на теорию

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

Предоставляет переменные, а не регистры

Предоставляет переменные, а не регистры

Позволяет сказать как конкретное состояние переменной получилось.

Позволяет сказать как конкретное состояние переменной получилось.

Для этого прекрасно подходит MLIL из Binary Ninja в SSA форме! Давайте объясню что это

В Binary Ninja есть промежуточные представления, которые предназначены для лифтинга машинного кода в псевдо Си. Одно из них — Medium Level Intermediate Language. Оно как раз абстрагирует регистры и стек, оставляя только переменные, также у вызовов функций появляются аргументы. Первое требование выполнено, что со вторым?

SSA форма

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

Суть этой формы заключается в том, что при каждом изменении переменной создаётся новая версия. Соответственно у каждой версии переменной может быть только одно определение (то что как раз нам нужно) и любое количество использований. Вот пример того, как эта форма выглядит:

А для получения использований и определения есть API, крутяк!

Также у Binary Ninja в SSA форме при вызовах функций или взаимодействием с глобальной памятью создаются версии памяти. Вот как это выглядит:

Проверяем гипотезу

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

Найти количество всех переменных:

Найти все вызовы:

Найти аргументы функции:

Найти весь путь от использования (в нашем случае — использование в качестве аргумента функции) переменной до её первого определения:

Кстати, тут важно сделать небольшую ремарку. Хоть мы и работаем с SSA-формой, внутри неё всё равно могут попадаться обычные (не SSA) переменные. Обычно это происходит, когда в коде берется адрес переменной ( ptr = &var ).

Как только декомпилятор видит взятие адреса, он понимает, что теперь переменная может быть изменена по указателю из любого места. Отследить её строгие версии, как требует SSA, становится невозможно, поэтому Binary Ninja помечает её как aliased и оставляет обычной переменной.

Из-за этого мы не можем просто пойти назад по графу. Поэтому к таким переменным я применяю консервативный подход: я анализирую не только её определения, но и все использования, читая результаты таких выражений.

Собираем проверку гипотезы

Чтобы выполнять такие большие скрипты прямо из VSCode нужно поставить плагин Binja-RPyC, так их гораздо удобнее писать.

Тут мы считаем количество важных переменных (те, которые используются в параметрах функций) и находим их соотношение к общему количеству SSA переменных. Мы должны получить 1 если используются все переменные, иначе должны получить число меньше 1. А мы получаем аж 0.002446256486286138 (это 0.24%), то есть 99.76% переменных (и примерно кода) просто нам не нужны и мы это доказали!

Пишем деобфускатор

Теперь имея реальные переменные мы можем вырезать те выражения, которые не используют эти переменные.

Как переписывать декомпиляцию

Binary Ninja позволяет при помощи системы Workflow менять любое промежуточное представление (на данный момент кроме HLIL).

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

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

Как писать Workflow

Чтобы Binary Ninja начала использовать наш алгоритм, его нужно зарегистрировать как кастомный этап анализа

Здесь куча бойлерплейта. Думаю стоит здесь выделить 2 вещи:

В wf.insert написан core.function.generateHighLevelIL чтобы наш проход выполнился перед созданием HLIL (по официальной документации рекомендуется использовать wf.insert_after(“core.function.generateMediumLevelIL” , но и так окей). Это значит, что наш скрипт почистит MLIL прямо перед тем, как декомпилятор начнёт строить из него финальный Си-псевдокод

В wf.insert написан core.function.generateHighLevelIL чтобы наш проход выполнился перед созданием HLIL (по официальной документации рекомендуется использовать wf.insert_after(“core.function.generateMediumLevelIL” , но и так окей). Это значит, что наш скрипт почистит MLIL прямо перед тем, как декомпилятор начнёт строить из него финальный Си-псевдокод

Workflow в Binary Ninja после загрузки нельзя изменить. По умолчанию, если бы я писал основной код прямо в функции-колбэке, мне приходилось бы перезапускать декомпилятор, а это неудобно. Используя трюк с importlib.reload, я вынес всю логику в отдельный файл deobfuscator_logic.py. Теперь я могу редактировать код деобфускатора, нажимать в Binary Ninja кнопку Reanalyze, и она выполнит мой новый код. Это мне сократило кучу времени.

Workflow в Binary Ninja после загрузки нельзя изменить. По умолчанию, если бы я писал основной код прямо в функции-колбэке, мне приходилось бы перезапускать декомпилятор, а это неудобно.

Используя трюк с importlib.reload, я вынес всю логику в отдельный файл deobfuscator_logic.py. Теперь я могу редактировать код деобфускатора, нажимать в Binary Ninja кнопку Reanalyze, и она выполнит мой новый код. Это мне сократило кучу времени.

Пишем deobfuscator_logic.py

Повторю наш план: мы хотим вырезать те выражения, которые не используют помеченные нами переменные.

get_important_vars — по-сути копия кода из проверки гипотезы. Единственный новый код — это run .

Ставим деобфускатор

Теперь у нас есть 2 файла — загрузчик (тот, что с _trampoline ) и сам анализатор ( deobfuscator_logic.py ), кидаем их в папку плагинов, её можно открыть вот так

Кидаем туда 2 наших файла и перезапускаем Binary Ninja.

После перезапуска загружаем файл и лезем в настройки (CTRL + ,), ищем function workflow и ставим extension.deobf вместо core.function.metaAnalysis

И всё, у нас всё работает!

... но всё ли?

В main всё действительно хорошо

Однако если мы зайдём в функцию по адресу 140081590 , то увидим это

У нас нет определений переменных в условиях!

Чтобы это исправить, добавим ещё один проход. Он будет анализировать аргументы ветвлений. Если эти аргументы как-то зависят от уже известной нам «полезной» переменной, то мы помечаем весь этот путь вычислений как важный, чтобы декомпилятор его не удалил. Код этого прохода:

И добавим в run:

Всё, работает!

Вот так, объединив SSA-форму и крутое API Binary Ninja, мы сократили функцию с >4000 мусорных строк до 35 строк чистой логики. Дальше реверсить этот таск стало делом техники.

Полный исходный код скрипта я выложил на Gist .

Подписывайтесь на телегу. Туда кидаю миништуки, всякие свои скрипты и инструменты: https://t.me/vector35_fan_club

← Cybersecurity