Modologic. Декомпилируем проприетарный ассемблер в си-код

Содержание статьи

  • Пара слов о декомпиляции
  • Граф потока управления
  • Обработка функций
  • Пример построения графа
  • Симулятор стека
  • Проходы
  • Сжатие кода
  • Выводы

Это рас­сказ о том, как я сде­лал деком­пилятор вро­де 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

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *