VMProtect 1.81 Under the Hood: VM Structure and De-virtualization

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Preface

This article walks through the fundamental structure of VMProtect by analyzing a program protected with the VMProtect 1.81 Demo, and attempts to work out what the virtualized code does and how to de-virtualize it.

Virtual machine structure

Virtual-machine (VM) protection converts a program’s executable code into custom intermediate opcodes (one or more bytes each) to protect the original program from reverse engineering and tampering. These opcodes are interpreted and executed by a virtual machine, which reproduces the original behavior. To reverse a protected program you then have to reverse the whole VM structure, understand what it does, and additionally analyze the opcodes — making the whole reverse-engineering effort very tedious. A general VM structure looks like this:

General VM structure

The VM’s interpretation loop works as follows:

  1. Push the real execution context onto the stack and initialize the environment the VM needs (for example, initialize VIP and VSP).
  2. Fetch the next virtual instruction’s opcode via VIP, and jump to the corresponding VM handler.
  3. Execute the VM handler and update the virtual instruction pointer.
  4. Check the virtual stack to make sure there is enough space.
  5. Repeat steps 2–4 until all virtual bytecode has executed.
  6. Restore the real context and exit the VM.

The VM-related data structures:

  • VIP — virtual instruction pointer, the analog of x86-64’s RIP. Points to the address of the next virtual instruction to execute. Usually kept in the RSI register.
  • VSP — virtual stack pointer, the analog of x86-64’s RSP. Points to the top of the virtual stack. Usually kept in RBP.
  • VM Stack — the virtual stack. A stack-based VM performs every instruction through the virtual stack; an add, for example, requires pushing both operands first and then operating on the stack.
  • VM Registers / VM Context — the virtual register file, or the VM’s context. Usually pointed to by RDI.
  • VM Instruction — a virtual instruction, aka virtual bytecode: the bytes the VM interprets and executes. Each is one or more bytes.
  • VM Opcode — the opcode of a virtual instruction; the first byte of each virtual instruction.
  • IMM — immediates within a virtual instruction. They may be constants, virtual-register indices, etc.
  • VM Handler — a snippet of real code that implements one virtual operation (one ADD opcode maps to one handler). The handler for an opcode is chosen by that opcode and interprets its specific semantics.
  • VM Handler Table — a table of offsets to all VM handlers.
  • VM Opcode Table — a table of virtual-instruction bytecode.

Case study

The sample in this case comes from a beginner tutorial on analyzing the VMProtect 1.81 Demo. The sample’s assembly is just adding and subtracting the same constant:

.text:00401000 sub_401000      proc near               ; CODE XREF: start↓p
.text:00401000                 mov     eax, dword_403000; value is 0deadbeefh
.text:00401005                 add     eax, 12345678h
.text:0040100A                 sub     eax, 12345678h
.text:0040100F                 mov     dword_403000, eax
.text:00401014                 retn
.text:00401014 sub_401000      endp

After VMProtect protection it becomes:

.text:00401000 ; void sub_401000()
.text:00401000 sub_401000      proc near               ; CODE XREF: start↓p
.text:00401000                 push    offset byte_404781
.text:00401005                 call    sub_40472C
.text:0040100A
.text:0040100A loc_40100A:                             ; CODE XREF: sub_40472C+2B↓j
.text:0040100A                                         ; DATA XREF: .vmp0:jpt_404757↓o
.text:0040100A                 mov     esi, [ebp+0]    ; jumptable 00404757 cases 126,128,146,149,246,255
.text:0040100D                 add     ebp, 4
.text:00401010                 jmp     loc_40474E
.text:00401010 sub_401000      end

There’s a classic VMProtect entry signature here: push + call. The push pushes the address of the VM opcode table, then call sub_40472C enters the VM entry. byte_404781:

.vmp0:00404781 byte_404781     db 0Eh, 0E8h, 81h       ; DATA XREF: sub_401000↑o
.vmp0:00404784                 dd 5C765DA9h, 3E0A1A1Eh, 3A36262Eh, 602122Ah, 3000E822h
.vmp0:00404798                 dd 16790040h, 34567869h, 1E7A1412h, 5678E836h, 97341234h
.vmp0:004047AC                 dd 5C066B4Dh, 1121973Eh, 4C3C0622h, 0EB161121h, 1E11F7EAh
.vmp0:004047C0                 dd 326B2020h, 78081577h, 36325C32h, 30006904h, 0ED0040h
.vmp0:004047D4                 dd 4382010h, 8342C24h, 0E1001Ch, 8 dup(0)
.vmp0:00404800                 dd 200h dup(?)

VM Entry analysis

.vmp0:0040472C                 push    esi
.vmp0:0040472D                 push    edi
.vmp0:0040472E                 push    esp
.vmp0:0040472F                 push    ebx
.vmp0:00404730                 push    eax
.vmp0:00404731                 push    edx
.vmp0:00404732                 push    ebp
.vmp0:00404733                 pushf
.vmp0:00404734                 push    ecx

The first classic signature: when VMProtect builds the VM environment, it pushes the real register values onto the stack to save them.

.vmp0:00404735                 push    ds:dword_404649
.vmp0:0040473B                 push    0

These two instructions push the data to be operated on. This is a trait of stack-based VMs: operations happen on the stack. So this is the virtual stack.

.vmp0:00404740                 mov     esi, [esp+2Ch+arg_0] ;arg_0 = dword ptr  4

Initialize esi. Drawing a stack diagram shows that esi becomes byte_404781, the base of the VM opcode table.

