
Декомпиляция — это не магия, а очень упрямый, скрупулёзный и грязноватый процесс, где каждый байт может оказаться фатальным. В этой статье я разложу по винтикам, как мыслят современные декомпиляторы: как они восстанавливают структуру кода, зачем строят SSA, почему не верят ни одному call’у на слово, и как Ghidra и RetDec реализуют свои механизмы под капотом. Это не глянцевый обзор, а техразбор, вплоть до IR, реконструкции управляющего графа и попытки угадать типы переменных там, где они уже испарились. Будет сложно, но весело.
Введение
Когда-то давно, в эпоху до удобных IDA, я сидел в холодной общаге и вручную распутывал рекурсивные вызовы в дизассемблере, который даже не умел выделять функции. Тогда я еще думал, что дизассемблер — это просто таблица соответствий: байт → инструкция. Наивно, но душевно.
С тех пор многое поменялось. Сегодняшние декомпиляторы вроде Ghidra и RetDec умеют реконструировать не просто код, а чуть ли не начальную логику программы. Но как? Как они догадываются, где функция начинается и заканчивается? Почему они иногда путают указатели с int’ами? И при чем тут SSA и Control Flow Graph?
Давайте заглянем внутрь. Прямо в кишки.
1. Что такое декомпиляция и почему это боль
Коротко: дизассемблирование — это превращение бинарного кода в инструкции ассемблера. А декомпиляция — это превращение того же бинаря в некий суррогат высокоуровневого языка, чаще всего — C.
Но! Это не обратимое преобразование. Информация утеряна. Типы? Потеряны. Имена функций? Нет их. Границы блоков? Возможно. И если дизассемблер ещё может просто механически разбирать инструкции, то декомпилятор должен... угадывать. Местами буквально.
2. Общий пайплайн: от байта к коду
Наивный взгляд на декомпилятор:
Загрузи бинарь
Разбери инструкции
Построй граф
Восстанови функции
Построй AST
Сгенерируй C-код
На практике:
Всё не так.
Вообще не так.
Вот как это реально устроено (в 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 без конфликтов.