Заманка

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

Мы из вот такой огроменной (это лишь малая её часть) функции, в ней 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#5
mem#7 = 0x14002e020(rcx_1030#1066) @ mem#6
rax_2057#2268, mem#8 = 0x140081590(rcx_1031#1067) @ mem#7
rax_3125#11037, mem#60 = 0x140226560() @ mem#59
rax_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) # main
ssa: MediumLevelILFunction = func.mlil.ssa_form

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)

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 = call
			if ".synthetic_builtins" == bv.get_sections_at(call.dest.value.value)[0].name:
				continue
			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)

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 bn
import importlib
import json

def _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 bn
from binaryninja import *
import time

def 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_vars

def 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_form

	pending: List[SSAVariable] = []
	important_vars: Set[SSAVariable] = set()

	# cache: var -> reaches_good?
	reaches_cache = {}

	def add_important(var):
		if var in important_vars:
			return

		if isinstance(var, SSAVariable):
			important_vars.add(var)
			pending.append(var)
		else:
			# обычная Variable / aliased var
			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)

	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 False
		seen.add(var)

		if var in reaches_cache:
			return reaches_cache[var]

		# Уже хорошая переменная из основного слайсинга
		if var in good_vars:
			reaches_cache[var] = True
			return True

		# Переменная, которую мы уже добавили как важную из другого IF
		if var in important_vars:
			reaches_cache[var] = True
			return True

		if isinstance(var, SSAVariable):
			def_instr = ssa.get_ssa_var_definition(var)
			if def_instr is None:
				reaches_cache[var] = False
				return False

			# Если любая зависимость определения ведёт к good — var тоже good-dependent
			for parent in def_instr.vars_read:
				if reaches_good(parent, seen):
					reaches_cache[var] = True
					return True

			reaches_cache[var] = False
			return False

		else:
			# Обычная Variable: пробуем пройтись по её определениям
			for def_site in func.mlil.get_var_definitions(var):
				ssa_def = def_site.ssa_form
				if ssa_def is None:
					continue

				for parent in ssa_def.vars_read + ssa_def.vars_written:
					if reaches_good(parent, seen):
						reaches_cache[var] = True
						return True

			reaches_cache[var] = False
			return False

	def 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 = True

	while changed:
		changed = False

		for block in ssa:
			for instr in block:
				if instr.operation != MediumLevelILOperation.MLIL_IF:
					continue

				cond_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 = True
						reaches_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