.vmp0:00404744                 mov     ebp, esp
.vmp0:00404746                 sub     esp, 0C0h
.vmp0:0040474C                 mov     edi, esp

Initialize ebp to esp (the virtual stack top), then create a new stack frame used as the virtual registers; edi points to the virtual-register base.

.vmp0:0040474E loc_40474E:
.vmp0:0040474E                 add     esi, [ebp+0]        ;index of vm opcode

Next it pops the virtual-stack top — the offset of the virtual opcode — and adds the opcode-table base, so esi now points to the first virtual opcode to execute.

VM Dispatcher analysis

mov     al, [esi]        ;get vm opcode
movzx   eax, al
inc     esi             ;index + 1
jmp     ds:jpt_404757[eax*4] ; jmp to vm handler

Jump to the VM handler for the opcode. jpt_404757 is a function-address table holding the offsets of the VM handlers.

VM handler table

VM Handler analysis

ADD — addition

ADDB — add two bytes

loc_40476F:
mov     al, [ebp+0]
sub     ebp, 2
add     [ebp+4], al
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Adds the two bytes at the top of the virtual stack, stores the result into the latter’s slot, pushes the flags onto the real stack, then stores the top real-stack element (the flags) into the virtual-stack top.

The addition uses [src ebp+2] and [src ebp], not [src ebp+1] and [src ebp] — probably because the virtual stack is 2-byte aligned.

ADDW — add two words

loc_4045FC:
mov     ax, [ebp+0]
sub     ebp, 2
add     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Adds the two 2-byte values at the virtual-stack top. The virtual stack then rises two bytes; the first four bytes at the new top are the flags produced by the operation, and the next two bytes are the result value.

ADDDW — add two dwords

loc_404000:
mov     eax, [ebp+0]
add     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_404751

Adds the two dwords at the virtual-stack top. The virtual-stack top stays in place.

SHR — logical shift right

SHRB — logical shift right a byte

loc_404680:
mov     al, [ebp+0]
mov     cl, [ebp+2]
sub     ebp, 2
shr     al, cl
mov     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes two bytes from the virtual-stack top and shifts right. The result is zero-extended to a word (the result goes into al, but ax is pushed). The virtual stack rises two bytes.

SHRW — logical shift right a word

loc_40454E:
mov     ax, [ebp+0]
mov     cl, [ebp+2]
sub     ebp, 2
shr     ax, cl
mov     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes a word and a byte (the shift count) from the virtual stack and shifts right. The virtual stack rises two bytes.

SHRDW — logical shift right a dword

loc_404077:
mov     eax, [ebp+0]
mov     cl, [ebp+4]
sub     ebp, 2
shr     eax, cl
mov     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes a dword and a byte (the shift count) from the virtual-stack top and shifts right. The virtual stack rises two bytes.

SHRD — double-precision logical shift right

SHRDDW — double-precision shift right a dword

loc_404610:
mov     eax, [ebp+0]
mov     edx, [ebp+4]
mov     cl, [ebp+8]
add     ebp, 2
shrd    eax, edx, cl
mov     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_404751

Takes two dwords and a byte from the virtual-stack top, performs a double-precision shift right, and stores only the high part eax. The virtual stack shrinks two bytes.

SHL — logical shift left

SHLB — logical shift left a byte

loc_4045C0:
mov     al, [ebp+0]
mov     cl, [ebp+2]
sub     ebp, 2
shl     al, cl
mov     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes two bytes from the virtual stack and shifts left, zero-extending the result to a word (result in al, but ax is pushed). The virtual stack rises two bytes.

SHLW — logical shift left a word

loc_404698:
mov     ax, [ebp+0]
mov     cl, [ebp+2]
sub     ebp, 2
shl     ax, cl
mov     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes a word and a byte (shift count) from the virtual-stack top and shifts left. The virtual stack rises two bytes.

SHLDW — logical shift left a dword

loc_4046EF:
mov     eax, [ebp+0]
mov     cl, [ebp+4]
sub     ebp, 2
shl     eax, cl
mov     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes a dword and a byte (shift count) from the virtual-stack top and shifts left. The virtual stack rises two bytes.

SHLD — double-precision logical shift left

SHLDDW — double-precision shift left a dword

loc_4046BF:
mov     eax, [ebp+0]
mov     edx, [ebp+4]
mov     cl, [ebp+8]
add     ebp, 2
shld    eax, edx, cl
mov     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_404751

Takes two dwords and a byte from the virtual-stack top and performs a double-precision shift left. The virtual stack shrinks two bytes.

NAnd — NOT-then-AND

NAndB — NOT-then-AND two bytes

loc_404591:
mov     ax, [ebp+0]
mov     dx, [ebp+2]
not     al
not     dl
sub     ebp, 2
and     al, dl
mov     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

Takes two words from the virtual stack; actually the low byte of each is NOTed, then ANDed. The virtual stack rises two bytes.

NAndW — NOT-then-AND two words

loc_404041:
not     dword ptr [ebp+0]
mov     ax, [ebp+0]
sub     ebp, 2
and     [ebp+4], ax
pushf
pop     dword ptr [ebp+0]
jmp     loc_40400F

NOTs the dword at the virtual-stack top, then splits it into two words and ANDs them. The virtual stack rises two bytes.

NAndDW — NOT-then-AND two dwords

loc_40451A:
mov     eax, [ebp+0]
mov     edx, [ebp+4]
not     eax
not     edx
and     eax, edx
mov     [ebp+4], eax
pushf
pop     dword ptr [ebp+0]
jmp     loc_404751

