
Декомпиляция — это не магия, а очень упрямый, скрупулёзный и грязноватый процесс, где каждый байт может оказаться фатальным. В этой статье я разложу по винтикам, как мыслят современные декомпиляторы: как они восстанавливают структуру кода, зачем строят 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 без конфликтов.
Комментарии (5)
Ilya_JOATMON
28.07.2025 15:55Перевод в промежуточное представление машинных кодов хоть и обеспечивает универсальность для дальнейшей декомпиляции имеет и свои недостатки, такие как потеря той информации, которая идет в порядке инструкций и выборе машинных инструкций. Декомпиляторов имеющих возможности посмотреть из верхнего уровня на уровень инструкций за подсказками я не знаю.
Использование LLVM для целей декомпиляции же я считаю вообще ошибкой, эта штука проектировалась для ОПТИМИЗАЦИИ, а в этой задаче нужна ДЕОПТИМИЗАЦИЯ - для человекочитаемости. Нужно распутивать граф управления в том числе дублированием блоков кода (это насколько знаю умеет только fernflower), приводить switch case к нормальному виду из мешанины if и прочее.
Ни один из декомпиляторов нормально не поддерживает объектный и шаблонный код на С++, результат - месиво просто.
NeriaLab
28.07.2025 15:55Тут совсем так немножечко решил написать свой декомпилятор и дизассемблер:пока только для x16-x32 - "заболел" ретро играми (DOS, Windows, Atari)
Часть из моего ТЗ
10.1. Функциональные критерии
Корректное декодирование инструкций x86 (16/32 бита) на реальных бинарных примерах, включая все addressing modes, префиксы, переходы, вызовы, арифметику, работу с памятью, стеком, портами, флагами, прерываниями.
Соответствие формата вывода выбранному синтаксису (по умолчанию — Intel/MASM/TD), поддержка всех вариантов записи операндов, префиксов, сегментов, смещений, литералов.
Корректная работа виртуализации (отображение только видимых строк), отсутствие артефактов, задержек, ошибок при прокрутке, обновлении диапазона.
Корректная интеграция с кастомным hex-редактором, поддержка всех событий, навигации, выделения, обновления данных.
Возможность расширения (новые инструкции, режимы, форматы, модули анализа) без переписывания ядра, через интерфейсы и точки расширения.
Подробные логи для отладки, анализ ошибок, предупреждений, событий, производительности.
Документация и примеры использования для всех основных сценариев, модулей, интерфейсов, расширений.
10.2. Нефункциональные критерии
Время декодирования 1000 инструкций — не более 100 мс на среднестатистическом ПК (Intel i5, 8 ГБ RAM), при больших объёмах — линейное масштабирование.
Время обновления диапазона строк — не более 50 мс, отсутствие заметных задержек при прокрутке, навигации.
Потребление памяти — не более 50 МБ на 1 МБ кода, оптимизация хранения промежуточных данных.
Корректная обработка ошибок и исключений, отсутствие сбоев, утечек памяти, зависаний.
Соответствие архитектурным принципам (разделение логики и UI, использование интерфейсов, абстракций, точек расширения).
...
18.4. Ограничения и реалистичные целиСоздание такого анализатора — задача, сравнимая по сложности с разработкой компилятора или переводчика между двумя сложнейшими языками. x86-ассемблер допускает произвольные переходы, самомодифицирующийся код, нестандартные прологи/эпилоги, что делает автоматическое определение границ и назначения функций крайне сложным. Даже лучшие инструменты (IDA, Ghidra) не всегда справляются идеально и всегда оставляют место для ручной корректировки.
В связи с этим:
Анализатор позиционируется как "помощник", а не "заменитель эксперта".
Основная задача — автоматизация рутинных операций (поиск функций, построение call graph, подсказки по паттернам), а не "магическое" понимание смысла любого кода.
Всегда должна быть возможность ручной корректировки, переименования, комментирования, визуализации.
Архитектура должна быть расширяемой: поддержка новых эвристик, паттернов, сигнатур, интеграция с внешними базами, возможность дообучения.
Вся работа анализатора должна быть прозрачной для пользователя: показывать, почему принято то или иное решение, давать возможность "откатить" или скорректировать результат.
18.5. Польза для реверса и обучения
Существенно ускоряет первичный разбор неизвестного бинарного кода.
Помогает быстро выявить ключевые функции, точки входа, связи между частями программы.
Позволяет отслеживать реальные пути исполнения, видеть "живое" поведение функций.
Делает процесс обучения ассемблеру более наглядным: пользователь видит, как реально исполняется код, какие участки "живые", а какие — только "на бумаге".
Обеспечивает удобную платформу для накопления и обмена знаниями (база сигнатур, паттернов, пользовательских комментариев).
mov eax, [ebx]
add eax, 4
call eax
Это даже не боль - мелочь кошачья и легко понимается
Берём значение из памяти по адресу в ebx → кладём в eax
Прибавляем 4 к этому значению.
Вызываем функцию по получившемуся адресу.
Такой шаблон очень характерен для C++ при вызове виртуальных функций через полиморфный объект
class Animal { public: virtual void speak() { cout << "Animal sound\n"; } virtual void move() { cout << "Animal moves\n"; } // ← это вторая виртуальная функция }; Animal* animal = new Dog(); animal->move(); // ← вот этот вызов может компилироваться в эти 3 строки
molnij
28.07.2025 15:55Поэтому автор и привел три варианта, любой из которых может быть скомпилирован в приведенный asm-код. Вы выбрали всего лишь один из них.
NeriaLab
28.07.2025 15:55Так я исходил из того, что знаю. Есть два пути, короткий и длинный:
1. Короткий:
1.1. То, что я написал и без вариантов
2. Длинный
2.1. Открываем DIE (Github)
2.2. Открываем ImHex (Github)
Согласно пунктам 2.1. и 2.2. получили базовую инфу о программе
2.3. Если у нас C то:С (пример)
struct GameModule { void (*init)(); void (*update)(); void (*render)(); }; GameModule* mod = ...; mod->update(); // → mov eax, [ebx] → add eax, 4 → call eax
2.4. Если у нас C++ - пример уже написан
2.5. Есть вариант что это указатели на функции, которые хранятся в структуре, загруженной из DLL, но обычно, они так редко записываются
3. Открываем Гидру, Иду или другое...
Итог:
Если видишь, что ebx указывает на объект, а [ebx] — на глобальную таблицу, и так везде — тогда можно сказать: "Да, это vtable." В противном случае лучше сказать: "индиректный вызов через таблицу функций".
Goron_Dekar
Ага. Интересно.
Можно пример декомпиляции кода на VB6 с вызовом внешних функций из С библиотек. С войной с соглашениями вызовов и угадыванием сигнатур?