
Декомпиляция — это не магия, а очень упрямый, скрупулёзный и грязноватый процесс, где каждый байт может оказаться фатальным. В этой статье я разложу по винтикам, как мыслят современные декомпиляторы: как они восстанавливают структуру кода, зачем строят 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 без конфликтов.
ссылка на оригинал статьи https://habr.com/ru/articles/931768/
Добавить комментарий