Takes two dwords from the virtual-stack top, NOTs each, then ANDs them. The virtual-stack top stays in place.

PushConst — push an immediate from the virtual instruction

PushConstB — push a one-byte immediate

loc_4044E4:
movzx   eax, byte ptr [esi]
sub     ebp, 2
mov     [ebp+0], ax
add     esi, 1
jmp     loc_40400F

Takes the operand (one byte) from the virtual instruction, zero-extends it to a word, and pushes it. The virtual stack rises two bytes.

PushConstW — push a two-byte immediate

loc_40464D:
mov     ax, [esi]
lea     esi, [esi+2]
sub     ebp, 2
mov     [ebp+0], ax
jmp     loc_40400F

Takes the operand (two bytes) from the virtual instruction and pushes it. The virtual stack rises two bytes.

PushConstDW — push a four-byte immediate

loc_40462B:
mov     eax, [esi]
sub     ebp, 4
lea     esi, [esi+4]
mov     [ebp+0], eax
jmp     loc_40400F

Takes the operand (four bytes) from the virtual instruction and pushes it. The virtual stack rises four bytes.

PushConstCBDE — sign-extend a one-byte immediate to dword and push

loc_40453B:
mov     al, [esi]
cbw
cwde
sub     ebp, 4
lea     esi, [esi+1]
mov     [ebp+0], eax
jmp     loc_40400F

Takes the operand (one byte) from the virtual instruction, sign-extends it to a dword, and pushes it. The virtual stack rises four bytes.

PushConstCWDE — sign-extend a two-byte immediate to dword and push

loc_4044BE:
mov     ax, [esi]
cwde
add     esi, 2
sub     ebp, 4
mov     [ebp+0], eax
jmp     loc_40400F

Takes the operand (two bytes) from the virtual instruction, sign-extends it to a dword, and pushes it. The virtual stack rises four bytes.

PushReg — push the value of a given virtual register

PushRegB — push the byte value of a virtual register

loc_4044D0:
mov     al, [esi]
mov     al, [edi+eax]
sub     ebp, 2
sub     esi, 0FFFFFFFFh
mov     [ebp+0], ax
jmp     loc_40400F

Takes the operand (one byte) from the virtual instruction as a virtual-register index, reads the corresponding virtual register’s byte, and pushes it. The virtual stack rises two bytes.

sub esi, 0FFFFFFFFh equals add esi, 1.

PushRegW — push the word value of a virtual register

loc_4045E9:
mov     al, [esi]
inc     esi
mov     ax, [edi+eax]
sub     ebp, 2
mov     [ebp+0], ax
jmp     loc_40400F

Takes the operand (one byte) from the virtual instruction as a virtual-register index, reads the corresponding virtual register’s word, and pushes it. The virtual stack rises two bytes.

PushRegDW — push the dword value of a virtual register

loc_4045AF:
and     al, 3Ch
mov     edx, [edi+eax]
sub     ebp, 4
mov     [ebp+0], edx
jmp     loc_40400F

ANDs the virtual instruction’s opcode byte with 0x3C to form the virtual-register index, reads the corresponding dword, and pushes it. The virtual stack rises four bytes.

PopReg — pop the top of the stack into a virtual register

PopRegB — pop a byte into a virtual register

loc_404568:
mov     al, [esi]
mov     dx, [ebp+0]
sub     esi, 0FFFFFFFFh
add     ebp, 2
mov     [edi+eax], dl
jmp     loc_404751

Takes a one-byte operand from the virtual instruction as a virtual-register index, takes the two-byte value at the virtual-stack top, and stores its low byte into the register. The virtual stack shrinks two bytes.

PopRegW — pop a word into a virtual register

loc_4046DA:
mov     al, [esi]
add     esi, 1
mov     dx, [ebp+0]
add     ebp, 2
mov     [edi+eax], dx
jmp     loc_404751

Takes a one-byte operand from the virtual instruction as a virtual-register index, then takes the two-byte value at the virtual-stack top and stores it into the register. The virtual stack shrinks two bytes.

PopRegDW — pop a dword into a virtual register

loc_404058:
and     al, 3Ch
mov     edx, [ebp+0]
add     ebp, 4
mov     [edi+eax], edx
jmp     loc_404751

ANDs the virtual instruction’s opcode byte with 0x3C, takes the dword at the virtual-stack top, and stores it into the virtual register. The virtual stack shrinks four bytes.

PushVSP — push the virtual stack pointer

PushVSPW — push the low word of the virtual stack pointer

loc_40463B:
mov     eax, ebp
sub     ebp, 2
mov     [ebp+0], ax
jmp     loc_40400F

Pushes the virtual-stack pointer (low two bytes). The virtual stack rises two bytes.

PushVSPDW — push the virtual stack pointer (dword)

loc_4046B2:
mov     eax, ebp
sub     ebp, 4
mov     [ebp+0], eax
jmp     loc_40400F

Pushes the virtual-stack pointer (dword). The virtual stack rises four bytes.

PopVSP — load the virtual stack pointer from the top of the stack

PopVSPW — load a word into the virtual stack pointer

loc_404532:
mov     bp, [ebp+0]
jmp     loc_40400F

Loads the two-byte value at the virtual-stack top into the virtual stack pointer. The resulting stack top is unknown.

PopVSPDW — load a dword into the virtual stack pointer

loc_40457C:
mov     ebp, [ebp+0]
jmp     loc_40400F

