К содержимому
gabdulla.dev
Караганда, Казахстан Telegram
← все статьи Reverse Engineering · Часть 1

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, посмотрим, чей движок хитрее.

Инструменты

Всё на собственном коде и лицензионной копии Themida, в исследовательских целях: понимать защиту, чтобы делать её лучше. Не инструкция по «крякам» чужого софта.

← вернуться к статьям