Analyzing the VMProtect 3.0.0beta Virtual Machine: Handler Chaining and Double Encryption

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 the VMProtect virtual-machine series, moving from the last Delphi build (2.12.3) into the 3.x line. Starting with 3.0, the whole thing was rewritten in C++ (with a Qt UI), and the encrypted output ballooned — this sample grows from 179 KB to 590 KB under protection. The target is the 3.0.0beta. Two instructions at the entry are virtualized (mov eax,0x12345678 and jmp 00415035), with the other registers set to recognizable constants so their movement through the VM is easy to track:

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

After protection, the two virtualized instructions become:

0040A5C6   .- E9 CCC20800   jmp 123_vmp.00496897
0040A5CB   >^ FFE7          jmp edi
0040A5CD   .  57            push edi
0040A5CE   .  C3            retn
0040A5CF   .  4F            dec edi
0040A5D0   .  C3            retn
0040A5D1      90            nop

0040A5C6 is replaced with a jmp into the VM; 0040A5CB becomes what looks like a handler fragment. Stepping into the entry:

00496897    68 5AFA2B26     push 0x262BFA5A
0049689C    E8 05BFFBFF     call 123_vmp.004527A6

The same push A; call B opener as 1.6. But the prologue is where things diverge. The key region, with junk elided:

004527A6    9C              pushfd
------省略------
004527CD    8B7424 28       mov esi,dword ptr ss:[esp+0x28]
------省略------
00452811    8BEC            mov ebp,esp
------省略------
0045281C    8DA424 40FFFFFF lea esp,dword ptr ss:[esp-0xC0]
------省略------
0045282B   >  8BDE           mov ebx,esi;ebx存放esi的值作为密钥
------省略------
0045283B    8D3D 3B284500   lea edi,dword ptr ds:[0x45283B]
00452841    8B06            mov eax,dword ptr ds:[esi]
00452843    F9              stc
00452844    8DB6 04000000   lea esi,dword ptr ds:[esi+0x4]
0045284A    33C3            xor eax,ebx;解密
0045284C    48              dec eax解密
0045284D    66:3D 8A58      cmp ax,0x588A
00452851    66:85D7         test di,dx
00452854    0FC8            bswap eax解密
00452856    2D 6D7E3B00     sub eax,0x3B7E6D解密
0045285B    35 412CD300     xor eax,0xD32C41解密
00452860    F6C6 C6         test dh,0xC6
00452863    33D8            xor ebx,eax;生成下一次解密的密钥
00452865    F8              clc
00452866    03F8            add edi,eax ;算出handler的地址
00452868    57              push edi
00452869    C3              retn ;执行handler

Comparing against the 1.64 entry:

  • At 004527CD the duplicated register slot from earlier versions is gone — esi is still encrypted at that point, and only gets decrypted by 00452811.
  • 0045281C is just sub esp, 0xC0.
  • 0045283B is new: edi is no longer the virtual-register base (that role moves to esp). edi now holds the handler address.
  • 00452841 reads 4 bytes of virtual code, not 1.

The handler lookup is no longer the [eax*4 + base] vtable-style table of earlier versions. Instead the handler is chained: the current handler’s address plus a decrypted 4-byte value yields the next handler.

Double encryption of the virtual code

The per-handler pseudocode is now double-encrypted. The outer layer is a sequence of arithmetic ops (not, inc, bswap, dec, neg, …); the inner layer xors/adds/subtracts with a running key that is itself the pseudocode address. The key lives in ebx (initialized from esi on the first round), and every round it’s updated by xoring with the decrypted value:

xor ebx,eax     ; generate the key for the next round
add edi,eax     ; edi = decrypt(pseudocode) + edi  -> next handler address

The pseudocode length can be 4, 2, or 1 byte(s); 2- and 1-byte variants update only bx or bl of the key. The decryption order is the exact reverse of encryption: first the inner xor/add/sub using the pseudocode address as key, then the arithmetic sequence, then the new key is computed.

