Как стать автором
Поиск
Написать публикацию
Обновить

Как мыслит дизассемблер: внутренняя логика decompiler-инструментов на примере Ghidra и RetDec

Уровень сложностиСложный
Время на прочтение4 мин
Количество просмотров691

Декомпиляция — это не магия, а очень упрямый, скрупулёзный и грязноватый процесс, где каждый байт может оказаться фатальным. В этой статье я разложу по винтикам, как мыслят современные декомпиляторы: как они восстанавливают структуру кода, зачем строят SSA, почему не верят ни одному call’у на слово, и как Ghidra и RetDec реализуют свои механизмы под капотом. Это не глянцевый обзор, а техразбор, вплоть до IR, реконструкции управляющего графа и попытки угадать типы переменных там, где они уже испарились. Будет сложно, но весело.

Введение

Когда-то давно, в эпоху до удобных IDA, я сидел в холодной общаге и вручную распутывал рекурсивные вызовы в дизассемблере, который даже не умел выделять функции. Тогда я еще думал, что дизассемблер — это просто таблица соответствий: байт → инструкция. Наивно, но душевно.

С тех пор многое поменялось. Сегодняшние декомпиляторы вроде Ghidra и RetDec умеют реконструировать не просто код, а чуть ли не начальную логику программы. Но как? Как они догадываются, где функция начинается и заканчивается? Почему они иногда путают указатели с int’ами? И при чем тут SSA и Control Flow Graph?

Давайте заглянем внутрь. Прямо в кишки.


1. Что такое декомпиляция и почему это боль

Коротко: дизассемблирование — это превращение бинарного кода в инструкции ассемблера. А декомпиляция — это превращение того же бинаря в некий суррогат высокоуровневого языка, чаще всего — C.

Но! Это не обратимое преобразование. Информация утеряна. Типы? Потеряны. Имена функций? Нет их. Границы блоков? Возможно. И если дизассемблер ещё может просто механически разбирать инструкции, то декомпилятор должен... угадывать. Местами буквально.


2. Общий пайплайн: от байта к коду

Наивный взгляд на декомпилятор:

  1. Загрузи бинарь

  2. Разбери инструкции

  3. Построй граф

  4. Восстанови функции

  5. Построй AST

  6. Сгенерируй C-код

На практике:

  1. Всё не так.

  2. Вообще не так.

Вот как это реально устроено (в Ghidra и RetDec):

[Bytes] → [Instruction Decoder] → [Intermediate Representation (IR)] →  
→ [Control Flow Graph] → [SSA Form] → [Type Recovery] →  
→ [Decompilation Rules] → [AST Generator] → [C-like Output]

3. Ghidra: её мозг — это Sleigh

Если вы думали, что в Ghidra всё делают скрипты на Java — не совсем так. Центральное место здесь занимает язык описания архитектур Sleigh. Он позволяет описывать, как из последовательности байт получаются инструкции.

Пример фрагмента Sleigh для x86:

define token opcodes (8)
  ADD = 0x01;
...
:ADD reg8, reg8 is opcodes=0x00; reg8; reg8
{
  reg8 = reg8 + reg8;
}

Это DSL, по которому Ghidra строит декодер инструкций. И этот слой уже превращает байты в IR — промежуточное представление, на котором и происходит основная аналитика.


4. RetDec и сила LLVM

RetDec построен на базе LLVM. Бинар разбирается в LLVM IR, после чего к нему применяются те же оптимизации, что и в компиляторе. Бонус: можно декомпилировать под любые архитектуры, если есть фронтенд.

Пример IR-фрагмента после разбора:

define void @func() {
entry:
  %x = alloca i32
  store i32 42, i32* %x
  %y = load i32, i32* %x
  ret void
}

Это не C, но уже что-то, с чем можно работать: видны переменные, потоки управления, инструкции. И, главное, их можно анализировать.


5. Control Flow Graph — хребет анализа

Один из первых этапов — построение графа управления (CFG). Он показывает, какие блоки кода исполняются после каких. Без него невозможно построить нормальную картину исполнения.

Пример на Python с networkx (просто для иллюстрации):

import networkx as nx
G = nx.DiGraph()
G.add_edges_from([
    ('start', 'check'),
    ('check', 'true_branch'),
    ('check', 'false_branch'),
    ('true_branch', 'end'),
    ('false_branch', 'end'),
])
nx.draw(G, with_labels=True)

На практике всё сложнее: приходится учитывать условные переходы, прямые jmp, call, ret, и экзотические jump table.


6. SSA: Static Single Assignment

Следующий шаг — SSA. Каждая переменная должна быть присвоена один раз. Это позволяет проще анализировать зависимости.

Пример:

int x = 1;
if (cond) {
    x = 2;
}
use(x);

В SSA:

x1 = 1
if (cond) {
    x2 = 2
}
x3 = phi(x1, x2)
use(x3)

Зачем? Это облегчает оптимизации и упрощает анализ. Операции с переменными становятся графом, а не спагетти.


7. Восстановление типов: гадание на байтах

Типов в бинаре нет. Есть только байты, mov, push и call. Но декомпилятор должен как-то показать char*, int, double.

Он строит гипотезы. Например:

mov eax, [ebp+8]
mov [ebx], eax
call printf

→ Может быть, это указатель?
→ Может быть, он передаётся в функцию?
→ Что эта функция делает?

Ghidra и RetDec используют эвристику + сигнатуры стандартных библиотек (вроде libc). Если call указывает на printf, и туда передаётся eax, то, возможно, eax — это char*.


8. От IR к C: магия шаблонов

Когда граф построен, SSA применена, типы угаданы, остаётся «вернуть» код. Тут начинается шаблонный генератор — превращение IR в C-подобный код.

Пример из Ghidra:

int __cdecl main(int argc, const char **argv)
{
  int result;
  result = puts("Hello, world!");
  return result;
}

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


9. Почему декомпиляция — это не точная наука

Типичный пример боли:

mov eax, [ebx]
add eax, 4
call eax

Что это?
— Индирект вызов?
— Таблица виртуальных функций?
— Динамический переход?

Декомпилятор может только предположить. Тут спасают паттерны, эвристика и context-aware анализ. Но 100% гарантии — нет.


10. Сюрпризы: оптимизации, инлайнинг, tail-call

Оптимизирующий компилятор — злейший враг декомпилятора. Он меняет структуру, инлайнит функции, превращает циклы в goto и tail-call’ы. В итоге декомпилятору приходится гадать, была ли тут вообще функция.

В RetDec есть опции, чтобы бороться с инлайном, но он всё равно не всесилен. Ghidra умеет находить куски функций по паттернам, но это напоминает охоту на привидений.


Заключение

Декомпилятор — это не просто инструмент, а маленький сумасшедший компилятор наоборот. Он мыслит графами, строит гипотезы, обманывает себя SSA и надеется, что call не делает подлянку. Ghidra и RetDec — отличные примеры того, как далеко зашёл реверс, но за кулисами у них всё ещё идёт постоянная борьба с отсутствием информации, костылями и багами компиляции.

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


Если вам интересно углубиться в декомпиляцию руками — можно будет разобрать конкретный кейс или кусок бинаря в следующей статье. А пока — байты вам в стек и SSA без конфликтов.

Теги:
Хабы:
+5
Комментарии3

Публикации

Ближайшие события