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

от автора

Заманка

Скачать задания

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

Изначальный main

Изначальный main

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

Деобфусцированный main

Деобфусцированный main

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

Вступление

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

Ужасная куча вычислений

Ужасная куча вычислений

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

Что делать?

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

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

>>> len(current_function.call_sites)31

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

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

Вызовы функций, которые не используют тяжёлые вычисления

Вызовы функций, которые не используют тяжёлые вычисления

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

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

int64_t var1 = data_1001; // data_1001 нигде в полезном не используется# куча преобразований var1, может быть использования других переменныхdata_1004 = var1_new; // data_1004 нигде в полезном не используется

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

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

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

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

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

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

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

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

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

MLIL

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

SSA форма

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

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

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

>>> current_function.mlil.ssa_form.get_ssa_var_definition(example_var)<MediumLevelILSetVarSsa: rcx_2606#6 = &var_218>

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

// mem#7 — новая версия памяти, mem#6 — то, в какой версии это происходитrax_5206#6, mem#7 = string::new_autolen(rcx_2606#6, rdx_750#2) @ mem#6

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

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

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

>>> len(current_function.mlil.ssa_vars)26980

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

>>> def is_call(instr):... if isinstance(instr, MediumLevelILCallSsa):... return instr... return None... ... # traverse рекурсивно обходит дерево выражений. Мы передаём ему функцию-фильтр: ... # если она возвращает None, узел пропускается, а если возвращает сам объект, ... # то он попадает в итератор.... for call in current_function.mlil.ssa_form.traverse(is_call):... print(call)mem#6 = 0x1402268c0(rcx_1029#1065, 0x140) @ mem#5mem#7 = 0x14002e020(rcx_1030#1066) @ mem#6rax_2057#2268, mem#8 = 0x140081590(rcx_1031#1067) @ mem#7rax_3125#11037, mem#60 = 0x140226560() @ mem#59rax_3126#11038, mem#61 = 0x14022e3d0(rcx_1563#5349) @ mem#60...

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

>>> current_il_expr<MediumLevelILCallSsa: mem#6 = 0x1402268c0(rcx_1029#1065, 0x140) @ mem#5>>>> current_il_expr.params[<MediumLevelILVarSsa: rcx_1029#1065>, <MediumLevelILConst: 0x140>]

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

>>> example_var = current_il_expr.dest... ... func = current_function... mlil = func.mlil... ssa = mlil.ssa_form... ... pending: List[SSAVariable] = []... important_vars = set()... ... def add_important(var):... if var in important_vars:... return... important_vars.add(var)... if isinstance(var, SSAVariable):... pending.append(var)... else:... for def_site in func.mlil.get_var_definitions(var):... ssa_def = def_site.ssa_form... if ssa_def is not None:... for used_var in ssa_def.vars_read + ssa_def.vars_written:... add_important(used_var)... for use_site in func.mlil.get_var_uses(var):... ssa_use = use_site.ssa_form... if ssa_use is not None:... for used_var in ssa_use.vars_written:... add_important(used_var)... ... add_important(example_var)... ... while len(pending) > 0:... current_var = pending.pop()... def_instr = ssa.get_ssa_var_definition(current_var)... if def_instr is not None:... for used_var in def_instr.vars_read:... add_important(used_var)... ... print(important_vars){<SSAVariable: rdx_1209 version 1338>, <SSAVariable: rcx_1030 version 1066>, <var void var_168>, <SSAVariable: rcx_2604 version 2134>, <SSAVariable: rdx_1025 version 815>, <SSAVariable: rcx_1031 version 1067>, <SSAVariable: rcx_5181 version 3617>, <SSAVariable: rcx_1029 version 1065>}

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

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

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

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

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