Loads the dword at the virtual-stack top into the virtual stack pointer. The resulting stack top is unknown.

Store — write to memory

StoreB — write a byte to memory

loc_4044AE:
mov     eax, [ebp+0]
mov     dl, [ebp+4]
add     ebp, 6
mov     [eax], dl
jmp     loc_404751

Takes a dword (the address) and a byte (the value) from the virtual stack, and stores the byte at that address. The virtual stack shrinks six bytes.

Variant using the ss segment register:

loc_40475E:
mov     eax, [ebp+0]
mov     dl, [ebp+4]
add     ebp, 6
mov     ss:[eax], dl
jmp     loc_404751

StoreW — write a word to memory

loc_4044F6:
mov     eax, [ebp+0]
mov     dx, [ebp+4]
add     ebp, 6
mov     [eax], dx
jmp     loc_404751

Takes a dword (the address) and a word (the value) from the virtual stack, and stores the word at that address. The virtual stack shrinks six bytes.

Variant using the ss segment register:

loc_404706:
mov     eax, [ebp+0]
mov     dx, [ebp+4]
add     ebp, 6
mov     ss:[eax], dx
jmp     loc_404751

StoreDW — write a dword to memory

loc_404670:
mov     eax, [ebp+0]
mov     edx, [ebp+4]
add     ebp, 8
mov     [eax], edx
jmp     loc_404751

Takes two dwords from the virtual-stack top — the first as the address, the second as the value — and stores the value at that address. The virtual stack shrinks eight bytes.

Variant using the ss segment register:

loc_4045D8:
mov     eax, [ebp+0]
mov     edx, [ebp+4]
add     ebp, 8
mov     ss:[eax], edx
jmp     loc_404751

Load — read from memory

LoadB — load a byte from memory onto the virtual stack

loc_40465F:
mov     edx, [ebp+0]
add     ebp, 2
mov     al, [edx]
mov     [ebp+0], ax
jmp     loc_404751

Takes a dword from the virtual-stack top as the address, reads a byte from it, zero-extends it to a word, and pushes it. The virtual stack shrinks two bytes.

Variant using the ss segment register:

loc_40449C:
mov     edx, [ebp+0]
add     ebp, 2
mov     al, ss:[edx]
mov     [ebp+0], ax
jmp     loc_404751

LoadW — load a word from memory onto the virtual stack

loc_404508:
mov     eax, [ebp+0]
add     ebp, 2
mov     ax, [eax]
mov     [ebp+0], ax
jmp     loc_404751

Takes a dword from the virtual stack as the address, reads a word from it, and pushes it. The virtual stack shrinks two bytes.

Variant using the ss segment register:

loc_404719:
mov     eax, [ebp+0]
add     ebp, 2
mov     ax, ss:[eax]
mov     [ebp+0], ax
jmp     loc_404751

LoadDW — load a dword from memory onto the virtual stack

loc_404584:
mov     eax, [ebp+0]
mov     eax, [eax]
mov     [ebp+0], eax
jmp     loc_404751

Takes a dword from the virtual-stack top as the address, reads a dword from it, and pushes it. The virtual-stack top stays in place.

Variant using the ss segment register:

loc_404069:
mov     eax, [ebp+0]
mov     eax, ss:[eax]
mov     [ebp+0], eax
jmp     loc_404751

VM Exit analysis

loc_40408E:
mov     esp, ebp
pop     edx        ;overwritten
pop     ebx        ;overwritten
pop     ecx
popf
pop     ebp
pop     edx
pop     eax
pop     ebx
pop     esi
pop     edi
pop     esi
retn

Pops in the reverse order of the VM entry’s pushes to restore the native environment.

VM Check analysis

// is there enough stack space?
loc_40400F:
lea     eax, [edi+50h]
cmp     ebp, eax
ja      loc_404751
// adjust the stack space
mov     edx, esp
lea     ecx, [edi+40h]
sub     ecx, edx
lea     eax, [ebp-80h]
and     al, 0FCh
sub     eax, ecx
mov     esp, eax
pushf
push    esi
mov     esi, edx
lea     edi, [eax+ecx-40h]
push    edi
mov     edi, eax
cld
rep movsb
pop     edi
pop     esi
popf
jmp     loc_404751

First it checks whether there’s enough stack space; if so, it jumps to the VM dispatcher, otherwise it adjusts the stack space and then jumps to the dispatcher.

De-virtualizing the virtual code

Recovering first-level virtual instructions

I use OllyDbg’s trace feature to record the executed code flow:

  1. Click the E button (or Alt+E) to open the executable-module window, right-click the main module, and choose “Limit run trace protocol to selected module” — so only that module’s addresses are recorded and system modules are not.
  2. Click Options → Options (Alt+O), select the Run Trace page, and tick “Remember memory”.
  3. Click Trace → Trace into (Ctrl+F11) to start recording the code flow.
  4. Click the ... button to open the run-trace window, right-click and choose “Log to file”, and tick “Add available contents” and “Separate columns with tabs” when saving.

Through the VM handler analysis above we already know these instruction types. Using the address → first-level-instruction mapping, we can turn the code flow into a first-level virtual-instruction stream. But the operands of these first-level instructions differ across contexts, so we still need the registers and memory from the OllyDbg trace to refine them further. I’ll cut a corner here — this method is sample-specific, not general: after finding the first-level instruction address, pick the right instruction from that handler, extract the operands from the recorded context, and compute the result manually.

The code:

import re
from typing import Dict

