Заманка
Мы из вот такой огроменной (это лишь малая её часть) функции, в ней 4185 (!) строк декомпиляции
Получим вот это
А тут всего 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 вещи:
-
В wf.insert написан core.function.generateHighLevelIL чтобы наш проход выполнился перед созданием HLIL (по официальной документации рекомендуется использовать
wf.insert_after(“core.function.generateMediumLevelIL”, но и так окей). Это значит, что наш скрипт почистит MLIL прямо перед тем, как декомпилятор начнёт строить из него финальный Си-псевдокод -
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 всё действительно хорошо
Однако если мы зайдём в функцию по адресу 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/