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


  1. Goron_Dekar
    28.07.2025 15:55

    Ага. Интересно.

    Можно пример декомпиляции кода на VB6 с вызовом внешних функций из С библиотек. С войной с соглашениями вызовов и угадыванием сигнатур?


  1. Ilya_JOATMON
    28.07.2025 15:55

    Перевод в промежуточное представление машинных кодов хоть и обеспечивает универсальность для дальнейшей декомпиляции имеет и свои недостатки, такие как потеря той информации, которая идет в порядке инструкций и выборе машинных инструкций. Декомпиляторов имеющих возможности посмотреть из верхнего уровня на уровень инструкций за подсказками я не знаю.

    Использование LLVM для целей декомпиляции же я считаю вообще ошибкой, эта штука проектировалась для ОПТИМИЗАЦИИ, а в этой задаче нужна ДЕОПТИМИЗАЦИЯ - для человекочитаемости. Нужно распутивать граф управления в том числе дублированием блоков кода (это насколько знаю умеет только fernflower), приводить switch case к нормальному виду из мешанины if и прочее.

    Ни один из декомпиляторов нормально не поддерживает объектный и шаблонный код на С++, результат - месиво просто.


  1. 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

    Это даже не боль - мелочь кошачья и легко понимается

    1. Берём значение из памяти по адресу в ebx → кладём в eax

    2. Прибавляем 4 к этому значению.

    3. Вызываем функцию по получившемуся адресу.

    Такой шаблон очень характерен для 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 строки


    1. molnij
      28.07.2025 15:55

      Поэтому автор и привел три варианта, любой из которых может быть скомпилирован в приведенный asm-код. Вы выбрали всего лишь один из них.


      1. 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." В противном случае лучше сказать: "индиректный вызов через таблицу функций".