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:

The VM’s interpretation loop works as follows:
- Push the real execution context onto the stack and initialize the environment the VM needs (for example, initialize VIP and VSP).
- Fetch the next virtual instruction’s opcode via VIP, and jump to the corresponding VM handler.
- Execute the VM handler and update the virtual instruction pointer.
- Check the virtual stack to make sure there is enough space.
- Repeat steps 2–4 until all virtual bytecode has executed.
- 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
RSIregister. - 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 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, 0FFFFFFFFhequalsadd 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:
- Click the
Ebutton (orAlt+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. - Click Options → Options (
Alt+O), select the Run Trace page, and tick “Remember memory”. - Click Trace → Trace into (
Ctrl+F11) to start recording the code flow. - 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:
- Starting before the first first-level instruction, record the virtual-stack-top relative offset as 0 (or some value).
- Execute a first-level instruction and update the relative offset according to its stack-pointer effect.
- 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:
-
// 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 instructionThis chunk reduces to:
MOV R5, [0x00403000]; value is 0xDEADBEEF -
// 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 instructionThis chunk reduces to:
ADDDW R13, R5, 0x12345678; R13 = R5 + 0x12345678 -
// 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 instructionThis 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 -
// 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 instructionThis 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:
- A beginner tutorial on analyzing the VMProtect 1.81 Demo.
- https://blog.back.engineering/17/05/2021
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