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

На одном 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 ; если да — повторить цикл
Что здесь видно без переименования переменных:
LOADчитает байт по вычисляемому адресу — это массив-ключ.INT_XORвыполняет побитовое XOR входного символа (AL) с загруженным байтом — ядро шифрования.STOREзаписывает результат — выходной буфер.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:
iVar1→loop_counter,uVar2→xor_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.