func: Function = bv.get_function_at(0x14020ba00) # mainssa: MediumLevelILFunction = func.mlil.ssa_formdef add_important(var):if var in important_vars:returnimportant_vars.add(var)if isinstance(var, SSAVariable):pending.append(var)else:for def_site in func.mlil.get_var_definitions(var):ssa_def = def_site.ssa_formif ssa_def is not None:for used_var in ssa_def.vars_read + ssa_def.vars_written:add_important(used_var)for use_site in func.mlil.get_var_uses(var):ssa_use = use_site.ssa_formif ssa_use is not None:for used_var in ssa_use.vars_written:add_important(used_var)pending: List[SSAVariable] = []important_vars = set()for call_site in func.call_sites:for mlil in call_site.mlils:for call in mlil.ssa_form.traverse(lambda b: b if isinstance(b, MediumLevelILCallSsa) else None):call: MediumLevelILCallSsa = callif ".synthetic_builtins" == bv.get_sections_at(call.dest.value.value)[0].name:continuefor var in call.vars_read + call.vars_written:add_important(var)while len(pending) > 0:current_var = pending.pop()def_instr = ssa.get_ssa_var_definition(current_var)if def_instr is not None:for used_var in def_instr.vars_read:add_important(used_var)print(len(important_vars) / len(ssa.ssa_vars))

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

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

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

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

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

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

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

>>> current_il_expr.vars_read[<SSAVariable: rcx_2607 version 2137>, <SSAVariable: rdx_751 version 685>]>>> current_il_expr.vars_written[<SSAVariable: rax_5207 version 4557>]

Как писать Workflow

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

import binaryninja as bnimport importlibimport jsondef _trampoline(analysis_context):    import deobfuscator_logic    importlib.reload(deobfuscator_logic)    deobfuscator_logic.run(analysis_context)configuration = json.dumps({    "name": "extension.deobfuscator.slicing",    "title": "Deobfuscator DCE Slicing",    "description": "Dead code elimination via backward slicing on MLIL SSA",    "eligibility": {        "auto": {            "default": True        }    }})wf = bn.Workflow("core.function.metaAnalysis").clone("extension.deobf")wf.register_activity(bn.Activity(configuration, action=_trampoline))wf.insert("core.function.generateHighLevelIL", [    "extension.deobfuscator.slicing"])wf.register()

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

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

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

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

Пишем deobfuscator_logic.py

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

import binaryninja as bnfrom binaryninja import *import timedef get_important_vars(bv: BinaryView, func: Function) -> Set[SSAVariable]:    ssa: MediumLevelILFunction = func.mlil.ssa_form    pending: List[SSAVariable] = []    important_vars: Set[SSAVariable] = set()    def add_important(var):        if var in important_vars:            return                if isinstance(var, SSAVariable):            important_vars.add(var)            pending.append(var)        else:            for def_site in func.mlil.get_var_definitions(var):                ssa_def = def_site.ssa_form                if ssa_def is not None:                    for used_var in ssa_def.vars_read + ssa_def.vars_written:                        add_important(used_var)            for use_site in func.mlil.get_var_uses(var):                ssa_use = use_site.ssa_form                if ssa_use is not None:                    for used_var in ssa_use.vars_written:                        add_important(used_var)    for param_var in func.parameter_vars:        add_important(param_var)    for call_site in func.call_sites:        for mlil in call_site.mlils:            for call in mlil.ssa_form.traverse(                lambda b: b if isinstance(b, MediumLevelILCallSsa) else None            ):                try:                    if ".synthetic_builtins" == bv.get_sections_at(call.dest.value.value)[0].name:                        continue                except IndexError as e:                    pass                for var in call.vars_read + call.vars_written:                    add_important(var)    while len(pending) > 0:        current_var = pending.pop()        def_instr = ssa.get_ssa_var_definition(current_var)        if def_instr is not None:            for used_var in def_instr.vars_read:                add_important(used_var)    return important_varsdef run(context: bn.AnalysisContext):    func: Function = context.function    # Наш проход съедает много ресурсов, а анализ на питоне однопоточный, что будет сильно влиять на производительность    do_deobf = False    for block in func.basic_blocks:        if len(block) > 1000:            do_deobf = True            break    if not do_deobf:        return        mlil = context.mlil    ssa: MediumLevelILFunction = mlil.ssa_form    good_vars = get_important_vars(context.view, func)    # Собираем ненужные выражения    bad_expr = set()    for instr in ssa.instructions:        written = instr.vars_written + instr.vars_read        if (written and (all(v not in good_vars for v in written) and not isinstance(instr, MediumLevelILIf))):            non_ssa = instr.non_ssa_form            if non_ssa is not None:                bad_expr.add(non_ssa.expr_index)    # Стираем эти выражения    for expr in bad_expr:        mlil.replace_expr(expr, mlil.nop())    # ОБЯЗАТЕЛЬНО, ИНАЧЕ ДЕКОМПИЛЯЦИЯ НЕ ИЗМЕНИТСЯ     mlil.finalize()    mlil.generate_ssa_form()

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

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

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