class insnContext:
    insn: str
    addr: int
    memory: Dict[str, str]
    regs: Dict[str, str]

    def __init__(self, insn: str, addr: int, memory: Dict[str, str], regs: Dict[str, str]):
        self.insn = insn
        self.addr = addr
        self.memory = memory
        self.regs = regs

    def getRegValue(self, reg: str) -> str:
        return self.regs.get(reg)

    def getMemValue(self) -> str:
        key, value = next(iter(self.memory.items()))
        return value

def parseInsnStream(insn_stream: bytes) -> insnContext:
    insn_line = insn_stream.split(b'\t')
    addr = int(insn_line[1].decode(), 16)
    insn = insn_line[2].decode()
    # extract memory addresses and values with regex
    memory = dict(re.findall(r'\[([a-fA-F0-9]{8})]=([a-fA-F0-9]{8})', insn_line[3].decode()))
    # extract registers and values with regex
    regs = dict(re.findall(r'([a-zA-Z]{3})=([a-fA-F0-9]{8})', insn_line[4].decode()))
    context = insnContext(insn, addr, memory, regs)
    return context

vm_handlers = {
    0x40476F: "ADDB",
    0x4045FC: "ADDW",
    0x404000: "ADDDW",
    0x404680: "SHRB",
    0x40454E: "SHRW",
    0x404077: "SHRDW",
    0x404610: "SHRDDW",
    0X4045C0: "SHLB",
    0x404698: "SHLW",
    0x4046EF: "SHLDW",
    0x4046BF: "SHLDDW",
    0x404591: "NAndB",
    0x404041: "NAndW",
    0x40451A: "NAndDW",
    0x4044E4: "PushConstB",
    0x40464D: "PushConstW",
    0x40462B: "PushConstDW",
    0x40453B: "PushConstCBDE",
    0x4044BE: "PushConstCWDE",
    0x4044D0: "PushRegB",
    0x4045E9: "PushRegW",
    0x4045AF: "PushRegDW",
    0x404568: "PopRegB",
    0x4046DA: "PopRegW",
    0x404058: "PopRegDW",
    0x40463B: "PushVSPW",
    0x4046B2: "PushVSPDW",
    0x404532: "PopVSPW",
    0x40457C: "PopVSPDW",
    0x4044AE: "StoreB",
    0x40475E: "StoreB",
    0x4044F6: "StoreW",
    0x404706: "StoreW",
    0x404670: "StoreDW",
    0x4045D8: "StoreDW",
    0x40465F: "LoadB",
    0x40449C: "LoadB",
    0x404508: "LoadW",
    0x404719: "LoadW",
    0x404584: "LoadDW",
    0x404069: "LoadDW"
}
handler_keys = vm_handlers.keys()

with open(r".\trace.txt", "rb") as f:
    insn_stream = f.readlines()[1:-2]

step = 0
end = len(insn_stream)
while step < end:
    ctx = parseInsnStream(insn_stream[step])
    step += 1
    if ctx.addr in handler_keys:
        vm_insn_info = ''
        mnemonic = vm_handlers.get(ctx.addr)
        if mnemonic == "PushRegDW":
            ctx = parseInsnStream(insn_stream[step + 2])
            reg_value = ctx.getRegValue('EDX')
            reg_index = int(ctx.getRegValue('EAX'), 16) / 4 # 32-bit system
            vm_insn_info = "PushRegDW R%d;      R%d = 0x%s" % (reg_index, reg_index, reg_value)
        elif mnemonic == "PopRegDW":
            ctx = parseInsnStream(insn_stream[step + 2])
            reg_value = ctx.getRegValue('EDX')
            reg_index = int(ctx.getRegValue('EAX'), 16) / 4
            vm_insn_info = "PopRegDW R%d;       [ebp] = 0x%s" % (reg_index, reg_value)
        elif mnemonic == "PushConstDW":
            ctx = parseInsnStream(insn_stream[step + 2])
            imm = ctx.getRegValue("EAX")
            vm_insn_info = "PushConstDW 0x%s;" % imm
        elif mnemonic == "PushConstCWDE":
            ctx = parseInsnStream(insn_stream[step + 3])
            imm = ctx.getRegValue("EAX")
            vm_insn_info = "PushConstCWDE 0x%s;" % imm
        elif mnemonic == "ADDDW":
            ctx = parseInsnStream(insn_stream[step])
            ebp_0 = ctx.getRegValue("EAX")
            ebp_4 = ctx.getMemValue()
            result = hex(int(ebp_4, 16) + int(ebp_0, 16))[2:].zfill(8).upper()
            vm_insn_info = "ADDDW [ebp+4], [ebp+0];     [ebp+4] = 0x%s, [ebp] = 0x%s, [ebp+4]+[ebp] = 0x%s" % (ebp_4, ebp_0, result)
        elif mnemonic == "LoadDW":
            ctx = parseInsnStream(insn_stream[step])
            mem_addr = ctx.getRegValue("EAX")
            mem_value = ctx.getMemValue()
            vm_insn_info = "LoadDW [[ebp]];     [ebp] = 0x%s, [[ebp]] = 0x%s" % (mem_addr, mem_value)
        elif mnemonic == "StoreDW":
            ctx = parseInsnStream(insn_stream[step + 2])
            value = ctx.getRegValue("EDX")
            mem_addr = ctx.getRegValue("EAX")
            vm_insn_info = "StoreDW [[ebp+0]], [ebp+4];     [ebp] = 0x%s, [ebp+4] = 0x%s" % (mem_addr, value)
        elif mnemonic == "NAndDW":
            ctx = parseInsnStream(insn_stream[step])
            ebp_0 = ctx.getRegValue("EAX")
            ebp_4 = ctx.getMemValue()
            result = hex(((~int(ebp_4, 16)) & (~int(ebp_0, 16)) & 0xffffffff))[2:].zfill(8).upper()
            vm_insn_info = "NAndDW [ebp+4], [ebp+0];     [ebp] = 0x%s, [ebp+4] = 0x%s, ~[ebp+4] & ~[ebp]=0x%s" % (ebp_0, ebp_4, result)
        elif mnemonic == "PushVSPDW":
            ebp = ctx.getRegValue('EBP')
            vm_insn_info = 'PushVSPDW;      EBP = %s' % ebp
        else:
            print("unknow vm insntruction at address %s, vm_insn is %s" % (hex(ctx.addr).upper(), mnemonic))
            continue
        print(vm_insn_info)

