Перейти к содержимому

Ghidra P-Code реверс-инжиниринг: от дизассемблера до восстановления крипто-функции на реальном crackme

Ghidra P-Code реверс-инжиниринг: от дизассемблера до восстановления крипто-функции на реальном crackme
Время чтения: 12 мин.

На одном CTF stripped ELF-бинарник сожрал у команды два с лишним часа. Декомпилятор Ghidra выплюнул стену из iVar1 и uVar2 с побитовыми операциями, и трое участников честно пытались это читать как ассемблер. Переключение на P-Code заняло минуту, разбор — ещё двадцать: в сырых P-Code-операциях проступил паттерн INT_XOR с фиксированным ключом, за ним INT_RIGHT и характерная константа 0xedb88320 — полином CRC32. Разница была не в навыке чтения ассемблера, а в выборе правильного слоя представления. Разберём, как использовать Ghidra P-Code для восстановления логики крипто-функции без символов — от сырого листинга до момента, когда кастомный шифр читается как обычный код.

Что такое Ghidra P-Code и зачем он нужен при анализе бинарного кода без символов

P-Code — промежуточное представление (Intermediate Representation, IR) внутри Ghidra. При загрузке бинарника Ghidra «поднимает» (lifts) машинные инструкции конкретного процессора в универсальный набор P-Code-операций.

Зачем это нужно: x86 содержит сотни инструкций с неявными побочными эффектами — вычисление флагов, сегментные регистры, скрытые зависимости между операциями. ARM добавляет условное выполнение, MIPS — delay slots. P-Code убирает весь этот архитектурный шум и оставляет чистую логику.

Трансляция работает по принципу «один-ко-многим»: одна ассемблерная инструкция порождает одну или несколько P-Code-операций. Простой пример (по данным River Loop Security): инструкция MOV RAX, RSI транслируется в единственную P-Code-операцию RAX = COPY RSI. А SHR RAX, 0x3f разворачивается в тридцать P-Code-операций — процессор при сдвиге неявно пересчитывает флаги CF, OF, SF и ZF, и P-Code делает каждое вычисление явным. Для анализа крипто-функции эти тридцать операций — шум. Но сам INT_RIGHT (сдвиг вправо) — именно то, что нужно увидеть.

Трансляцию выполняет SLEIGH — язык описания процессорных инструкций Ghidra. SLEIGH задаёт и дизассемблирование (последовательность байтов 89 d8 = MOV EAX, EBX), и семантику (MOV EAX, EBX → P-Code EAX = COPY EBX). Документация по P-Code поставляется с Ghidra в файле docs/languages/html/pcoderef.html.

MITRE ATT&CK — открытая база тактик и техник атак, где каждой технике присвоен T-код (идентификатор). Удаление символов из бинарника классифицируется как T1027.008 — Stripped Payloads (тактика stealth). Приём стандартный: авторы malware и crackme удаляют отладочную информацию, чтобы затруднить реверс. В таком бинарнике функции именуются FUN_00401000, переменные — iVar1, и единственная зацепка — логика самих операций. P-Code даёт доступ к этой логике напрямую, минуя интерпретации декомпилятора.

Декомпилятор Ghidra строит C-подобный псевдокод поверх P-Code, но применяет эвристики: группирует операции, угадывает типы, иногда ошибается в структуре циклов или путает знаковые и беззнаковые типы. Когда псевдокод нечитаем — P-Code позволяет увидеть «сырую» логику без домыслов.

Альтернатива — radare2 (версия 6.1.9, активно развивается) со своим IR — ESIL. Принцип аналогичен, но синтаксис и среда скриптов отличаются. Эта статья фокусируется на Ghidra P-Code.

Ключевые P-Code операции для крипто-анализа

P-Code оперирует над varnode’ами (VarnodeAST) — это обобщение регистра или ячейки памяти. Каждый varnode описывается тройкой: адресное пространство, смещение, размер. Временные переменные (unique space) в raw P-Code выглядят как (unique, 0xb7c0, 4) — четырёхбайтовая временная, константы — как (const, 0x3f, 4). В упрощённой нотации их записывают как $Ub7c0:4 и 0x3f:4.

Для восстановления крипто-функций достаточно знать операции из таблицы:

P-Code операция Что делает Крипто-контекст
COPY Копирует varnode Перемещение данных
INT_XOR Побитовое исключающее ИЛИ XOR-шифрование, генерация ключей
INT_AND Побитовое И Маскирование, выделение полей
INT_LEFT / INT_RIGHT Битовый сдвиг Ротация в блочных шифрах
INT_ADD / INT_SUB Сложение / вычитание Аддитивные шифры, контрольные суммы
LOAD / STORE Чтение / запись памяти Доступ к S-box, таблицам подстановки
INT_EQUAL Сравнение на равенство Проверка результата шифрования
CBRANCH Условный переход Управление циклами шифрования