Как открыть папку с плагинами

Как открыть папку с плагинами

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

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

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

… но всё ли?

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

Декомпиляция main

Декомпиляция main

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

Мистические переменные

Мистические переменные

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

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

def backprop_conds(bv: BinaryView,func: Function,good_vars: Set[SSAVariable],) -> Set[SSAVariable]:ssa: MediumLevelILFunction = func.mlil.ssa_formpending: List[SSAVariable] = []important_vars: Set[SSAVariable] = set()# cache: var -> reaches_good?reaches_cache = {}def add_important(var):if var in important_vars:returnif isinstance(var, SSAVariable):important_vars.add(var)pending.append(var)else:# обычная Variable / aliased varfor def_site in func.mlil.get_var_definitions(var):ssa_def = def_site.ssa_formif ssa_def is not None:for used_var in ssa_def.vars_read + ssa_def.vars_written:add_important(used_var)def reaches_good(var, seen=None) -> bool:"""Проверяет: если идти назад от var по def-use цепочке,можно ли дойти до уже известных good_vars или newly-important vars."""if seen is None:seen = set()if var in seen:return Falseseen.add(var)if var in reaches_cache:return reaches_cache[var]# Уже хорошая переменная из основного слайсингаif var in good_vars:reaches_cache[var] = Truereturn True# Переменная, которую мы уже добавили как важную из другого IFif var in important_vars:reaches_cache[var] = Truereturn Trueif isinstance(var, SSAVariable):def_instr = ssa.get_ssa_var_definition(var)if def_instr is None:reaches_cache[var] = Falsereturn False# Если любая зависимость определения ведёт к good — var тоже good-dependentfor parent in def_instr.vars_read:if reaches_good(parent, seen):reaches_cache[var] = Truereturn Truereaches_cache[var] = Falsereturn Falseelse:# Обычная Variable: пробуем пройтись по её определениямfor def_site in func.mlil.get_var_definitions(var):ssa_def = def_site.ssa_formif ssa_def is None:continuefor parent in ssa_def.vars_read + ssa_def.vars_written:if reaches_good(parent, seen):reaches_cache[var] = Truereturn Truereaches_cache[var] = Falsereturn Falsedef drain_pending():"""Обычный backward slicing от уже добавленных important_vars."""while pending:current_var = pending.pop()def_instr = ssa.get_ssa_var_definition(current_var)if def_instr is not None:for used_var in def_instr.vars_read:add_important(used_var)changed = Truewhile changed:changed = Falsefor block in ssa:for instr in block:if instr.operation != MediumLevelILOperation.MLIL_IF:continuecond_vars = list(instr.vars_read)if not cond_vars:continue# Если хотя бы одна переменная условия назад доходит до good_vars,# считаем IF настоящим и сохраняем всю цепочку условия.if any(reaches_good(v) for v in cond_vars):before = len(important_vars)for v in cond_vars:add_important(v)drain_pending()if len(important_vars) != before:changed = Truereaches_cache.clear()return important_vars

И добавим в run:

good_vars = get_important_vars(context.view, func)ifs = backprop_conds(context.view, func, good_vars)good_vars.update(ifs)

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

Рабочий деобфускатор

Рабочий деобфускатор

Итоги

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1050502/