Reverse: Themida Virtualization
3 сентября 2026 · ~14 минут · Themida 3.2.6.0 · Unicorn · llvmlite · Mergen
Виртуализация - это когда протектор выкидывает твои x86-инструкции и на их место ставит свой байткод плюс интерпретатор. В бинаре больше нет твоей арифметики, есть fetch-decode-execute поверх непонятной ISA. Ниже - как я снимаю такую защиту с Themida: сначала строю девирт-движок, который восстанавливает семантику в LLVM IR и доказывает эквивалентность, потом упираюсь в то, чем Themida и живёт, и наконец пробиваю её приёмом, который обходит всю анти-эму разом. Всё на своём крякми, всё воспроизводимо, IDA не нужна.
Модель угрозы: что прячет CISC-VM
Themida (Oreans) вешает на помеченный участок один из своих движков - CISC, FISH, TIGER - и разворачивает вокруг него SecureEngine: mutation-слой, анти-дамп, тайминговые и CPUID-проверки на песочницу. Виртуализованная функция превращается в vm_enter (сохранение контекста в VM-структуру и заведение VIP - виртуального указателя инструкций), диспетчерский цикл «прочитать опкод по VIP → индекс в таблицу хендлеров → прыгнуть» и набор хендлеров, каждый из которых руками пересобирает одну примитивную операцию из мешанины mov/xor/rol. Читать это как дизасм бессмысленно: ты читаешь не логику, а её эмулятор. Значит работаем не с инструкциями, а с семантикой - что код делает с данными, - и восстанавливаем её в форму, которую умеет чистить оптимизатор.
Подопытный
Никакого чужого софта - беру свою функцию и оборачиваю в VM_START/VM_END
из SDK. На x64 эти макросы разворачиваются в call VMStart/VMEnd - две
реперные точки, по которым SecureEngine и находит регион под виртуализацию. Известный вход,
известная логика, есть эталон для проверки.
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "ThemidaSDK.h"
__declspec(noinline) uint32_t transform(uint32_t key)
{
VM_START
uint32_t x = key ^ 0xA5A5A5A5u;
x = x * 0x1000193u + 0x9E3779B9u;
x ^= x >> 15;
VM_END
return x;
}
int main(int argc, char **argv)
{
printf("hello from gabdulla.dev\n");
uint32_t key = (argc > 1) ? (uint32_t)strtoul(argv[1], NULL, 0) : 0x1337u;
printf("transform(0x%08X) = 0x%08X\n", key, transform(key));
return 0;
} Сборка и адрес - без реверса
Собираю MSVC x64. Либа SecureEngine подтягивается через #pragma comment(lib), так
что руками линковать нечего. Адрес функции знать заранее - тривиально: он лежит в map-файле
компоновщика (transform → 0x1400010d0). Ни отладчика, ни IDA для этого не надо -
весь пайплайн дальше пляшет от одного адреса из своего же билда.
call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat"
set SDK=D:\.gabdulladev/blog/reversethemida\themida\ThemidaSDK
cl /nologo /O2 /MT /FAcs /Fahello.asm hello.c /I"%SDK%\Include\C" ^
/Fe:hello.exe /link /LIBPATH:"%SDK%\Lib\COFF" /MAP:hello.map
Виртуализация из консоли. Themida требует проект .tmd (собирается один раз в GUI:
input, движок виртуализации, output), после чего сам протект - одна команда:
"D:\.gabdulladev/blog/reversethemida\themida\Themida64.exe" /protect "hello.tmd" Холодный дизасм: до и после
Минимальный дизассемблер на capstone: маплю образ по секциям, читаю байты по RVA. На чистом
бинаре по 0x10d0 - честная арифметика с реперами VMStart/VMEnd:
import sys
import pefile
from capstone import CS_ARCH_X86, CS_MODE_64, Cs
exe = sys.argv[1]
rva = int(sys.argv[2], 16) if len(sys.argv) > 2 else 0x10d0
pe = pefile.PE(exe, fast_load=True)
va = pe.OPTIONAL_HEADER.ImageBase + rva
data = pe.get_memory_mapped_image()[rva:rva + 0x100]
md = Cs(CS_ARCH_X86, CS_MODE_64)
for insn in md.disasm(data, va):
raw = " ".join(f"{b:02x}" for b in insn.bytes)
print(f"{insn.address:016x} {raw} {insn.mnemonic} {insn.op_str}")
if insn.mnemonic == "ret":
break > python disasm.py hello.exe
00000001400010d0 40 53 push rbx
00000001400010d6 8b d9 mov ebx, ecx
00000001400010d8 ff 15 82 51 01 00 call [rip+0x15182] ; VMStart
00000001400010de 81 f3 a5 a5 a5 a5 xor ebx, 0xa5a5a5a5
00000001400010e4 69 db 93 01 00 01 imul ebx, ebx, 0x1000193
00000001400010ea 81 eb 47 86 c8 61 sub ebx, 0x61c88647
00000001400010f0 ff 15 62 51 01 00 call [rip+0x15162] ; VMEnd
00000001400010f6 8b c3 mov eax, ebx
00000001400010f8 c1 e8 0f shr eax, 0xf
00000001400010fb 33 c3 xor eax, ebx
0000000140001102 c3 ret
А после Themida по тому же адресу линейный дизасм ловит классику анти-дизасм: перекрытые декоды,
мусорный cld, opaque-переходы jg/jnp в никуда. Арифметики нет - есть
вход в VM, замаскированный под нечитаемый поток байт:
> python disasm.py hello_protected.exe
00000001400010d0 fc cld
00000001400010d1 b9 57 75 ab ee mov ecx, 0xeeab7557
00000001400010d6 7f 2b jg 0x140001103
00000001400010d8 48 3a 9c a1 0c 0c 30 3b cmp bl, [rcx+riz*4+0x3b300c0c]
00000001400010e0 7b af jnp 0x140001091 Девирт-движок: семантика → LLVM IR
Прежде чем воевать с Themida, соберу инструмент, который вообще умеет девиртить: берёт поток
инструкций, гонит по ним абстрактную интерпретацию (мини символьный движок - Quarkslab Triton
на Windows не встаёт, так что считаю сам), собирает выражение результата над входом
key, эмитит его в LLVM IR через llvmlite и отдаёт new-PM оптимизатору. Реперные
call - no-op, стек-пролог отбрасываю, остаётся чистая data-flow цепочка:
def sym_exec(insns):
regs = {"rcx": ("key",)}
for insn in insns:
mnem, ops = insn.mnemonic, insn.operands
if mnem in ("push", "pop", "call", "nop", "ret"):
continue
dst = canon(insn.reg_name(ops[0].reg))
cur = regs.get(dst, ("c", 0))
if mnem == "mov": regs[dst] = operand(ops[1])
elif mnem == "xor": regs[dst] = ("xor", cur, operand(ops[1]))
elif mnem == "imul": regs[dst] = ("mul", operand(ops[1]), operand(ops[2]))
elif mnem == "sub": regs[dst] = ("sub", cur, operand(ops[1]))
elif mnem == "shr": regs[dst] = ("lshr", cur, operand(ops[1])[1])
return regs["rax"]
def emit(node, b, key):
if node[0] == "key": return key
if node[0] == "c": return ir.Constant(i32, node[1])
if node[0] == "xor": return b.xor(emit(node[1], b, key), emit(node[2], b, key))
if node[0] == "mul": return b.mul(emit(node[1], b, key), emit(node[2], b, key))
if node[0] == "sub": return b.sub(emit(node[1], b, key), emit(node[2], b, key))
if node[0] == "lshr": return b.lshr(emit(node[1], b, key), ir.Constant(i32, node[2]))
Дальше instcombine + reassociate + gvn + sccp сворачивают выражение, GVN ловит
общий подвыраз (CSE - у нас x считается дважды), а MCJIT компилит результат и я
сверяю его с конкретной эмуляцией тех же байт в Unicorn на пачке входов. На чистой функции
девирт тотальный - IR доказанно эквивалентен коду. devirt.py.
> python devirt.py hello.exe
eax = ((((key ^ 0xA5A5A5A5) * 0x1000193) - 0x61C88647) >> 15)
^ (((key ^ 0xA5A5A5A5) * 0x1000193) - 0x61C88647)
define i32 @transform(i32 %key) {
%.3 = xor i32 %key, -1515870811
%.4 = mul i32 %.3, 16777619
%.5 = add i32 %.4, -1640531527
%.6 = lshr i32 %.5, 15
%.10 = xor i32 %.6, %.5
ret i32 %.10
}
key=0x00001337 jit=0xF41409A4 unicorn=0xF41409A4 ok
key=0xDEADBEEF jit=0x95FB44C3 unicorn=0x95FB44C3 ok
key=0xFFFFFFFF jit=0x3473DD80 unicorn=0x3473DD80 ok
verified
Теперь натравливаю тот же движок на защищённый файл по тому же адресу - и получаю показательный
ноль. Линейный проход цепляет пару мусорных инструкций, утыкается в opaque-переход и отдаёт
ret i32 0: по 0x10d0 у Themida не логика, а вход в VM, реальная
арифметика живёт за диспетчером, в хендлерах, к которым линейно не доехать:
> python devirt.py hello_protected.exe
0x1400010d0 cld
0x1400010d1 mov ecx, 0xeeab7557
0x1400010d6 jg 0x140001103
0x1400010d8 cmp bl, [rcx+riz*4+0x3b300c0c]
0x1400010e0 jnp 0x140001091
eax = 0x0
define i32 @transform(i32 %key) { ret i32 0 } LOOPE-трюк: почему статические лифтеры дохнут
Промышленный лифтер Mergen
(x86-64 → LLVM IR) по холодному файлу спотыкается ровно об это: not implemented: LOOPE.
Вход Themida намеренно набит анти-дизасм: opaque-предикаты, всегда идущие в одну сторону, декодовое
перекрытие и редкие опкоды на мёртвых ветках. Статике и наивному лифту тут плохо. А эмуляции - нет:
Unicorn исполняет LOOPE сам и разрешает ветку по настоящим флагам. Никакого спец-кода
под трюк - просто идёшь по факту исполнения и лениво подставляешь неразмапленное:
def on_code(u, addr, size, _):
seen[addr] += 1
insn = next(md.disasm(u.mem_read(addr, size), addr), None)
trace.append((addr, insn))
if seen[addr] == 40:
u.emu_stop()
def on_unmapped(u, access, addr, size, value, _):
u.mem_map(addr & ~0xFFF, 0x1000)
return True
И трейс действительно проходит там, где Mergen умер - jg, ja, сам
LOOPE исполняются, поток идёт дальше. Но тут вылезает настоящая причина сложности
Themida: на холодном образе рантайм SecureEngine не отработал, VM не развёрнута, поэтому
opaque-предикат, который в живом процессе всегда идёт «правильной» веткой, здесь сваливает в декой -
мёртвый путь с мусором (cli/in/fdiv) - и эмуляция быстро летит в unmapped:
> python themida_trace.py hello_protected.exe
0x1400010d0 cld
0x1400010d6 jg 0x140001103 ; opaque, Unicorn берёт живую ветку
0x140001103 or eax, [rdx+rcx*4]
0x140001108 ja 0x1400010b8
0x1400010b8 loope 0x14000108b ; тот самый LOOPE - исполняется
0x1400010ba add r9d, [r14-0x7f37d43a]
0x1400010c1 cmpsd / cli / in / fdiv ...
status: UcError: UC_ERR_READ_UNMAPPED Вывод, который стоит зафиксировать: LOOPE - не блокер, эмуляция его переваривает. Блокер - рантайм-состояние. Чтобы идти по настоящему пути до тела хендлера, код надо получить уже расшифрованным. Эмулировать стаб целиком (Qiling поверх того же Unicorn) - можно, но Themida специально фингерпринтит эмулятор через тайминги и CPUID и уходит в декой. Значит зайдём с той стороны, где фингерпринтить нечего.
Пробитие: дамп живого процесса → Mergen
Ключевая мысль: не воевать с анти-эму, а не использовать эмулятор. Запускаю
настоящий процесс - живая Windows честно разворачивает VM в память (детектить нечего, это не
песочница), а я снимаю образ через ReadProcessMemory. Крякми прогоняет
transform и паркуется на getchar() - ловлю этот момент по маркеру в
stdout:
proc = subprocess.Popen([exe], stdin=PIPE, stdout=PIPE, text=True)
for line in proc.stdout:
if line.startswith("RESULT"):
break
handle, base, size = main_module(proc.pid)
image, pages = read_image(handle, base, size)
Разница дня и ночи по одному адресу 0x10e0. В холодном файле - анти-дизасм каша, в
живом дампе - честный jmp 0x1405228a3 прямо во вход VM:
; ХОЛОДНЫЙ файл, 0x10e0: ; ЖИВОЙ дамп, тот же 0x10e0:
cld push rbx
mov ecx, 0xeeab7557 sub rsp, 0x20
jg 0x140001103 mov ebx, ecx
cmp bl, [rcx+riz*4+0x3b300c0c] jmp 0x1405228a3
jnp 0x140001091
Иду по этому адресу в дампе и вижу настоящий vm_enter: pushfq, VM-ключ на
стек, сохранение контекста в VM-структуру, movabs rcx, 0x10f - это VIP. Дальше
обфусцированная стек-машина на push/pop [rsp] с арифметикой над указателем - тело
диспетчера, которого на холодном файле не существовало:
> python dumpdis.py live_dump.bin 0x1405228a3
0x1405228a3 pushfq
0x1405228a4 push 0x2ffd10c3 ; VM key
0x1405228a9 mov [rsp], rdx ; save context
0x1405228b6 movabs rcx, 0x10f ; VIP
0x1405228c6 push 0x3fbe92f7
0x1405228df sub [rsp], 0x56eae154
Осталось пересобрать снятый образ в валидный PE-на-диске (raw-смещения секций := их VA,
FileAlignment := SectionAlignment - и файл становится равен образу памяти) и скормить Mergen уже
расшифрованный код. Он подхватывает 413 функций из .pdata, проходит
сквозь jmp в VM, лифтит десятки тысяч инструкций диспетчера без единого
LOOPE-стопа, и в оптимизированном output.ll проступает наш
transform:
> lifter.exe hello2_dumped.exe 0x1400010e0
[outline] .pdata: 413 function starts added
... лифтит десятки тысяч инструкций VM, LOOPE-стены нет ...
writing complete -> output.ll
%a = xor i32 %key, -1515870811 ; key ^ 0xA5A5A5A5
%b = mul i32 %a, 16777619 ; * 0x1000193
%c = add i32 %b, -1640531527 ; + 0x9E3779B9
%d = lshr i32 %c, 15 ; >> 15
%e = xor i32 %d, %c ; ^ (x >> 15)
Пять операций - xor 0xA5A5A5A5, * 0x1000193, + 0x9E3779B9,
>> 15, xor - ровно исходник до Themida, вытащенный из-под VM.
Виртуализация снята. Мораль злая в своей простоте: Themida затачивают против
эмуляторов и отладчиков, а её кладёт обычный ReadProcessMemory по распакованному
процессу - вся VM уже на ладони, дальше дело техники. Скрипты пробития:
dump.py, reconstruct.py,
dumpdis.py.
Что осталось
output.ll ещё несёт шум VM-контекста - лишние store в структуру
состояния между полезными операциями. До тех же пяти строк автоматически его дожмёт
VTIL или мой
devirt.py как пост-пасс (DSE плюс склейка data-flow). Второй вектор - снимать дамп
во время исполнения хендлера, чтобы ловить и то, что Themida дешифрует on-demand и тут же
затирает. И часть 2 - тот же конвейер (дамп → reconstruct → Mergen) против VMProtect: стек-машина
с RISC-хендлерами и своим p-code, посмотрим, чей движок хитрее.
Инструменты
- Unicorn Engine - эмулятор CPU на базе QEMU
- llvmlite - LLVM IR, оптимизатор и MCJIT из Python
- Mergen - лифтинг x86-64 в LLVM IR
- VTIL - IR под девиртуализацию
- Tim Blazytko - синтез семантики VM-хендлеров
Всё на собственном коде и лицензионной копии Themida, в исследовательских целях: понимать защиту, чтобы делать её лучше. Не инструкция по «крякам» чужого софта.
← вернуться к статьям