На практике правило простое: цепочка LOAD → INT_XOR → STORE внутри цикла — маркер XOR-шифрования. Если к ней добавляется INT_LEFT и INT_RIGHT — возможна ротация, характерная для алгоритмов TEA или RC5. А если INT_ADD сочетается с константой 0x9e3779b9 — перед вами дельта TEA (производная золотого сечения).

Требования к окружению

Перед переходом к практике подготовьте рабочее место:

  • ОС: Windows 10/11 или Linux (Ubuntu 20.04+). Ghidra кроссплатформенна.
  • RAM: минимум 8 ГБ. Ghidra работает поверх JVM и при авто-анализе бинарников потребляет 2-4 ГБ.
  • JDK 17+ для Ghidra 11.x — скачивается с adoptium.net или через пакетный менеджер.
  • Ghidra 11.x — с ghidra-sre.org. Без установки: распаковка архива, запуск через ghidraRun (Linux) или ghidraRun.bat (Windows).
  • Виртуальная машина — обязательна для анализа незнакомых бинарников. VirtualBox или VMware, сеть внутри ВМ отключена.
  • Опционально: x64dbg (Windows) или GDB (Linux) для динамического анализа, если P-Code-подхода окажется недостаточно.

Ghidra поддерживается NSA и open-source сообществом, репозиторий на GitHub стабильно обновляется.

Восстановление крипто-функции без символов: от сырого листинга до P-Code

Поиск целевой функции через строки и перекрёстные ссылки

Первый шаг в любом crackme — не разбирать main, а искать строки. Текстовые маркеры («Wrong password», «Try Again!», «Correct!») ведут напрямую к функции проверки даже в stripped-бинарниках: строковые литералы хранятся в секции данных и не удаляются при strip’е (если автор не зашифровал их отдельно — техника T1027, Obfuscated Files or Information).

В Ghidra: Window → Defined Strings. Найдите строку проверки. Правый клик → References → Show References to Address. Ghidra покажет адреса, где строка используется. Двойной клик на ссылке перенесёт в целевую функцию. Тот же принцип работает в IDA Free через Shift+F12 → двойной клик → X для перекрёстных ссылок (xref — cross-reference, список всех мест, где используется конкретный адрес).

Внутри целевой функции ищите структуру: ввод данных (scanf, fgets), трансформация ввода (побитовые операции, вызовы подфункций) и сравнение с эталоном (strcmp, memcmp или прямой cmp).

Если бинарник упакован (T1027.002 — Software Packing) — Ghidra не найдёт ни строк, ни функций кроме entry point. Признак: секции с именами UPX0, UPX1 в Program Tree. Решение: распакуйте перед анализом командой upx -d binary для UPX. Для кастомных пакеров потребуется динамический дамп после распаковки.

Переключение на P-Code в Ghidra

Декомпилятор Ghidra (правая панель Code Browser) по умолчанию показывает псевдокод на C. Чтобы увидеть P-Code, выделите интересующую инструкцию в Listing (центральная панель), затем откройте Edit → Tool Options → Listing Fields → Instruction/Data → включите PCode Field (либо используйте Browser Field Formatter — иконка на панели Listing). Альтернативный путь — через Debug-настройки декомпилятора, где можно увидеть P-Code в SSA-форме. SSA (Static Single Assignment) — представление, где каждой переменной значение присваивается ровно один раз. Это позволяет однозначно отслеживать поток данных: если вы видите v15 = INT_XOR v12, v14, вы точно знаете, откуда пришли v12 и v14.

В SSA-форме P-Code обогащается дополнительными конструкциями. MULTIEQUAL — аналог phi-ноды из теории компиляторов: точка, где два потока данных (из разных веток if/else) сливаются в одну переменную. PTRSUB — арифметика указателей с учётом типов данных.

Для практической работы важнее всего сырой P-Code — именно в нём видны отдельные операции без группировки декомпилятора.

Статический анализ crackme: идентификация крипто-примитивов через Ghidra P-Code

Рассмотрим типовой паттерн crackme, где введённый пароль проходит через XOR с фиксированным ключом, а результат проверяется контрольной суммой CRC32. Структура подробно описана в разборе FatMike’s CrackMe#1: XOR каждого символа ввода с массивом-константой (xor_constant), затем CRC32 от результата (xor_output), сравнение с эталоном (0x5a6aa47d).

Идентификация XOR-паттернов в P-Code

В ассемблере XOR внутри цикла — одна инструкция (XOR AL, [ECX+EAX]). В псевдокоде декомпилятора — строка вида uVar2 = bVar1 ^ *(byte *)(iVar3 + iVar4), где без переименования непонятно, что ключ, а что ввод. На уровне P-Code каждая операция развёрнута явно:

; P-Code XOR-шифрования в цикле — демонстрация концепции
$U1000:1 = LOAD ram, $key_addr       ; чтение байта ключа из памяти
$U1010:1 = INT_XOR AL, $U1000        ; XOR символа ввода с байтом ключа
STORE ram, $out_addr, $U1010         ; запись результата в выходной буфер
EAX = INT_ADD EAX, 1:4               ; инкремент счётчика
$U1020:1 = INT_SLESS EAX, 0x18:4     ; сравнение: счётчик < 24?
CBRANCH <loop_start>, $U1020         ; если да — повторить цикл

Что здесь видно без переименования переменных:

  1. LOAD читает байт по вычисляемому адресу — это массив-ключ.
  2. INT_XOR выполняет побитовое XOR входного символа (AL) с загруженным байтом — ядро шифрования.
  3. STORE записывает результат — выходной буфер.
  4. INT_ADD + INT_SLESS + CBRANCH — цикл с границей 0x18 (24 в десятичной), обрабатываются 24 символа.

Этот паттерн — LOAD → INT_XOR → STORE в цикле — однозначный маркер XOR-шифрования. Если вместо INT_XOR стоит INT_ADD — аддитивный шифр. Если INT_LEFT и INT_XOR чередуются с добавлением константы через INT_ADD — потенциально TEA или его варианты.

Для CRC32 маркер ещё проще: константа 0xedb88320 внутри INT_XOR во вложенном цикле. Это полином CRC32, и его присутствие однозначно идентифицирует алгоритм — не нужно разбирать всю функцию побайтово.

Восстановление алгоритма шифрования

После идентификации примитивов восстановление алгоритма сводится к трём шагам.

Шаг 1: извлеките ключ. В P-Code-листинге адрес ключа — аргумент LOAD (в примере — $key_addr). Перейдите к этому адресу в Listing, выделите данные, кликните правой кнопкой → Data → char[24] (размер определяется по границе цикла). Ghidra покажет байты ключа. Ожидаемый результат: массив байтов или ASCII-строка, которая используется как XOR-ключ.

Шаг 2: извлеките эталонное значение. После вызова CRC32-функции в P-Code будет INT_EQUAL с константой — это эталон. В разобранном crackme это 0x5a6aa47d. Проверьте, нет ли дополнительных условий — например, проверки длины ввода (в том же crackme input_length == 0x16, то есть 22 символа).

Шаг 3: напишите решатель. XOR обратим: A XOR B XOR B = A. Зная ключ и зная, что CRC32 результата должен равняться эталону, задача сводится к подбору ввода. Для ключа длиной 24 и ввода длиной 22 пространство поиска управляемо. На Python: генерируете кандидатов, XOR’ите с ключом, проверяете CRC32 через binascii.crc32().

Псевдокод Ghidra декомпилятор: улучшение читаемости параллельно с P-Code

Ghidra P-Code анализ и работа с декомпилятором — не взаимоисключающие вещи. Каждый раз, когда P-Code подсказывает назначение переменной или функции, переносите это знание обратно в декомпилятор:

  • Правый клик на переменной → Rename Variable: iVar1loop_counter, uVar2xor_result.
  • Правый клик на функции → Edit Function Signature: исправьте типы параметров и возвращаемого значения. Декомпилированный код мгновенно станет читабельнее.
  • Двойной клик на DAT_00409480 в декомпиляторе → перейдите к адресу в Listing → выделите нужное количество байтов → нажмите T → введите char[24]. Ghidra перестанет трактовать каждый байт как отдельную переменную DAT_.

Подсветка синхронизируется в обе стороны: выделение строки в декомпиляторе подсвечивает соответствующий ассемблер в Listing — и наоборот. После переименования десятка переменных функция из каши iVar1 ^ *(byte *)(uVar2 + iVar3) превращается в читаемое input_char ^ key[i].

Ghidra скрипты для автоматизации P-Code анализа

Ручной просмотр P-Code работает для функций на 20-30 инструкций. Для более крупных нужны скрипты. Ghidra поддерживает Java и Python (через Jython), доступ — через Window → Script Manager.

Идея скрипта для поиска крипто-примитивов: получить high-level функцию через Decompiler API, пройти по всем PcodeOp и отфильтровать операции с нужным опкодом. Подход описан в скрипте River Loop Security для трассировки входов malloc — тот же принцип обхода P-Code-графа, но с другим целевым паттерном.

# Поиск INT_XOR операций в текущей функции
from ghidra.app.decompiler import DecompInterface
from ghidra.program.model.pcode import PcodeOp

decomp = DecompInterface()
decomp.openProgram(currentProgram)
func = getFunctionContaining(currentAddress)
results = decomp.decompileFunction(func, 30, monitor)
high_func = results.getHighFunction()

for op in high_func.getPcodeOps():
    if op.getOpcode() == PcodeOp.INT_XOR:
        print("XOR: {} ^ {}".format(op.getInput(0), op.getInput(1)))

Скрипт находит все XOR-операции в функции и выводит операнды. Если один из операндов — константа, перед вами статический XOR-ключ. Если оба — переменные, ключ вычисляется динамически и потребуется дополнительная трассировка.

Запуск: Script Manager → кнопка Create New Script → Python → вставьте код → Run. Вывод появится в окне Console. Ожидаемый результат: список строк вида XOR: AL ^ $U1000:1 с адресами — карта всех XOR-операций функции.

Практические примеры скриптов для P-Code есть в репозитории PracticalPCode на GitHub (kohnakagawa/PracticalPCode) — коллекция примеров работы с P-Code на этапах оптимизации и анализа.

Ограничения P-Code анализа и когда использовать другие подходы

P-Code — не серебряная пуля. Вот конкретные сценарии, где статический P-Code-анализ буксует:

Самомодифицирующийся код (T1140 — Deobfuscate/Decode Files or Information). Бинарник расшифровывает код в рантайме — P-Code покажет только декриптор, не расшифрованную логику. Решение: запуск в отладчике, breakpoint после расшифровки, дамп памяти и повторный импорт в Ghidra.

Антиотладка (T1622 — Debugger Evasion). Проверка ptrace(PTRACE_TRACEME) или IsDebuggerPresent. P-Code покажет саму проверку, но для обхода нужен патчинг: правый клик на условном переходе → Patch Instruction → инвертируйте JNZ на JZ. Приём подробно описан в разборе ELF-CrackPass на freeCodeCamp — патчинг одного байта обходит антиотладку.

Виртуализированный код (T1497 — Virtualization/Sandbox Evasion). VM-Protect и аналоги транслируют x86 в кастомный байткод с кастомным интерпретатором. P-Code покажет интерпретатор, но не байткод. PT SWARM описал механизм динамической инъекции P-Code — создание кастомных P-Code-конструкций через SLEIGH для нестандартных опкодов. Подход рабочий, но требует разработки целого модуля процессора.

Декомпилятор справляется сам. Если псевдокод Ghidra читаем и логика функции прозрачна — спускаться до P-Code избыточно. P-Code — инструмент для случаев, когда высокоуровневый вывод содержит ошибки или непонятен.

Тяжёлая обфускация потока управления. Dispatcher-based обфускация (switch с сотнями case-блоков, opaque predicates) — P-Code корректно представит каждый блок, но восстановить логический порядок вручную будет крайне трудоёмко. Здесь помогает символьное выполнение — angr или встроенный эмулятор Ghidra.

Главное правило: начинайте с декомпилятора. Если результат не читается — переходите на P-Code. Если P-Code указывает на динамическое поведение — переключайтесь на отладчик.


Большинство туториалов по реверс-инжинирингу учат двум вещам: читать ассемблер и пользоваться декомпилятором. P-Code упоминается как деталь реализации — что-то «под капотом», не требующее внимания аналитика. На мой взгляд, это ошибка в методике обучения.

Декомпилятор Ghidra генерирует синтаксически корректный, но иногда семантически неверный псевдокод для крипто-функций: путает знаковые и беззнаковые операции, объединяет два разных цикла в один, теряет побочные эффекты битовых сдвигов. Аналитик, который читает только декомпилятор, принимает этот вывод за истину и строит неверную модель алгоритма. Тот, кто умеет спуститься до P-Code, видит реальную картину — INT_XOR с константой, INT_LEFT на конкретное число позиций, INT_ADD с узнаваемой дельтой.

Русскоязычных практических материалов по P-Code-анализу крипто-функций на момент написания нет. Русские статьи о P-Code (Habr, PT SWARM) касаются инъекции — создания кастомных P-Code-конструкций для модулей процессоров, а не чтения существующего P-Code при реверсе. Англоязычные источники ближе к цели, но фокусируются на поиске уязвимостей (трассировка malloc у River Loop Security), а не на восстановлении алгоритмов. Разрыв — в практике уровня «вижу INT_XOR с 0xedb88320 — значит CRC32».

Попробуйте на ближайшем crackme: вместо того чтобы часами разбирать uVar2 ^ *(byte *)(iVar3 + iVar4) — откройте P-Code, найдите INT_XOR и посмотрите, с какой константой он работает. На курсе WAPT эту связку «строки → xref → P-Code → решатель» проходят в модуле по реверсу с лабами, где можно набить руку на реальных crackme.

Эту тему и смежные навыки разбирают на практике в курсе «Профессия Реверс-инженер» Codeby Academy.