Analyzing the VMProtect 2.12.3 Virtual Machine: Handler Flow and Virtual-Register Mapping
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.
This continues a series on VMProtect’s virtual machine. 2.12.3 is the last widely available 2.x build written in Delphi (3.0 rewrote it in C++), and — since the analysis plugins only officially support up to 2.08 — it’s the bridge into the 3.x line. The target is a VMP-protected HelloASM.vmp1, compiled with “fastest speed” and 5 nop bytes at the entry encrypted (a 12 KB exe). Everything below is done in OllyDbg.
VM entry: what changed since 1.64
For context, here’s the 1.64 VM entry (the push-register prologue):
004050E6 |$ 56 push esi
004050E7 |. 53 push ebx
004050E8 |. 50 push eax
004050E9 |. 53 push ebx
004050EA |. 51 push ecx
004050EB |. 9C pushfd
004050EC |. 52 push edx
004050ED |. 57 push edi
004050EE |. 55 push ebp
004050EF |. 68 00000000 push 0x0
004050F4 |. 8B7424 2C mov esi,dword ptr ss:[esp+0x2C]
004050F8 |. 89E5 mov ebp,esp
004050FA |. 81EC C0000000 sub esp,0xC0
Which left the stack like this at EIP = 004050FA:
0019FF44 00000000 ;1 push 0x0
0019FF48 0019FF80 ;2 push ebp
0019FF4C 00401000 ;3 push edi
0019FF50 00401000 ;4 push edx
0019FF54 00000246 ;5 pushfd
0019FF58 00401000 ;6 push ecx
0019FF5C 003C9000 ;7 push ebx
0019FF60 0019FFCC ;8 push eax
0019FF64 003C9000 ;9 push ebx
0019FF68 00401000 ;10 push esi
0019FF6C 0040578C ;return address from the preceding CALL
0019FF70 00405790 ;the virtual code address
In 2.12.3 the entry is no longer push A; call B. It opens with a jmp:
00401000 > $- E9 8E480000 jmp HelloASM.00405893
00405893 9C pushfd
00405894 E9 73010000 jmp HelloASM.00405A0C
In 1.6, A was the encrypted virtual-code address and pushfd filled a slot. At 00405894 the eight GPRs and flags are:
EAX 0019FFCC
ECX 00401000
EDX 00401000
EBX 00228000
ESP 0019FF70
EBP 0019FF80
ESI 00401000
EDI 00401000
Eflag 00000246
Then the prologue, now laced with junk instructions:
00405A0C E8 00000000 call HelloASM.00405A11
00405A11 C74424 04 05594>mov dword ptr ss:[esp+0x4], 00405905
00405A19 882C24 mov byte ptr ss:[esp],ch ; junk
00405A1C 66:C70424 EE70 mov word ptr ss:[esp],0x70EE ; junk
00405A22 880424 mov byte ptr ss:[esp],al ; junk
00405A25 C70424 2F1D0185 mov dword ptr ss:[esp],0x85011D2F; junk
00405A2C 9C pushfd
00405A2D 8D6424 04 lea esp,dword ptr ss:[esp+0x4]
00405A31 ^ E9 C1E9FFFF jmp HelloASM.004043F7
The call + mov [esp+4], 00405905 pair is the same “push A, call B” trick as before; pushfd + lea esp,[esp+4] cancels to nothing (junk). Then the register pushes:
004043F7 60 pushad
004043F8 9C pushfd
004043F9 885C24 04 mov byte ptr ss:[esp+0x4],bl
004043FD FF7424 14 push dword ptr ss:[esp+0x14] ; push ebx
00404401 f3:9c rep pushfd ; pushfd
00404403 8F4424 24 pop dword ptr ss:[esp+0x24]
00404407 9C pushfd
00404408 C60424 2B mov byte ptr ss:[esp],0x2B
0040440C 9C pushfd
0040440D 9C pushfd
0040440E 8D6424 30 lea esp,dword ptr ss:[esp+0x30]
00404412 E9 C1110000 jmp HelloASM.004055D8
This does the same job as the 1.64 prologue but is far harder to read. The trick: rather than single-step through it, set a breakpoint at 004050B2 (after the stack frame is built), dump the stack, and compare it against the saved registers — the layout comes back:
0019FF3C 0019FF33
0019FF40 00000000 ; push 0
0019FF44 00000000
0019FF48 0019FF80 ; push ebp
0019FF4C 00401000 ; push ecx
0019FF50 00401000 ; push edx
0019FF54 00228000 ; push ebx
0019FF58 00401000 ; push esi
0019FF5C 0019FFCC ; push eax
0019FF60 00401000 ; push edi
0019FF64 00401000 ; push duplicate
0019FF68 00000246 ; pushfd
0019FF6C 85011D2F ; the CALL return-address slot
0019FF70 00405905 ; the virtual code address
At this point esp = 0019FF3C, ebp = 0019FF40 (the extra unannotated slots are almost certainly there to complicate virtual-code execution). Then the virtual-machine frame:
004050B2 81EC BC000000 sub esp,0xBC ; build the frame
004050B8 66:0FBAF9 07 btc cx,0x7
004050BD D2D8 rcr al,cl
004050BF 89E7 mov edi,esp ; edi = virtual-register base
004050C1 D2E0 shl al,cl
004050C3 66:0FBDC9 bsr cx,cx
004050C7 F5 cmc
004050C8 0375 00 add esi,dword ptr ss:[ebp] ; relocation-related
Handler dispatch
The dispatch loop fetches a virtual-code byte, uses it to index a handler table, and decrypts the resulting address:
0040463E 8A06 mov al,byte ptr ds:[esi] ; fetch virtual code
00404640 66:0FACC9 02 shrd cx,cx,0x2
00404645 F6D1 not cl
00404647 D2E9 shr cl,cl
00404649 0FB6C0 movzx eax,al
0040464C 80F1 55 xor cl,0x55
0040464F 8B0C85 1A4C4000 mov ecx,dword ptr ds:[eax*4+0x404C1A] ; handler addr (still encrypted)
00404656 E9 81050000 jmp HelloASM.00404BDC
00404BDC 83EE FF sub esi,-0x1 ; == inc esi
00404BF7 81F1 CE66B527 xor ecx,0x27B566CE ; decrypt handler address
00404C02 894C24 24 mov dword ptr ss:[esp+0x24],ecx ; store handler addr
00404C06 C60424 0E mov byte ptr ss:[esp],0xE
00404C0A FF7424 24 push dword ptr ss:[esp+0x24] ; push handler addr
00404C0E C2 2800 retn 0x28 ; jump to handler
Mechanically this is the same as 1.6/1.2. The difference: in this version the table entry [eax*4 + handler_base] is encrypted, and it’s the handler that’s decrypted — so the virtual-register offset that used to be recovered from the same byte now needs a second bytecode byte (the dispatch reads two bytes: 09 and 3C, where 3C is the virtual-register offset).
The first handler: filling a virtual register
00404125 F5 cmc
00404126 0FC0C2 xadd dl,al
00404129 66:29CA sub dx,cx
0040412C 66:0FBDD3 bsr dx,bx
00404130 8A06 mov al,byte ptr ds:[esi] ; fetch virtual-register offset
00404132 66:81C2 F096 add dx,0x96F0
00404137 8B55 00 mov edx,dword ptr ss:[ebp] ; previously-pushed register value into edx
0040413A F5 cmc
0040413B E8 13010000 call HelloASM.00404253
00404253 8D76 01 lea esi,dword ptr ds:[esi+0x1] ; == inc esi
00404256 84FF test bh,bh
00404258 83C5 04 add ebp,0x4
0040425B 9C pushfd
0040425C 60 pushad
0040425D 891438 mov dword ptr ds:[eax+edi],edx ; write the virtual register
00404260 FF7424 08 push dword ptr ss:[esp+0x8]
00404264 68 58E5DA5D push 0x5DDAE558
00404269 9C pushfd
0040426A 8D6424 34 lea esp,dword ptr ss:[esp+0x34]
0040426E E9 580E0000 jmp HelloASM.004050CB
004050CB 20E8 and al,ch
004050CD E8 6BF5FFFF call HelloASM.0040463D ; back to the dispatch loop
That’s the first handler: still a “fill virtual register” handler that writes 0 (from 0019FF40) into the virtual register. Note the missing and al, 0x3C from earlier versions — the virtual-register offset is now carried in its own bytecode byte.
A second handler is a decoy: it reads 4 bytes from virtual code and writes them to [ebp], which just subtracts 4 from ebp — its only real effect, and the third handler can be skipped. The author deliberately salted the flow with junk handlers and conditional jumps that are actually unconditional (e.g. cmp ebp,eax where both are constant, so ja always jumps).
The virtual-register fill sequence
The next sixteen handlers each fill one virtual register. The mapping between the two-byte virtual code (09 3C, 04 24, …) and the pushed registers is:
| Virtual-register offset | Register pushed at entry |
|---|---|
0x3C |
push 0 |
0x0C |
(junk) |
0x24 |
push ebp |
0x08 |
push ecx |
0x34 |
push edx |
0x28 |
push ebx |
0x00 |
push esi |
0x10 |
push eax |
0x04 |
push edi |
0x14 |
push duplicate |
0x38 |
push eflag |
0x2C |
0x85011D2F |
0x30 |
the virtual code address |
After the fill, the virtual-register frame looks like:
0019FE80 esi edi ecx junk
0019FE90 eax dup unfilled unfilled
0019FEA0 unfilled ebp ebx 85011D2F
0019FEB0 code addr edx eflag relocation 0
The “junk” and “unfilled” virtual-register slots (and possibly more) can be treated as scratch — the rest are only used to hold computed results while emulating the protected code.
Virtual-register shuffling
This is the big change from earlier versions: virtual registers get swapped within a single flow block (in ≤1.6 they only differed across blocks). The eflag register, for example, starts at offset 0x38, moves to 0x2C, then to 0x34. A representative handler:
0040521D 80CE FE or dh,0xFE
00405220 8A06 mov al,byte ptr ds:[esi]
00405222 D0CE ror dh,1
00405224 21D2 and edx,edx
00405226 8B1438 mov edx,dword ptr ds:[eax+edi] ; read eflag into edx
00405229 66:0FBAE0 09 bt ax,0x9
0040522E 83ED 04 sub ebp,0x4 ; ebp = 0019FF70
00405231 F5 cmc
00405232 60 pushad
00405233 E8 23000000 call HelloASM.0040525B
0040525B 8955 00 mov dword ptr ss:[ebp],edx ; [0019FF70] = eflag
0040525E F8 clc
0040525F 60 pushad
00405260 886424 04 mov byte ptr ss:[esp+0x4],ah
00405264 83EE FF sub esi,-0x1 ; == inc esi
00405267 66:C74424 08 0C>mov word ptr ss:[esp+0x8],0xAB0C ; junk
0040526E 885C24 08 mov byte ptr ss:[esp+0x8],bl ; junk
00405272 C64424 14 E6 mov byte ptr ss:[esp+0x14],0xE6 ; junk
00405277 8D6424 44 lea esp,dword ptr ss:[esp+0x44] ; junk
0040527B E9 97020000 jmp HelloASM.00405517
That’s also junk — the shuffle is the real work. From pseudo-code address 0040597B onward, the virtual registers are effectively copied onto the stack and then freely re-mapped to different offsets.
The modified-ESI trick
One handler deserves a closer look because it rewrites esi (the virtual instruction pointer) — a computed jump:
004047EE 66:0FCE bswap si ; junk
004047F1 66:0FBCF7 bsf si,di ; junk
004047F5 9C pushfd
004047F6 66:0FABD6 bts si,dx ; junk
004047FA 8B75 00 mov esi,dword ptr ss:[ebp] ; esi = 00405899
004047FD 66:0FBAE1 0D bt cx,0xD
00404802 84DB test bl,bl
00404804 F9 stc
00404805 66:0FBAE0 08 bt ax,0x8
0040480A 83C5 04 add ebp,0x4
0040480D 68 E70B3726 push 0x26370BE7
00404812 8D6424 08 lea esp,dword ptr ss:[esp+0x8]
00404816 E9 A6080000 jmp HelloASM.004050C1
004050C1 D2E0 shl al,cl
004050C3 66:0FBDC9 bsr cx,cx
004050C7 F5 cmc
004050C8 0375 00 add esi,dword ptr ss:[ebp] ; relocation: ebp holds offset 0x3C (=0)
004050CB 20E8 and al,ch
004050CD E8 6BF5FFFF call HelloASM.0040463D
After this, esi = 0x00405899. Notably, the computed value for esi was encrypted and decrypted through multiple passes — yet the final exit address (0x401005) is stored unencrypted in the virtual code, unlike ≤1.6 where the exit was also encrypted.
Exiting the VM
Finally the virtual registers are dumped back to the stack and popped into the real registers:
004056DD 66:D3FF sar di,cl
004056E0 66:0FBAF3 06 btr bx,0x6
004056E5 38DE cmp dh,bl
004056E7 89EC mov esp,ebp ; esp = 0019FF44
004056E9 66:11E1 adc cx,sp
004056EC FC cld
004056ED 59 pop ecx ; ecx = AD7B4B1E (only advances esp)
004056EE 9F lahf
004056EF 5B pop ebx ; ebx = 00401000 (only advances esp)
004056F0 66:0FC1EE xadd si,bp
004056F4 D5 B9 aad 0xB9
004056F6 66:0FC1D6 xadd si,dx
004056FA 5D pop ebp ; ebp = 0019FF80
004056FB D4 7A aam 0x7A
004056FD 5E pop esi ; esi = 00401000
004056FE 3F aas
004056FF D2D4 rcl ah,cl
00405701 E8 F3F2FFFF call HelloASM.004049F9
004049F9 66:21DF and di,bx
004049FC 8B7C24 04 mov edi,dword ptr ss:[esp+0x4] ; junk
00404A00 99 cdq
00404A01 8B5C24 08 mov ebx,dword ptr ss:[esp+0x8] ; ebx = 00228000
00404A05 F5 cmc
00404A06 80FA 7A cmp dl,0x7A
00404A09 8B7C24 0C mov edi,dword ptr ss:[esp+0xC] ; edi = 00401000
00404A0D 80E5 E5 and ch,0xE5
00404A10 0FBAF8 0A btc eax,0xA
00404A14 66:0FB6C1 movzx ax,cl
00404A18 8B4424 10 mov eax,dword ptr ss:[esp+0x10] ; eax = 0019FFCC
00404A1C 66:81D1 6348 adc cx,0x4863
00404A21 8B5424 14 mov edx,dword ptr ss:[esp+0x14] ; edx = 00401000
00404A25 F5 cmc
00404A26 9C pushfd
00404A27 8B4C24 1C mov ecx,dword ptr ss:[esp+0x1C] ; ecx = 00401000
00404A2B 9C pushfd
00404A2C F5 cmc
00404A2D FF7424 24 push dword ptr ss:[esp+0x24]
00404A31 9D popfd
00404A32 66:C74424 24 3E>mov word ptr ss:[esp+0x24],0xAA3E
00404A39 886424 0C mov byte ptr ss:[esp+0xC],ah
00404A3D FF7424 28 push dword ptr ss:[esp+0x28]
00404A41 C2 2C00 retn 0x2C ; return to 0x401005
The final stack layout as the registers restore:
0019FF4C 0019FF80 ebp
0019FF50 00401000 esi
0019FF54 461BFFD3
0019FF58 002A6000 ebx
0019FF5C 00401000 edi
0019FF60 0019FFCC eax
0019FF64 00401000 edx
0019FF68 00401000 ecx
0019FF6C 00000246 eflag
Summary
Compared to 1.6, 2.12.3 is a real step up:
- virtual registers are swapped within a single flow block, not just across blocks
- the computed
esivalue is encrypted, but the exit address is now plaintext in the virtual code - virtual-register read/write pseudo-code is split into two bytes instead of one
- the handler table entries are encrypted, requiring the extra bytecode byte to carry the virtual-register offset
The overall execution framework is unchanged, which is what makes it predictable once you’ve mapped one version — but each of these changes materially raises the cost of writing an analysis plugin.
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