The resulting first-level virtual instructions:

PopRegDW R3;       [ebp] = 0x00000000
PushConstDW 0x765DA981;
ADDDW [ebp+4], [ebp+0];     [ebp+4] = 0x00000000, [ebp] = 0x765DA981, [ebp+4]+[ebp] = 0x765da981
PopRegDW R7;       [ebp] = 0x00000206
PopRegDW R6;       [ebp] = 0x765DA981
PopRegDW R2;       [ebp] = 0x00401015
PopRegDW R15;       [ebp] = 0x00000246
PopRegDW R11;       [ebp] = 0x0019FF84
PopRegDW R9;       [ebp] = 0x00401015
PopRegDW R13;       [ebp] = 0x0019FFCC
PopRegDW R14;       [ebp] = 0x003F9000
PopRegDW R10;       [ebp] = 0x0019FF64
PopRegDW R4;       [ebp] = 0x00401015
PopRegDW R0;       [ebp] = 0x00401015
PopRegDW R1;       [ebp] = 0x0040100A
PopRegDW R8;       [ebp] = 0x00404781
PushConstDW 0x00403000;
LoadDW [[ebp]];     [ebp] = 0x00403000, [[ebp]] = 0xDEADBEEF
PopRegDW R5;       [ebp] = 0xDEADBEEF
PushConstDW 0x12345678;
PushRegDW R5;      R5 = 0xDEADBEEF
ADDDW [ebp+4], [ebp+0];     [ebp+4] = 0x12345678, [ebp] = 0xDEADBEEF, [ebp+4]+[ebp] = 0xF0E21567
PopRegDW R7;       [ebp] = 0x00000292
PopRegDW R13;       [ebp] = 0xF0E21567
PushConstDW 0x12345678;
PushRegDW R13;      R13 = 0xF0E21567
PushVSPDW;      EBP = 0019FF6C
LoadDW [[ebp]];     [ebp] = 0x0019FF6C, [[ebp]] = 0xF0E21567
NAndDW [ebp+4], [ebp+0];     [ebp] = 0xF0E21567, [ebp+4] = 0xF0E21567, ~[ebp+4] & ~[ebp]=0x0F1DEA98
PopRegDW R1;       [ebp] = 0x00000202
ADDDW [ebp+4], [ebp+0];     [ebp+4] = 0x12345678, [ebp] = 0x0F1DEA98, [ebp+4]+[ebp] = 0x21524110
PopRegDW R15;       [ebp] = 0x00000212
PushVSPDW;      EBP = 0019FF70
LoadDW [[ebp]];     [ebp] = 0x0019FF70, [[ebp]] = 0x21524110
NAndDW [ebp+4], [ebp+0];     [ebp] = 0x21524110, [ebp+4] = 0x21524110, ~[ebp+4] & ~[ebp]=0xDEADBEEF
PopRegDW R8;       [ebp] = 0x00000282
PopRegDW R1;       [ebp] = 0xDEADBEEF
PushRegDW R15;      R15 = 0x00000212
PushVSPDW;      EBP = 0019FF70
LoadDW [[ebp]];     [ebp] = 0x0019FF70, [[ebp]] = 0x00000212
NAndDW [ebp+4], [ebp+0];     [ebp] = 0x00000212, [ebp+4] = 0x00000212, ~[ebp+4] & ~[ebp]=0xFFFFFDED;
PopRegDW R5;       [ebp] = 0x00000286
PushConstCWDE 0xFFFFF7EA;
NAndDW [ebp+4], [ebp+0];     [ebp] = 0xFFFFF7EA, [ebp+4] = 0xFFFFFDED, ~[ebp+4] & ~[ebp]=0x00000010;
PopRegDW R7;       [ebp] = 0x00000202
PushRegDW R8;      R8 = 0x00000282
PushRegDW R8;      R8 = 0x00000282
NAndDW [ebp+4], [ebp+0];     [ebp] = 0x00000282, [ebp+4] = 0x00000282, ~[ebp+4] & ~[ebp]=0xFFFFFD7D
PopRegDW R12;       [ebp] = 0x00000286
PushConstCWDE 0x00000815;
NAndDW [ebp+4], [ebp+0];     [ebp] = 0x00000815, [ebp+4] = 0xFFFFFD7D, ~[ebp+4] & ~[ebp]=0x00000282
PopRegDW R12;       [ebp] = 0x00000206
ADDDW [ebp+4], [ebp+0];     [ebp+4] = 0x00000010, [ebp] = 0x00000282, [ebp+4]+[ebp] = 0x00000292
PopRegDW R12;       [ebp] = 0x00000202
PopRegDW R13;       [ebp] = 0x00000292
PushRegDW R1;      R1 = 0xDEADBEEF
PushConstDW 0x00403000;
StoreDW [[ebp+0]], [ebp+4];     [ebp] = 0x00403000, [ebp+4] = 0xDEADBEEF
PushRegDW R0;      R0 = 0x00401015          R0  = esi
PushRegDW R4;      R4 = 0x00401015          R4  = edi
PushRegDW R8;      R8 = 0x00000282          R8  = vm eflag
PushRegDW R14;      R14 = 0x003F9000        R14 = ebx
PushRegDW R1;      R1 = 0xDEADBEEF
PushRegDW R9;      R9 = 0x00401015          R9  = edx
PushRegDW R11;      R11 = 0x0019FF84        R11 = ebp
PushRegDW R13;      R13 = 0x00000292
PushRegDW R2;      R2 = 0x00401015          R2  = ecx
PushRegDW R7;      R7 = 0x00000202
PushRegDW R0;      R0 = 0x00401015          R0  = esi

