Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Это рассказ о том, как я сделал декомпилятор вроде Hex-Rays для экзотического языка программирования, используемого для скриптов игры «Мор (Утопия)». Ты узнаешь, как работает кросс‑компиляция, и получишь необходимый теоретический минимум из теории компиляции, который поможет тебе сделать так же. info
Полный код моих скриптов и результат работы декомпилятора ищи на GitHub.
Мы будем работать с ассемблерным кодом, полученным в статье «Мод (Утопия)». Он представляет собой набор из 89 опкодов. Фактически у нас есть только набор команд и заголовки файла.
Декомпиляция на язык более высокого уровня на самом деле является кросс‑компиляцией. То есть мы пишем компилятор, который принимает на вход исходный код на одном языке и превращает его в код на другом. Для этого мы создадим граф исполнения оригинального кода и напишем простенький эмулятор для стековой машины. Но обо всем по порядку. В написании декомпилятора мне сильно помог опыт работы с Angr, о котором я тоже уже писал статью. Фактически я повторил типовые решения, используемые в компиляторах и анализаторах кода, и это сработало! Спасибо теории компиляции.
Первый шаг анализа любого кода — разбить его на базовые блоки, то есть набор инструкций, который обрывается командой передачи управления. Затем из базовых блоков можно построить граф, отразив связи: кто и кому передает управление.
Знакомый многим граф в IDA Pro
Из картинки видно, что есть блоки, которые кончаются командами, не передающими управление. Это связано с тем, что следующий за ними участок кода используется как адреса перехода. То есть другой код (обычно цикл) передает управление в середину базового блока. В таком случае блок делится на два так, чтобы второй начинался с адреса, на который могут передать управление.
Поэтому первым делом я прохожу по всему коду и запоминаю адреса переходов. Будь это команда CALL
или JMP
— записываем, куда они передают управление. После этого можно делить код на базовые блоки. Проходим от первой инструкции до последней, разделяя их по известным адресам перехода. Для этого я ввел специальные поля в классе самой инструкции: метаданные, хранящие адреса и тип перехода — условный или безусловный.
Разделив код на базовые блоки (создав массив объектов BasicNode
), можно приступать к их связыванию, то есть установке ссылок — на какой блок передает управление исследуемый. Каждый базовый блок содержит информацию о своем адресе (адресе первой входящей в него инструкции) и об адресах перехода, которые известны последней инструкции.
Граф должен иметь вершину — первую инструкцию, с которой начинается поток управления. Заголовки из ассемблерного файла дают список возможных точек входа. Я запускаю рекурсивный анализ от первого базового блока, после чего алгоритм проходит по всем адресам перехода и добавляет к блоку ссылки на «дочерние».
При этом каждый блок я прохожу лишь единожды. Существует глобальный список seen
— адреса блоков, которые алгоритм уже видел. При повторном анализе они будут проигнорированы. Это позволяет быстро анализировать рекурсивный граф, в котором могут быть вложенные циклы.
Когда все ссылки на «дочерние» блоки расставлены, граф готов. Это аналог абстрактного синтаксического древа (AST). Дальше мы будем работать только с ним.
Проприетарный язык игры поддерживает аналог команд CALL
и RET
из обычного ассемблера x86. Однако тут нет никаких соглашений о вызовах вроде stdcall
. Установить число переменных, передаваемых как аргументы функции, невозможно без полноценной эмуляции местного стека.
Поэтому я не стал выделять начала функций как отдельные вершины графа. Каждый вызов значится как обычная передача управления, а тело вызываемой функции — часть тела вызывающей. Этот трюк позволяет анализировать функцию в контексте вызывающего ее кода. После первичного анализа функцию можно выделить в отдельный граф.
Я решил показать все этапы анализа на примере небольшого файла fire.bin
. Вот как выглядит его ассемблерный код:
RunOp = 0x6 RunTask = 1 GlobalTasks: GTASK_0 Params = 0 EVENT_5 Op = 0x3 Vars = () GTASK_1 Params = 0 EVENT_6 Op = 0x26 Vars = () 0x0: @ Hold() 0x1: Pop(0) 0x2: Return(); Pop(0) 0x3: @ StopGroup0() 0x4: Pop(0) 0x5: Return(); Pop(0) 0x6: PushEmpty(object, object) 0x7: PushEmpty(bool) 0x8: Call 0x2c 0x9: Pop(0) 0xa: Pop(1); Push((bool) Stack[-1] == 0) 0xb: IF (Stack[-1] == 0) GOTO 0x11; Pop(1) 0xc: PushEmpty() 0xd: Push(-0, 0); TaskCall(0) 0xe: Call 0x0 0xf: Pop(-0, 0); TaskReturn 0x10: Pop(0) 0x11: Push("fire") 0x12: @ FindParticleSystem(Stack[-1], Stack[-2]) 0x13: Pop(1) 0x14: Pop(0); Push((bool) Stack[-1] == 0) 0x15: IF (Stack[-1] == 0) GOTO 0x1a; Pop(1) 0x16: Push("Can't find fire particle system") 0x17: @ Trace(Stack[-1]) 0x18: Pop(1) 0x19: Return(); Pop(2) 0x1a: Push(CVector(0.0, 0.0, 0.0)) 0x1b: Push(CVector(0.0, 1.0, 0.0)) 0x1c: Push((float)0.0) 0x1d: @@ AddSource(Stack[-3], Stack[-2], Stack[-1]) 0x1e: Pop(3) 0x1f: @@ Enable() 0x20: Pop(0) 0x21: @ Hold() 0x22: Pop(0) 0x23: GOTO 0x21 0x24: Return(); Pop(2) 0x25: Stack[-1] = 0 0x26: PushEmpty() 0x27: Push(-0, 0); TaskCall(0) 0x28: Call 0x0 0x29: Pop(-0, 0); TaskReturn 0x2a: Pop(0) 0x2b: Return(); Pop(0) 0x2c: PushEmpty(bool, bool) 0x2d: @ IsLoaded(Stack[-1]) 0x2e: Pop(0) 0x2f: Stack[-3] = Stack[-1] 0x30: Return(); Pop(2)
Из заголовков видно, что тут есть три точки входа: 0x3
, 0x6
и 0x26
. Пройдя по коду, замечаем два адреса, на которые передается управление через Call
, — 0x0
и 0x2C
. Это вершины функций. Так выглядит первичный граф:
0x3: @ StopGroup0()0x4: Pop(0)0x5: Return(); Pop(0)0x26: PushEmpty()0x27: Push(-0, 0); TaskCall(0)0x28: Call 0x0 0x0: @ Hold() 0x1: Pop(0) 0x2: Return(); Pop(0) 0x29: Pop(-0, 0); TaskReturn 0x2a: Pop(0) 0x2b: Return(); Pop(0)0x6: PushEmpty(object, object)0x7: PushEmpty(bool)0x8: Call 0x2c 0x2c: PushEmpty(bool, bool) 0x2d: @ IsLoaded(Stack[-1]) 0x2e: Pop(0) 0x2f: Stack[-3] = Stack[-1] 0x30: Return(); Pop(2) 0x9: Pop(0) 0xa: Pop(1); Push((bool) Stack[-1] == 0) 0xb: IF (Stack[-1] == 0) GOTO 0x11; Pop(1) 0xc: PushEmpty() 0xd: Push(-0, 0); TaskCall(0) 0xe: Call 0x0 0x0: @ Hold() 0x1: Pop(0) 0x2: Return(); Pop(0) 0xf: Pop(-0, 0); TaskReturn 0x10: Pop(0) 0x11: Push("fire") 0x12: @ FindParticleSystem(Stack[-1], Stack[-2]) 0x13: Pop(1) 0x14: Pop(0); Push((bool) Stack[-1] == 0) 0x15: IF (Stack[-1] == 0) GOTO 0x1a; Pop(1) 0x16: Push("Can't find fire particle system") 0x17: @ Trace(Stack[-1]) 0x18: Pop(1) 0x19: Return(); Pop(2) 0x1a: Push(CVector(0.0, 0.0, 0.0)) 0x1b: Push(CVector(0.0, 1.0, 0.0)) 0x1c: Push((float)0.0) 0x1d: @@ AddSource(Stack[-3], Stack[-2], Stack[-1]) 0x1e: Pop(3) 0x1f: @@ Enable() 0x20: Pop(0) 0x21: @ Hold() 0x22: Pop(0) 0x23: GOTO 0x21
Для каждой точки входа граф строится отдельно, то есть их тут три штуки. Отступ слева обозначает начало дочернего блока.
У блока по адресу 0x26
есть два «ребенка»: вызов функции по адресу 0x0
и следующая по номеру инструкция 0x29
. В первоначальном графе тело вызываемой функции прикреплено к «родительскому» блоку, но только один раз на граф.
Блок по адресу 0x9
содержит два дочерних блока: следующий по номеру 0xc
и переход на 0x11
. Из‑за этого перехода блок (внутри которого нет команд передачи управления) по адресу 0xf
делится на два. При этом блок по адресу 0x11
одновременно является «ребенком» для блока 0xf
, как следующий за ним по номеру. Но так как мы придерживаемся правила «не заходить в один блок дважды», граф будет отображаться с таким причудливым порядком вложенности.
Основная проблема декомпилированного кода — относительные адреса в стеке. К переменной обращаются через индекс от конца стека, и добавление новой переменной в конец стека ломает старые ссылки. Единственный способ понять, какая переменная где используется, — пройти по графу исполнения, симулируя все операции над стеком.
Для этого мне пришлось улучшить декомпилятор ассемблерного кода: я добавил каждой инструкции информацию о том, сколько переменных она достает из стека и какого типа переменные в стек помещает.
class CInstructionPop: def __init__(self, r): self.OpCode = 'Pop' self.VarIn = r.uint32() self.PopCount = self.VarIn def __repr__(self): return f'Pop({self.VarIn})'class CInstructionAdd: def __init__(self, r): self.OpCode = 'Add' self.Var1 = r.uint32() self.Var2 = r.uint32() self.TaskVar = r.int8() # signed self.PopCount = self.TaskVar & 0x3F self.PushType = [VAR_TYPE.INT] def __repr__(self): if self.TaskVar >= 0: var_1 = f'Stack[-{self.Var1}]' else: var_1 = f'Stack[{self.Var1} + StackPtr]' if self.TaskVar & 0x40: var_2 = f'Stack[{self.Var2} + StackPtr]' else: var_2 = f'Stack[-{self.Var2}]' return f'Pop({self.PopCount}); Push({var_1} + {var_2});'
Симулятор стека идет от вершины графа. Каждая инструкция добавляет или убирает из стека значения. Хитрость в том, что мы делаем два снимка стека для каждой инструкции — до выполнения опкода и после. И добавляем их как метаданные в тело инструкции. На следующем этапе анализа снимки стека помогут определить, какая переменная используется в инструкции.
0x6: PushEmpty(object, object)>before: []>after: [var_0_object, var_1_object] 0x7: PushEmpty(bool)>before: [var_0_object, var_1_object] >after: [var_0_object, var_1_object, var_2_bool]
Например, в самом начале функции 0x6
стек пустой, но первая команда помещает в него две переменные типа object
. Следующая инструкция добавляет переменную типа bool
.
Симулятор стека помнит номер последней созданной переменной и при помещении в стек новой выдает ей следующий порядковый номер. Простое решение с глобальным счетчиком оказалось чрезвычайно плодотворным. Каждая используемая переменная получила уникальный номер, а значит, ее использование можно отслеживать!
Источник: xakep.ru