The practical consequence: to enumerate every handler address you must know the address of the previous handler. The author’s entire redesign goal was to prevent dumping the full handler table and translating all virtual code back into normal instructions.

The first handler

Following the retn at 00452869 lands on the first handler:

00447687    0FB606          movzx eax,byte ptr ds:[esi];取出一个字节的伪代码
0044768A    66:D3C9         ror cx,cl
0044768D    81C6 01000000   add esi,0x1;;伪代码指针加1
00447693    03CF            add ecx,edi
00447695    81F1 7B41E822   xor ecx,0x22E8417B
0044769B    66:0FBAF1 41    btr cx,0x41
004476A0    32C3            xor al,bl ;解密伪代码
004476A2    0FBFCC          movsx ecx,sp
004476A5    F6D0            not al ;解密伪代码
004476A7    0f94c5          sete ch
004476AA    FEC8            dec al ;解密伪代码
004476AC    D3F9            sar ecx,cl
004476AE    0FB7CB          movzx ecx,bx
004476B1    F6D0            not al ;解密伪代码
004476B3    0FBDCE          bsr ecx,esi
004476B6    F6D8            neg al ;解密伪代码
004476B8    0BCF            or ecx,edi
004476BA    66:3BC6         cmp ax,si
004476BD    32D8            xor bl,al;生成下次解密的密钥
004476BF    c1d1 f3         rcl ecx,0xf3
004476C2    86ED            xchg ch,ch
004476C4    66:13CE         adc cx,si
004476C7    8B4C25 00       mov ecx,dword ptr ss:[ebp];取出堆栈的值(对应push 0)
004476CB    A8 8D           test al,0x8D
004476CD    81C5 04000000   add ebp,0x4;ebp指向下一个堆栈的值(对应push esi)
004476D3    66:F7C7 5744    test di,0x4457
004476D8    890C04          mov dword ptr ss:[esp+eax],ecx ;存入虚拟寄存器(对应Vm_Relocation)
004476DB    66:0FBAF8 99    btc ax,0x99
004476E0    03C7            add eax,edi
004476E2    8B06            mov eax,dword ptr ds:[esi];从esi读取4字节
004476E4    81C6 04000000   add esi,0x4;伪代码指针加4
004476EA    F8              clc
004476EB    33C3            xor eax,ebx ;解密伪代码
004476ED    48              dec eax  ;解密伪代码
004476EE    F8              clc
004476EF    F5              cmc
004476F0    0FC8            bswap eax;解密伪代码
004476F2    66:F7C2 FD44    test dx,0x44FD
004476F7    8D80 9381C4FF   lea eax,dword ptr ds:[eax-0x3B7E6D] ;解密伪代码
004476FD    35 412CD300     xor eax,0xD32C41;解密伪代码
00447702    33D8            xor ebx,eax;生成下次解密的密钥
00447704    85DA            test edx,ebx
00447706    03F8            add edi,eax;生成handler的地址
00447708  ^ FFE7            jmp edi;等价于push retn组合

This is still a “fill virtual register” handler, but it differs structurally from 2.12.3. The handler now has two parts: part one performs the handler’s function (here, mov [esp+eax], ecx — writing the pushed register value into a virtual register, with esp as the virtual-register base and eax carrying the decoded offset); part two decrypts the next 4 bytes and computes the next handler address.

Using vm_<reg> to denote each virtual register, the fill handlers map like this:

Virtual-register offset Register
0x2c vm_relocation (always 0)
0x30 vm_esi
0x24 vm_ebp
0x04 vm_ebx
0x34 vm_eax
0x18 vm_edi
0x00 vm_ecx
0x1c vm_edx
0x3c vm_eflags
0x20 vm_retaddr (the return from the call at 0x49689c)
0x28 vm_push (the value pushed at 0x496897)

The full virtual-register frame is 0x40 bytes — 16 registers of 4 bytes each, same as the 1.2 era, with the spare slots used for computation and confusion:

0019FE88 vm_ecx vm_ebx 空闲 空闲
0019FE98 空闲 空闲 vm_edi vm_edx
0019FEA8 vm_retaddr vm_ebp vm_push vm_relocation
0019FEB8 vm_esi vm_eax 空闲 vm_eflags

Dumping virtual registers to the stack

After the virtualized mov eax,0x12345678, the handlers pop virtual registers back onto the stack. The next-handler computation is occasionally obfuscated with a decoy jump:

004840A4    03F8            add edi,eax ;算出下一条handler地址
004840A6  ^ E9 1319FCFF     jmp 123_vmp.004459BE;这条代码不是push edi,或者jmp edi。
004459BE    8D4424 60       lea eax,dword ptr ss:[esp+0x60]
004459C2    3BE8            cmp ebp,eax
004459C4  ^ 0F87 89AFFFFF   ja 123_vmp.00440953 ;混淆,永远都是跳走
00440953  ^\FFE7            jmp edi

The cmp ebp,eax; ja is a fake conditional — both operands are constant, so it always jumps. After the dump phase, the stack layout is:

0019FF50   51515151 ;vm_esi
0019FF54   0019FF80 ;vm_ebp
0019FF58   BBBBBBBB ;vm_ebx
0019FF5C   12345678 ;0x12345678
0019FF60   D1D1D1D1 ;vm_edi
0019FF64   CCCCCCCC ;vm_ecx
0019FF68   DDDDDDDD ;vm_edx
0019FF6C   00000246 ;vm_eflags
0019FF70   00415035 ;00415035

Note there’s no vm_eax in that table — 0019FF5C is vm_eax, which now holds 0x12345678 because the mov eax,0x12345678 was virtualized. And 0019FF70 (00415035) is the virtualized jmp 00415035.

Exiting the VM

The final handler restores the real registers and performs the virtualized jump:

0047F585    8BE5            mov esp,ebp ;修改esp,配合后面的出栈指令
0047F587    0FBFF6          movsx esi,si
0047F58A    5E              pop esi     ;恢复esi
0047F58B    9F              lahf
0047F58C    0FBFDA          movsx ebx,dx
0047F58F    F7C5 A23F0045   test ebp,0x45003FA2
0047F595    5D              pop ebp     ;恢复ebp
0047F596    5B              pop ebx     ;恢复ebx
0047F597    98              cwde
0047F598    66:0FBAFA C4    btc dx,0xC4
0047F59D    66:98           cbw
0047F59F    58              pop eax     ;恢复eax
0047F5A0    c0f9 28         sar cl,0x28
0047F5A3    5F              pop edi     ;恢复edi
0047F5A4    66:0FB6D0       movzx dx,al
0047F5A8    59              pop ecx     ;恢复ecx
0047F5A9    5A              pop edx     ;恢复edx
0047F5AA    F6C3 95         test bl,0x95
0047F5AD    9D              popfd       ;恢复eflags
0047F5AE    C3              retn        ;相当于jmp 00415035

At 0047F59F the top of the stack is 0x12345678, so pop eax executes the virtualized mov eax,0x12345678; the retn at the end jumps to 00415035, executing the virtualized jmp.

Summary

3.0.0beta is a large file that executes relatively little code. The core changes versus 2.12.3:

  • Handler hiding: the vtable-style [eax*4 + base] lookup is gone. edi now carries the handler address (the virtual-register base moves to esp), and each handler computes its successor as edi = decrypt(pseudocode) + edi.
  • 4-byte pseudocode reads instead of 1-byte.
  • Double encryption of the virtual code: an arithmetic-op layer plus an xor/add/sub keyed by the pseudocode address.
  • The virtual-register frame itself stays 0x40 bytes / 16 registers.

It’s still a beta — mostly functionality with comparatively little confusion — so despite the bigger file it’s actually less involved to trace than 2.12.3. But the handler-chaining and double encryption are the foundation the later 3.x builds build their obfuscation 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