Recovering second-level virtual instructions

The method for turning first-level into second-level instructions relies on virtual-stack balance: after a second-level instruction executes, the virtual stack is balanced. The steps:

  1. Starting before the first first-level instruction, record the virtual-stack-top relative offset as 0 (or some value).
  2. Execute a first-level instruction and update the relative offset according to its stack-pointer effect.
  3. When the relative offset returns to 0 (or its original value), the first-level instructions executed so far correspond to one second-level instruction.

Notes:

  • Consecutive pops and consecutive pushes are the save/restore of the virtual-register context.
  • Virtual-stack balance cannot end with a first-level push.
  • Virtual-stack balance cannot end with a first-level arithmetic instruction.

For the first-level sample above, work backward from the end: the trailing consecutive PushReg instructions are virtual-register pushes used to restore the real context later. So the first-level chunk is:

PushRegDW R0;        R0  = esi
PushRegDW R4;        R4  = edi
PushRegDW R8;
PushRegDW R14;       R14 = ebx
PushRegDW R1;
PushRegDW R9;        R9  = edx
PushRegDW R11;       R11 = ebp
PushRegDW R13;
PushRegDW R2;        R2  = ecx
PushRegDW R7;
PushRegDW R0;        R0  = esi

So going forward, we need to find the same number of consecutive PopReg instructions — the corresponding first-level chunk:

PopRegDW R2;        R2  = ecx
PopRegDW R15;       R15 = eflag
PopRegDW R11;       R11 = ebp
PopRegDW R9;        R9  = edx
PopRegDW R13;       R13 = eax
PopRegDW R14;       R14 = ebx
PopRegDW R10;       R10 = esp
PopRegDW R4;        R4  = edi
PopRegDW R0;        R0  = esi
PopRegDW R1;        R1  = return address of call sub_40472C
PopRegDW R8;        R8  = byte_404781, i.e. the VM opcode table

The mapping between virtual and real registers is derived from the VM entry code.

