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

Снятие VMProtect в Ghidra: восстановление логики лицензионной проверки на HTB-машине

Снятие VMProtect в Ghidra: восстановление логики лицензионной проверки на HTB-машине
Время чтения: 11 мин.

На разборе одного VMProtect-обработчика у меня ушло 12 часов. 42 инструкции в дизассемблере, из которых 35 — мусор, а оставшиеся семь свернулись в одну операцию MOV [reg+offset], value. Двенадцать часов ради одной строчки. На HTB-задачах класса reversing виртуализованные бинарники встречаются на среднем и высоком уровне сложности: автор защищает проверку ключа через VMProtect, а участник должен восстановить алгоритм, чтобы получить флаг. Ниже — полный разбор процесса: от первого открытия файла в Ghidra до восстановления логики лицензионной проверки, с тупиковыми ветками и честными таймингами.

Зачем реверсеру разбираться с VMProtect

VMProtect — коммерческий протектор кода. Его используют и разработчики ПО для защиты лицензий, и авторы малвари для затруднения анализа. Если вы впервые встречаете аббревиатуру MITRE ATT&CK — это открытая база тактик и техник атак, где каждая техника имеет идентификатор вида T1027. VMProtect задействует сразу несколько таких техник:

  • Software Packing (T1027.002) — упаковка кода, чтобы статический анализ бинарного файла не дал результатов
  • Debugger Evasion (T1622) — обнаружение отладчика и противодействие ему
  • Dynamic API Resolution (T1027.007) — скрытие вызовов WinAPI через рантайм-расшифровку IAT (Import Address Table — таблица адресов внешних функций, по которой программа находит нужные системные вызовы)
  • Junk Code Insertion (T1027.016) — вставка бессмысленных инструкций между реальным кодом

Бизнес-логика задачи простая: бинарник содержит функцию проверки лицензионного ключа. Без VMProtect это десяток сравнений и пара хеш-вычислений — минутная задача для дизассемблера. С VMProtect — тысячи обфусцированных инструкций, виртуальная машина обфускации кода с собственным набором опкодов (кодов операций), мусорные инструкции и анти-дебаг техники. Аналитик малвари, который не умеет работать с виртуализацией, не разберёт значительную часть реальных образцов. В CTF и HTB это навык, который отделяет решённые задачи от зависших.

Архитектура виртуализации VMProtect

Dispatcher, handlers, виртуальный стек

VMProtect преобразует оригинальный машинный код x86/x64 в байткод собственной виртуальной машины. Если вы работали с Java или Python — принцип похожий: есть интерпретатор, который читает не нативные инструкции процессора, а свои собственные коды операций. Только здесь цель не переносимость, а запутывание аналитика.

Внутри защищённого бинарника появляется интерпретатор, работающий циклом:

  1. Читает байткод по указателю VIP (Virtual Instruction Pointer — аналог регистра EIP/RIP, но для виртуальной машины). Грубо говоря, VIP показывает «где мы сейчас» в виртуальном коде
  2. Декодирует опкод и передаёт управление соответствующему handler (обработчику) — функции, которая выполняет одну виртуальную инструкцию: сложение, чтение памяти, условный переход
  3. Handler выполняет действие и возвращает управление dispatcher (диспетчеру)
  4. Dispatcher переходит к следующей виртуальной инструкции — цикл повторяется

Виртуальный стек (VSP — Virtual Stack Pointer) хранит операнды и промежуточные результаты. Для входа в виртуальную машину используется VMEntry — точка, где регистры реального процессора сохраняются в VM-контекст. При выходе (VMExit) происходит обратный процесс.

И вот момент, который определяет всю сложность задачи: каждая сборка VMProtect генерирует уникальный набор опкодов. Опкод 0x3A в одном бинарнике может означать ADD, а в другом — XOR. Степень рандомизации зависит от версии и настроек протектора, но суть одна: универсального «словаря» не существует. Для каждого образца таблицу опкодов нужно восстанавливать заново. По данным исследования VMProtect devirtualization (hackyboiz.github.io), задачу можно формализовать как анализ state transitions: неважно, сколько мусорных инструкций внутри handler — важно, как изменились VIP, VSP, VStack и VFlags (виртуальные флаги) после его выполнения.

Что VMProtect делает с бинарником на практике

После защиты бинарника вы увидите характерные признаки:

  • Секция .vmp0 (или .vmp1) — содержит байткод виртуальной машины и тело интерпретатора. Утилита Detect It Easy (DIE) определяет VMProtect по сигнатурам этих секций
  • Обфусцированный entry point — оригинальная точка входа перенаправлена в код инициализации VMProtect
  • Пустая или зашифрованная IAT — вместо прямых вызовов CreateFileW используются трамплины: небольшие функции, которые в рантайме вычисляют адрес нужного API. По данным исследования unpacking VMProtected kernel driver (eversinc33.com), трамплины следуют одному шаблону — загрузка константы, вычитание смещения, прибавление указателя. Это позволяет автоматизировать восстановление IAT через эмуляцию
  • Мусорный код между реальными операциями: pushfd/popfd, бессмысленные xor/shl, цепочки push/lea esp, [esp+X] для «уборки» стека

Распаковка VMProtect и восстановление логики — пошаговый разбор

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

[Применимо: CTF/лаб-среда, анализ малвари в песочнице]

  • ОС: Windows 10/11 x64
  • RAM: минимум 16 ГБ (Ghidra при анализе VMProtect-бинарников потребляет от 4 ГБ, x64dbg с трассировкой — ещё 2-4 ГБ)
  • Диск: SSD (автоанализ Ghidra на HDD занимает в 3-5 раз дольше)
  • Инструменты: Ghidra 11.x+, x64dbg с плагином ScyllaHide (скрывает отладчик от анти-дебаг проверок), Python 3.9+
  • Для трассировки: Intel Pin (фреймворк динамической инструментации — записывает каждую выполненную инструкцию) или аналог
  • Для символьного анализа: Triton (фреймворк, который заменяет конкретные значения переменными и упрощает формулы — подробнее на шаге 3)

Работает если: VMProtect 3.x с виртуализацией отдельных функций, без mutation mode. Не работает если: VMProtect 3.8+ с ultra-виртуализацией + мутацией — автоматические подходы требуют адаптации на каждый запуск.

Шаг 1 — идентификация защиты и поиск VMEntry в Ghidra

Открываем бинарник в Ghidra. При импорте Ghidra предложит автоматический анализ — соглашаемся, но готовьтесь к тому, что декомпилятор покажет нечитаемый результат для виртуализованных функций. Это нормально: декомпилятор не понимает семантику VM-обработчиков.

Делай раз: проверяем секции через Window → Program Trees. Наличие .vmp0, .vmp1 или нестандартных секций с высокой энтропией подтверждает VMProtect.

Делай два: ищем строки через Search → For Strings. Цель — диагностические сообщения типа "Wrong key", "License expired", "Access granted". Это зацепки для обратного хода по графу вызовов. Если строки найдены — кликаем по каждой правой кнопкой, References → Show References to Address, переходим к функции, которая их использует.

Делай три: от найденной функции поднимаемся по xref (перекрёстным ссылкам — показывают, откуда вызывается функция или используется переменная) вверх по графу вызовов. В какой-то момент вы увидите характерный паттерн VMEntry: серию PUSH EAX, PUSH EBX, …, PUSHFD, за которой следует переход JMP на адрес внутри секции .vmp0. Запоминаем этот адрес — он нужен для динамического анализа.

Ожидаемый результат: адрес VMEntry записан. Если строк в бинарнике нет — они могут быть зашифрованы и расшифровываться в рантайме (Deobfuscate/Decode Files or Information, T1140). Тогда переходим сразу к динамике.

Тупиковая ветка, на которую я наступал: запуск Ghidra-декомпилятора на виртуализованной функции в надежде «увидеть логику». Декомпилятор выдаёт тысячи строк псевдокода, которые не имеют смысла — это интерпретация мусорных инструкций обработчиков как «реального кода». Не тратьте время на чтение этого вывода.

Шаг 2 — сбор трассы выполнения через динамический анализ

Статический анализ виртуализованного кода в Ghidra даёт мало. Переходим к динамике.

Делай раз: запускаем бинарник под x64dbg с включённым ScyllaHide. В настройках плагина (Plugins → ScyllaHide → Options) отмечаем IsDebuggerPresent, NtQueryInformationProcess, GetTickCount. ScyllaHide перехватывает анти-отладочные проверки и возвращает «безопасные» значения — бинарник не замечает отладчик.

Делай два: ставим breakpoint на адрес VMEntry командой bp 0xАДРЕС в командной строке x64dbg. Запускаем программу (F9), вводим произвольный ключ. При срабатывании breakpoint мы оказываемся на входе в виртуальную машину.

Делай три: для сбора полной трассы используем Intel Pin. Pin записывает каждую выполненную инструкцию с адресом и значениями регистров. Запуск: pin -t trace_tool.dll -- target.exe, где trace_tool.dll — самостоятельно скомпилированный pintool (например, на основе примеров из source/tools/ManualExamples в Pin SDK). Результат — текстовый файл с миллионами строк вида eip=0x50260c,esp=0x2f1f51c,....

Ожидаемый результат: файл трассы размером от десятков мегабайт до нескольких гигабайт. Из него нужно выделить сегменты, соответствующие выполнению конкретных VM-обработчиков.

Шаг 3 — анализ обработчиков и деобфускация кода VMProtect

Dispatcher VMProtect содержит характерный цикл: чтение байта по VIP, декодирование (обычно XOR с ключом), косвенный переход по таблице обработчиков. Адрес MAIN_LOOP — точка, куда возвращается каждый handler.

Из полной трассы нарезаем фрагменты: каждый начинается переходом из dispatcher в handler и заканчивается инструкцией перед MAIN_LOOP. Для каждого фрагмента определяем семантику — что handler делает с VM-состоянием.

Конкретный пример из анализа VMProtect (по данным исследования на Хабре): handler по адресу 0x50260c содержит 42 инструкции. Из них значимых семь: чтение байта из байткода (mov al, byte ptr [esi], где ESI = VIP), декодирование XOR-ключом, использование результата как смещения для записи значения из [EBP]. Семантика: VM_POP_TO_REG[operand]. Остальные 35 инструкций — pushfd, push/lea esp, бессмысленные shl/xor — мусор.

Для автоматизации используется Triton. Идея такая: загружаем байты каждого handler, запускаем символьное выполнение (Triton заменяет конкретные значения регистров на переменные и прослеживает, как они трансформируются), вызываем simplify(). Triton убирает dead stores (записи, результат которых никогда не читается) и показывает чистую формулу.

# Triton: упрощение одного VM-handler
# Предусловие: ctx — инициализированный TritonContext
from triton import TritonContext, ARCH
ctx = TritonContext(ARCH.X86)
for opcode_bytes in handler_instructions:
    inst = Instruction(opcode_bytes)
    ctx.processing(inst)
# Получаем символьное выражение для EAX (результат)
eax_expr = ctx.getSymbolicRegister(
    ctx.registers.eax
)
simplified = ctx.simplify(eax_expr.getAst(), True)
print(f"Handler semantic: {simplified}")

Ожидаемый результат: для каждого handler — одна-две строки семантики вместо десятков инструкций.

Ограничение: Triton требует линейного кода. Все jmp/jz/jnz нужно удалить, call — заменить на push адреса возврата. Если handler содержит ветвления внутри себя (что бывает в VMProtect 3.5+), придётся разбивать его на линейные сегменты вручную. В материалах Хабра автор честно пишет «результат далёк от успеха» — и это правда для сложных случаев.

Ghidra-скрипт для разметки обработчиков VMProtect

После определения семантики каждого handler возвращаемся в Ghidra. Цель — пометить виртуализованную функцию читаемыми комментариями. Скрипт на Python (Script Manager → New → Python):

# Ghidra: разметка VM-handlers комментариями
# Пример для демонстрации концепции
handler_map = {
    0x3A: "VM_ADD: VSP[0] += VSP[1]; VSP++",
    0x1F: "VM_POP_REG: REG[op1] = POP(VStack)",
    0x42: "VM_PUSH_IMM: PUSH(imm32)",
    0x58: "VM_CMP: flags = CMP(VSP[0], VSP[1])",
}
for opcode, sem in handler_map.items():
    # Адрес handler'а берём из восстановленной таблицы dispatcher'а,
    # а не вычисляем арифметически — handler'ы расположены произвольно
    addr = toAddr(handler_addresses[opcode])  # dict {opcode: real_addr}
    listing = currentProgram.getListing()
    listing.setComment(addr, 0, sem)

В реальном анализе таблицу handler_map заполняем по результатам Triton. Для каждого нового образца VMProtect таблица будет другой — скрипт остаётся тем же, меняются только данные.

Патчинг лицензионной проверки — восстановление алгоритма

Имея семантику обработчиков и байткод из секции .vmp0, восстанавливаем логику:

  1. Дизассемблируем байткод — читаем побайтово, применяем таблицу семантик. Последовательность 0x42, 0x3A, 0x58, 0x7C может означать PUSH_IMM → ADD → CMP → JZ
  2. Восстанавливаем поток управленияVM_CMP + VM_JZ = условный переход. Отмечаем ветки «ключ верный» и «ключ неверный»
  3. Описываем алгоритм на псевдокоде — типичная проверка в CTF: считать строку, применить серию XOR/ADD/ROL к каждому символу, сравнить с захардкоженным значением

Для получения флага на HTB нужен keygen — обратный алгоритм, восстанавливающий ключ из константы сравнения. Для простого патчинга достаточно инвертировать условие перехода в байткоде (заменить опкод VM_JZ на VM_JNZ или на VM_JMP), но это не даст флаг.

Когда девиртуализация VMProtect не работает

Не работает если:

  • VMProtect 3.8+ с mutation mode — каждый handler мутирует при каждой сборке, Triton-подход требует полного анализа заново для каждого билда
  • Бинарник проверяет целостность кода (CRC/хеш секций) — патчинг байткода ломает проверку, нужно дополнительно нейтрализовать integrity checks
  • Комбинация VMProtect + Themida — двойная виртуализация, где каждый слой генерирует свои опкоды. Тайминги вырастают кратно
  • Анти-VM-детект (System Checks, T1497.001) — бинарник определяет виртуальную среду (VirtualBox, VMware) и меняет поведение. ScyllaHide не закрывает все проверки на уровне ядра

Альтернативы при неудаче:

  • Дамп после распаковки — если код не виртуализован, а только упакован. Подход из исследования eversinc33: breakpoint на entry point, дождаться завершения инициализации VMProtect, снять дамп через .writemem в WinDbg, восстановить IAT через эмуляцию в Unicorn. Для упакованных (не виртуализованных) бинарников это решает задачу полностью
  • Frida-хуки на API — не девиртуализируем код, а перехватываем результат: ставим хук на MessageBoxW или функцию сравнения строк и читаем аргументы. Работает, если нужен только результат, а не алгоритм
  • Tenet — плагин для навигации по трассе в обе стороны (forward и backward). Чистый код не даёт, но позволяет «промотать» выполнение до точки принятия решения и увидеть, при каком значении регистра бинарник уходит в ветку "Access granted"

Девиртуализация VMProtect — навык, который не масштабируется через автоматизацию так, как хотелось бы. Можно прочитать десять статей про VIP, VSP и dispatcher, но реальное понимание приходит после того, как сам просидишь часы над одним handler, убеждаясь что 35 из 42 инструкций — мусор.

В CTF-сообществе вижу устойчивую картину: люди ищут «автоматический devirtualizer» и расстраиваются, когда он не работает на конкретном семпле. VMProtect целенаправленно делает каждый билд уникальным — универсального инструмента не будет. Умение руками построить таблицу опкодов, написать скрипт для Triton под конкретный образец и прочитать результат в Ghidra — это то, что отличает реверсера, который решает задачи, от того, кто застревает на первом handler.

Вторая неудобная деталь: большинство «VMProtect cracked» постов в интернете описывают бинарники с упаковкой без виртуализации — там действительно достаточно дампа и восстановления IAT. Настоящая VM-защита на порядок сложнее, и честные тайминги «40-120 часов на одно семейство» (по моей оценке и опыту коллег из реверс-сообщества) ближе к реальности, чем обещания «снять за вечер».

Если подход из этой статьи кажется слишком трудоёмким — начните с упакованных, не виртуализованных образцов: дамп + IAT recovery через Unicorn. Это набьёт руку и интуицию, прежде чем переходить к полной девиртуализации. На WAPT в Codeby Academy связку «дамп → восстановление IAT → анализ в Ghidra» проходят с лабами, где можно отработать каждый шаг на живом бинарнике.

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