Analyzing the VMProtect 3.9.4 Virtual Machine: Backward Virtual Code and the Final Boss

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 closes out the VMProtect virtual-machine series, from the early Delphi versions up to 3.9.4 — the “final boss.” As before, two instructions at the entry are virtualized (mov eax,0x12345678 and jmp 00415035), with recognizable register constants for tracking:

0040A5A8 >  B8 AAAAAAAA     mov eax,0xAAAAAAAA
0040A5AD    BB BBBBBBBB     mov ebx,0xBBBBBBBB
0040A5B2    B9 CCCCCCCC     mov ecx,0xCCCCCCCC
0040A5B7    BA DDDDDDDD     mov edx,0xDDDDDDDD
0040A5BC    BE 51515151     mov esi,0x51515151
0040A5C1    BF D1D1D1D1     mov edi,0xD1D1D1D1
0040A5C6    B8 78563412     mov eax,0x12345678
0040A5CB    E9 65AA0000     jmp 123.00415035
0040A5D0    C3              retn
0040A5D1    90              nop

The entry stub changed again — instead of a single jmp, the protected instruction is now replaced with a push/push/call/pop/jmp sequence:

0040A5C6  |.  57            push edi
0040A5C7  |.  53            push ebx
0040A5C8  |.  E8 B4AB0B00   call 123_vmp.004C5181
0040A5CD  |.  59            pop ecx
0040A5CE  \.- FFE5          jmp ebp

The prologue still uses DSP (displacement) encryption from 3.8.1:

004C5181    9C              pushfd
004C5182    BF 9C9C823B     mov edi,0x3B829C9C
004C5187    BB 8C0F063D     mov ebx,0x3D060F8C
004C518C    81F3 A36618C2   xor ebx,0xC21866A3
;flags change
004C5192    8BBC3C 70637DC4 mov edi,dword ptr ss:[esp+edi-0x3B829C90]
;== mov edi,[esp+0xC]  -- dsp = 0xC, encrypted
;edi restored to D1D1D1D1
004C5199    C74424 0C 0895A>mov dword ptr ss:[esp+0xC],0x8BA49508
;[0019FF70] = 8BA49508
004C51A1    8B5C24 08       mov ebx,dword ptr ss:[esp+0x8]
;ebx restored to BBBBBBBB
004C51A5    E8 3294FFFF     call 123_vmp.004BE5DC

Register roles and backward virtual code

After the pushes and initialization, the register assignments for 3.9.4 are:

Register Role
ebx the decryption key
esp virtual-register base
ebp handler address
esi virtual-code pointer — stored backwards
edi stack read/write scratch (like a real esp)

The stack layout after the pushes:

0019FF50   D1D1D1D1  ; edi
0019FF54   00000000  ; relocation
0019FF58   BBBBBBBB  ; ebx
0019FF5C   00000246  ; eflags
0019FF60   51515151  ; esi
0019FF64   0019FF80  ; ebp
0019FF68   DDDDDDDD  ; edx
0019FF6C   CCCCCCCC  ; ecx
0019FF70   AAAAAAAA  ; eax

The big new behavior in 3.9.4 is that the virtual code is stored backwards. esi starts at virtual_code + 4 and reads at negative offsets ([esi-0x4], [esi-0x6], [esi-0x9], …), decrementing as it goes. This also changes the first-round key: in 3.8.1 the initial key was esi’s starting value, but here it’s esi + 4. The decryption loop:

0047D8DA    BD 3FCD892B     mov ebp,0x2B89CD3F
0047D8DF    8BDE            mov ebx,esi
;the virtual-code address into ebx (the key)
...
0047D8FA    8D2D F2D84700   lea ebp,dword ptr ds:[0x47D8F2]
;an in-code address into ebp, used to compute the next handler
...
0047D905    8DB486 0439FDFF lea esi,dword ptr ds:[esi+eax*4-0x2C6FC]
;== sub esi, 4
...
0047D90E    8B940E 424EFFFF mov edx,dword ptr ds:[esi+ecx-0xB1BE]
;== mov edx,[esi]  -- read 4 bytes of virtual code
0047D919    33D3            xor edx,ebx  ;decrypt the handler-address addend
...

The first handler: filling vm_edi, vm_relocation, vm_ebx

0050AA79    8B4E FC         mov ecx,dword ptr ds:[esi-0x4]
;read 4 bytes at esi-4 -- consistent with backward storage
0050AA7C    B8 879910BE     mov eax,0xBE109987
0050AA81    0FB6D0          movzx edx,al
0050AA84    33CB            xor ecx,ebx  ;decrypt handler-address addend
0050AA86    92              xchg eax,edx
0050AA87    F7D1            not ecx
0050AA89    0FB3C2          btr edx,eax
0050AA8C    D1C1            rol ecx,1
0050AA8E    c0e2 e6         shl dl,0xe6
0050AA91    66:c1e2 8c      shl dx,0x8c
0050AA95    c0e0 e3         shl al,0xe3
0050AA98    49              dec ecx
0050AA99    0AC0            or al,al
0050AA9B    F7D9            neg ecx      ;decrypt done
0050AA9D    33D9            xor ebx,ecx  ;new key
0050AA9F    03E9            add ebp,ecx  ;next handler address
0050AAA1    66:0FA3D2       bt dx,dx
0050AAA5    8B5407 C8       mov edx,dword ptr ds:[edi+eax-0x38]
;read edi's value from the stack
...
0050AAAF    0FB68C06 FB1FFF>movzx ecx,byte ptr ds:[esi+eax-0xE005]
;read one byte of virtual code
0050AAB7    32CB            xor cl,bl ;decrypt the virtual-register offset
...
0050AACD    FEC9            dec cl     ;offset decryption done
...
0050AAD9    03CC            add ecx,esp
;address of virtual register at offset 0x4
0050AADE    891421          mov dword ptr ds:[ecx],edx
;store edi's value into vm_edi (offset 0x4)

The handler continues through several chained code blocks (the jmp edx connections from 3.8.1) and fills two more registers — vm_relocation (offset 0x2c) and vm_ebx (offset 0x38) — before retn 0xC dispatches the next handler. The second and third handlers fill the rest, yielding the full virtual-register map:

19fe8c  unused    edi        eflags     eax
19fe9c  unused    unused     unused     esi
19feac  ecx       unused     unused     vm_relocation
19febc  unused    unused     ebx        edx
19fecc  ebp       unused     unused     unused

So the offsets are: vm_edi=0x4, vm_relocation=0x2c, vm_ebx=0x38, vm_eflags=0x8, vm_esi=0x1c, vm_ebp=0x40, vm_edx=0x3c, vm_ecx=0x20, vm_eax=0xC. Note vm_ebp at 0x40 again confirms the frame is larger than the old 0x40 total.

Handling the two protected instructions

The fourth handler decrypts the immediate (0x12345678) and stores it to virtual register offset 0x10:

004A5D91    8B4E FC         mov ecx,dword ptr ds:[esi-0x4]
;read 4 bytes of virtual code
...
004A5D9F    33CB            xor ecx,ebx
;decrypt the immediate (0x12345678)
004A5DA1    E9 CB560000     jmp 123_vmp.004AB471
004AB471    c0fa c2         sar dl,0xc2
004AB474    C1C9 02         ror ecx,0x2
004AB477    FEC8            dec al
004AB479    0AC2            or al,dl
004AB47B    0FC9            bswap ecx
004AB47D    41              inc ecx
004AB47E    D1C1            rol ecx,1 ;immediate decryption done
...
004AB484    898C17 1280FFFF mov dword ptr ds:[edi+edx-0x7FEE],ecx
;store the immediate at 0019FF70

The fifth handler decrypts the jmp displacement (0x00415035) and stores it at 0019FF70 as well:

004D24DD    B9 BD5E15C1     mov ecx,0xC1155EBD
004D24E2    E8 F73CF9FF     call 123_vmp.004661DE
004661DE    8D048D A43E85C5 lea eax,dword ptr ds:[ecx*4-0x3A7AC15C]
004661E5    81EE 04000000   sub esi,0x4
...
004661FC    8B8416 6898638E mov eax,dword ptr ds:[esi+edx-0x719C6798]
;read 4 bytes of virtual code
00466203    49              dec ecx
00466204    33C3            xor eax,ebx
;decrypt the jmp displacement (00415035)
00466206    C1C8 02         ror eax,0x2
...
00466224    D1C0            rol eax,1 ;displacement decryption done
00466226    33D8            xor ebx,eax  ;new key
...
0046622F    8984CF F07F8A9F mov dword ptr ds:[edi+ecx*8-0x60758010],eax
;store the displacement at 19ff70

Dumping virtual registers to the stack

The sixth through tenth handlers pop the virtual registers onto the stack in reverse order. They’re structurally identical to each other (same decryption chain, differing only in which virtual register they read), so I’ll summarize:

Stack address Value Source
0019FF50 D1D1D1D1 vm_edi
0019FF54 BBBBBBBB vm_ebx
0019FF58 00000246 vm_eflags
0019FF5C 51515151 vm_esi
0019FF60 0019FF80 vm_ebp
0019FF64 DDDDDDDD vm_edx
0019FF68 CCCCCCCC vm_ecx
0019FF6C 12345678 the virtualized mov eax,0x12345678
0019FF70 00415035 the virtualized jmp 00415035

Exiting the VM

The eleventh and final handler restores the real registers and executes both protected instructions:

0049AC92    8BE7            mov esp,edi ;prepare to restore registers
0049AC94    5F              pop edi     ;restore edi
0049AC95    5B              pop ebx     ;restore ebx
0049AC96    B8 8ECD941D     mov eax,0x1D94CD8E
0049AC9B    9D              popfd       ;restore eflags
0049AC9C    5E              pop esi     ;restore esi
0049AC9D    0FB6C8          movzx ecx,al
0049ACA0    98              cwde
0049ACA1    5D              pop ebp     ;restore ebp
0049ACA2    8D14CD 3CE01F73 lea edx,dword ptr ds:[ecx*8+0x731FE03C]
0049ACA9    E9 C8340400     jmp 123_vmp.004DE176
004DE176    87CA            xchg edx,ecx
004DE178    5A              pop edx     ;restore edx
004DE179    51              push ecx
004DE17A    8B8C0C 581BE08C mov ecx,dword ptr ss:[esp+ecx-0x731FE4A8] ;restore ecx
004DE181    864424 01       xchg byte ptr ss:[esp+0x1],al
004DE185    874424 00       xchg dword ptr ss:[esp],eax
004DE189  ^ E9 C37BF6FF     jmp 123_vmp.00445D51
00445D51    8B8404 5C71E08C mov eax,dword ptr ss:[esp+eax-0x731F8EA4]
;eax = 12345678 -- executes the first protected instruction
00445D58    68 B5BFAB16     push 0x16ABBFB5
00445D5D    8D6424 10       lea esp,dword ptr ss:[esp+0x10]
00445D61    C3              retn
;leave the VM -- executes the second protected instruction (jmp 00415035)

Summary

3.9.4’s material change over 3.8.1 is the backward storage of the virtual code (esi reads at negative offsets and the initial key shifts by 4). Everything else — the DSP-encrypted displacements, the double-encrypted virtual code, the key in ebx, the fragmented handlers chained with computed jumps — carries forward from 3.8.1, with the register roles shuffled once more.

This wraps the full 1.x → 3.9.4 virtualization branch. It’s still a long way from “fully analyzed” (instruction mutation, anti-debug, and memory protection remain), but the execution framework is now mapped end to end — which is the foundation everything else builds on.

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