Once we have the virtual-stack-to-virtual-register mapping, we can split the first-level stream into second-level instructions using stack balance. A few example chunks:

  1. // record the virtual-stack offset as 0 here
    PushConstDW 0x00403000;            -4
    LoadDW [[ebp]];                    -4            [ebp] = 0x00403000, [[ebp]] = 0xDEADBEEF
    PopRegDW R5;                    0            [ebp] = 0xDEADBEEF
    // offset is 0 again; the chunk above is one second-level instruction

    This chunk reduces to:

    MOV R5, [0x00403000]; value is 0xDEADBEEF
  2. // record the virtual-stack offset as 0 here
    PushConstDW 0x12345678;        -4
    PushRegDW R5;                  -8            R5 = 0xDEADBEEF
    ADDDW [ebp+4], [ebp+0];     -8            [ebp+4] = 0x12345678, [ebp] = 0xDEADBEEF, [ebp+4]+[ebp] = 0xF0E21567
    PopRegDW R7;                   -4            [ebp] = 0x00000292
    PopRegDW R13;               0            [ebp] = 0xF0E21567
    // offset is 0 again; the chunk above is one second-level instruction

    This chunk reduces to:

    ADDDW R13, R5, 0x12345678;    R13 = R5 + 0x12345678
  3. // record the virtual-stack offset as 0 here
    PushConstDW 0x12345678;        -4
    
    PushRegDW R13;              -8        R13 = 0xF0E21567
    PushVSPDW;                  -12        EBP = 0019FF6C
    LoadDW [[ebp]];             -12        [ebp] = 0x0019FF6C, [[ebp]] = 0xF0E21567
    NAndDW [ebp+4], [ebp+0];    -12        [ebp] = 0xF0E21567, [ebp+4] = 0xF0E21567, ~[ebp+4] & ~[ebp]=0x0F1DEA98
    PopRegDW R1;                   -8        [ebp] = 0x00000202
    
    ADDDW [ebp+4], [ebp+0];     -8        [ebp+4] = 0x12345678, [ebp] = 0x0F1DEA98, [ebp+4]+[ebp] = 0x21524110
    PopRegDW R15;               -4        [ebp] = 0x00000212
    
    PushVSPDW;                  -8        EBP = 0019FF70
    LoadDW [[ebp]];             -8        [ebp] = 0x0019FF70, [[ebp]] = 0x21524110
    NAndDW [ebp+4], [ebp+0];    -8        [ebp] = 0x21524110, [ebp+4] = 0x21524110, ~[ebp+4] & ~[ebp]=0xDEADBEEF
    PopRegDW R8;                   -4        [ebp] = 0x00000282
    PopRegDW R1;                   0        [ebp] = 0xDEADBEEF
    // offset is 0 again; the chunk above is one second-level instruction

    This chunk reduces to:

    mov R1, ~(~R13 + 0x12345678);

    For a signed integer x, two’s complement gives −x = ∼x + 1, and therefore ∼x = −(x + 1). Simplifying the above with this yields:

    sub R1, R13, 0x12345678;        R13 - 0x12345678
  4. // record the virtual-stack offset as 0 here
        // ~R15        the eflag produced by ADDDW
    PushRegDW R15;              -4        R15 = 0x00000212;
    PushVSPDW;                  -8        EBP = 0019FF70
    LoadDW [[ebp]];             -8        [ebp] = 0x0019FF70, [[ebp]] = 0x00000212
    NAndDW [ebp+4], [ebp+0];    -8        [ebp] = 0x00000212, [ebp+4] = 0x00000212, ~[ebp+4] & ~[ebp]=0xFFFFFDED;
    PopRegDW R5;                   -4        [ebp] = 0x00000286
        // ~0xFFFFF7EA & R15, result 0x00000010, AF flag is 1
    PushConstCWDE 0xFFFFF7EA;    -8
    NAndDW [ebp+4], [ebp+0];    -8        [ebp] = 0xFFFFF7EA, [ebp+4] = 0xFFFFFDED, ~[ebp+4] & ~[ebp]=0x00000010;
    PopRegDW R7;                   -4        [ebp] = 0x00000202
        // ~R8    the eflag produced by NAndDW
    PushRegDW R8;                  -8        R8 = 0x00000282
    PushRegDW R8;                  -12        R8 = 0x00000282
    NAndDW [ebp+4], [ebp+0];    -12        [ebp] = 0x00000282, [ebp+4] = 0x00000282, ~[ebp+4] & ~[ebp]=0xFFFFFD7D
    PopRegDW R12;               -8        [ebp] = 0x00000286
        // ~0x00000815 & R8
    PushConstCWDE 0x00000815;    -12
    NAndDW [ebp+4], [ebp+0];    -12        [ebp] = 0x00000815, [ebp+4] = 0xFFFFFD7D, ~[ebp+4] & ~[ebp]=0x00000282
    PopRegDW R12;               -8        [ebp] = 0x00000206
        // (~0xFFFFF7EA & R15) + (~0x00000815 & R8)    ~0xFFFFF7EA = 0x00000815    merge two eflags
    ADDDW [ebp+4], [ebp+0];     -8        [ebp+4] = 0x00000010, [ebp] = 0x00000282, [ebp+4]+[ebp] = 0x00000292
    PopRegDW R12;               -4        [ebp] = 0x00000202
    PopRegDW R13;               0        [ebp] = 0x00000292
    // offset is 0 again; the chunk above is one second-level instruction

    This part performs complex flag computation, but its result is never used later — it’s junk/obfuscating code. Flag computation is usually related to conditional jumps: extract a flag bit (e.g. ZF) via logic operations, compute the target from it, then perform the conditional jump.

As for the very first first-level chunk, its virtual stack doesn’t balance — because the VM entry pushed two extra values when saving the real environment; adding those two pushes balances it:

//0
push    ds:dword_404649;                 -4
push    0;                              -8

PopRegDW R3;                            -4
PushConstDW 0x765DA981;                  -8
ADDDW [ebp+4], [ebp+0];                  -8
PopRegDW R7;                            -4
PopRegDW R6;                            0

But the result and flags of that ADDDW are never used later, so this chunk is also junk.

After removing the junk code and the virtual/real register mapping (the pop/push instructions, which mainly track real registers across multiple VMs), the recovered second-level instructions are:

mov R5, [0x00403000];            value is 0xDEADBEEF
ADDDW R13, R5, 0x12345678;      R13 = R5 + 0x12345678
sub R1, R13, 0x12345678;        R1 = R13 - 0x12345678
mov [0x00403000], R1;           store the value back

At this point the second-level instructions are essentially identical to the source instructions — the behavior is the same.

Summary

To de-virtualize virtualized code, the first priority is to identify the VM’s data structures — the virtual stack pointer, the virtual bytecode pointer, the virtual bytecode table, and so on — and then manually analyze what the VM handlers do. VM handler analysis can also be aided by AI: tell it the VM’s data structures so its answers are more precise. Once you understand the VM structure and its data structures, you can try to extract structural signatures (e.g. the dispatcher’s use of the handler table) and then automate the identification. After that, use emulation to obtain the full code-flow trace and recover the first-level virtual-instruction stream.

To reduce first-level instructions to second-level instructions, use the virtual-stack balance property to split the first-level stream into chunks, each of which corresponds to one or more second-level instructions (complex logic can be simplified). A rule of thumb I’ve drawn — corrections welcome:

In a stack-based VM, a complete second-level instruction first pushes its operands, operates on the stack, then pops the flags or the result. So a push marks the start of a second-level instruction, and a pop marks its end. But note: the first pop after an arithmetic instruction pops the flags; if there’s no second pop, the result stays on the virtual stack and may still feed subsequent instructions, so you must continue the stack-balance analysis (except for test/cmp, which only care about the flags).

Both the native instructions from emulation and the recovered first-level instructions may contain dead code. You can use live-variable analysis to identify and delete it, simplifying the code further.


References:

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch