VMProtect · 架构笔记
VMP2 还是 VMP1 那台机器
骨架没换。难的是 max:壳和函数拆成两台派发器,滚动解密也不再是看一眼字节就懂。
VMP1 做完再看 VMP2,第一感觉是难了很多。派发器不直给,handler 藏在弯路里,max 一启动还先跑一台壳。拆开之后会发现,里面还是那五样东西:VIP、派发器、handler、虚拟栈、虚拟寄存器。
能从 VMP1 带过来的是分析格式:在派发器上逐条记账,看虚拟栈多了什么、少了什么,以此来认指令。带不过来的是具体哪条指令、哪个寄存器、哪张表。那些每个样本都不一样。
一样的部分
不管一代还是二代,不管 speed 还是 max,虚拟机都在重复同一件事。
从 VIP 指向的地方取出一条字节码
用手里的密钥把这条字节码解开
派发器按解开的结果,跳进对应的 handler
handler 去改虚拟栈,或者改虚拟寄存器
然后回到派发器,取下一条
VIP 就是「下一条字节码在哪」。虚拟栈用来传参数、放计算结果。虚拟寄存器是一块跟着这次调用走的小内存,handler 按编号去读它、写它。派发器本身不干活,它只负责把每一条字节码送进正确的 handler。
所以看到一份新的 VMP,不要先问「这是第几代、操作码表在哪」。先问:谁在决定下一次取指,取出来的字节交给谁,handler 改的是栈还是那块小内存。这三个问题答上了,这台机器就认出来了。
一眼看出的差别:派发器长什么样
同一件「取字节 → 进 handler」,两边写出来的样子差很多。下面都是真实样本里的派发核心,中间的垃圾指令已经拿掉了。
VMP1.09 SPEED:三句就结束。
lodsb ; AL = [ESI],ESI += 1
movzx eax, al ; 这个字节就是 opcode
jmp dword ptr [eax*4+0x423B8D] ; 直接进 handler
日志里看到的那个字节,几乎可以直接当操作码用。handler 做完,jmp 回 lodsb,下一轮开始。
VMP1.09 MAX:多了 BL,但还是一眼能看到跳表。
lodsb
add al, bl
not al
dec al
xor al, 0xBD
rol al, 0x07
add bl, al ; 钥匙跟着滚
movzx eax, al
jmp dword ptr [eax*4+0x42384D] ; 仍然是直接 jmp
字节先和 BL 搅一轮,才是 opcode。跳转方式没变:还是 jmp [表],handler 做完还是 jmp 回 lodsb。
VMP2 SPEED:解 opcode 还看得见,进 handler 绕了一圈。
xor bl, al
movzx eax, al
mov edx, dword ptr [eax*4+0x4229f5] ; 表里拿出来的还不是地址
add edx, 0xbb352db0 ; 再加一个常数,才是 handler
... ; 中间夹着 pushfd / pushad
push dword ptr [esp+0x30] ; 把算好的地址压成返回地址
ret 0x34 ; 用 ret 跳进去,不是 jmp
表项是编码过的,handler 入口也不再是 jmp 直达。但从「xor bl → movzx → 查表」这一串,仍然能认出自己站在派发器上。
VMP2 MAX:派发器本身也拆散了。取指、解密、查表不一定挨在一起;进 handler 仍然是 push + ret;同一个功能的 handler,换一个样本地址就全换。更关键的是:启动时跑的那台,和进了函数之后跑的那台,不是同一张表。
同一份 add_two 的 max 样本里,全程能撞上五处查表,它们只归属两张表:
; 壳(启动阶段,表 0x0056f55e)
00571d3d mov edx, dword ptr [eax*4+0x0056f55e]
00570ff2 mov edx, dword ptr [eax*4+0x0056f55e] ; 另一条分支,同一张表
; 函数(原入口之后,表 0x00423f10)
004207b2 mov edx, dword ptr [eax*4+0x00423f10]
00420fc9 mov edx, dword ptr [eax*4+0x00423f10] ; 另一条分支,同一张表
日志里同一条 op=35,站在不同表上,跳进去的地方完全不一样:
壳: op=35 handler 原始值 f0fc5600 → 字节交换后是 0056fcf0
函数: op=35 handler 原始值 95244200 → 对应 00422495
数字碰巧都是 35,不是同一条指令。一台表上的两个查表点可以合并跟踪;两张表必须分开学。这就是 max 相对 speed、相对一代,多出来的那一步:先分清自己站在哪一台里。
把四段并排放:
|
取到的字节是不是 opcode |
怎么进 handler |
做完怎么回来 |
| VMP1 SPEED |
基本就是 |
jmp [表] |
jmp 回 lodsb |
| VMP1 MAX |
和 BL 异或一轮后才是 |
jmp [表] |
jmp 回 lodsb |
| VMP2 SPEED |
和 BL 搅过一轮 |
表项再解码,ret 进去 |
清栈后回到派发 |
| VMP2 MAX |
更深,且壳 / 函数各一套 |
同上,还要先认清是哪一台 |
同上 |
一代难在「认指令」。二代难在「先把派发器认出来」。骨架没换,外壳厚了。
VMP2 难在哪
难不在状态机换了,难在二代把解释器本身也打乱了。同一版 VMP2 保护出来的两个程序,派发器长得不一样,同一个功能的 handler 也不在同一个地方。一代里「认出版本,套已经逆过的指令表」这件事,二代做不到。
另一个变化是字节码不再裸着放。一代里,日志里看到的那个字节,常常就是操作数本身,或者只跟一个很短的密钥异或一下。二代把解开这一步藏进了各条 handler,而且还越藏越深。这就是下面说的滚动解密。
speed:还是一台派发器
VMP2 的 speed 和 VMP1 最像。整个程序里被保护的函数,跳进的是同一台派发器。找到它,后面就和一代一样。
做法也很熟:在派发器上每执行一条就记一行。这一行里要有取指位置、原始字节、跳进了哪个 handler、虚拟栈顶现在是什么。一条指令认没认对,只看一件事:用这一行栈顶算出来的结果,是不是出现在下一行。对上了,这条指令就可以起名。
举一个最小的例子。连续两行的栈顶是这样:
这一条:栈上是 3 和 5,handler 是某一段代码
下一条:栈上只剩 8
那一段代码就是加法。
真实样本里也能直接撞上。一份 1 + 2 的小函数,VMP2 speed 压缩成 152 条虚拟指令之后,栈顶会先后变成:
... s0=00000002
... s0=00000001 s1=00000002
... s0=00000003 ; 1+2 出现了,这一条就是 add
不必先读懂 handler 里的 x86。栈对上了,指令就有名字了。
把加减、读内存、写内存、离开虚拟机都这样认完,指令集就齐了。之后还原某一个函数,只是在用这套指令集读它的字节码,不再是在猜。speed 可以走到这一步,最后写成普通代码。一代能做完的事,speed 也能做完,因为始终只有一台派发器,不用先猜自己现在站在哪一台里。
函数和函数之间也好分。一次原生的调用进去、返回出来,就是一台函数的边界。内存基址换成一块新的,通常就是下一台。不要靠数有多少条字节码来猜哪里结束。
max:壳一台,函数又一台
max 才是相对一代变化最大的地方。程序刚启动时先跑的那段虚拟机,不是你要看的函数。那是壳。壳的工作是把真正的程序展开到内存里,展开完才进入原入口。被虚拟化的函数用的是另一台派发器,查表的地方和 handler 都跟壳不是同一套。
启动
-> 壳的派发器,把后面的程序展开出来
-> 原入口
-> 函数自己的派发器
-> 用户名、注册码这一类逻辑在这里面算
同一份 add_two(原来就 3 条 x86:mov / add / ret),保护级别一升,数字会差一个数量级。下面这些都是你自己样本上数过的:
|
文件体积 |
虚拟指令(进 handler 次数) |
底层真实 x86(函数段追踪) |
备注 |
| 原文件(未保护) |
115 KB |
— |
3 |
vmp_add.exe / vmp_add_map.exe,同一份 add_two |
| VMP1.09 SPEED |
136.5 KB |
48 |
约 600 多行 |
15 类 handler;映像约 152 KB |
| VMP1.09 MAX |
137 KB |
229 |
约 4000 行 |
33 类 handler;多出来的主要是 BL 和解密;映像约 152 KB |
| VMP2 SPEED |
126 KB |
152 |
约 11653 条(vmp0) |
全程约 14 万条 x86,几乎没有壳;映像约 136 KB |
| VMP2 MAX |
730 KB |
函数 1991 / 全程约 34 万 |
函数约 16.7 万 / 全程约 1.34 亿 |
含启动壳;映像约 1484 KB;vmp1 约 1.13 亿,vmp0 约 0.21 亿 |
文件体积从原文件就能对上。未保护 115 KB;一代 speed / max 涨到 136.5 / 137 KB,几乎一样大,差的是跑起来的指令条数(48 → 229,x86 六百多 → 四千)。二代 speed 126 KB,max 直接到 730 KB,相对原文件大约 6 倍——壳和那台上亿条 handler 一起写进文件里了。二代 speed → max,函数段虚拟指令从 152 涨到 1991;再加上壳,全程到 34 万条虚拟指令、上亿条 x86。膨胀不只是「每条 handler 多写了几句」,整条指令流程更长,落盘的 exe 在二代 max 上才明显一起胖。
34 万听起来吓人,真正要认的不是 34 万条,是两张表上那两百多个操作码。同一张表、同一个 opcode,做的是同一件事;壳那张表和函数那张表,数字碰巧一样也不算同一条指令。
这里说的「吐出源代码」,不是把虚拟机还原成 C。壳写回去的是展开后的程序。展开完的那份东西也不能当成「壳已经脱完、可以当普通程序跑」。壳在展开时留下的一些指针,离开当时的进程就无效了,单独拿展开结果去跑,会在进虚拟机时崩掉。
这里有个容易混的说法:有人把「虚拟机脱壳」理解成「把 VM 去掉」。实际不是。
壳启动完之后做的事,通常就三件:把加密区解密展开、找到 OEP、把 IAT 修到能正常导入。做完这些,程序能从原入口跑起来。你这份 max 里也能对上——前面上亿条是壳在跑,最后 ret 到 00401370,.text 里原先是零的位置也被填上了。
但当初勾成 Virtualization 的函数,脱完壳还在。00401010 仍然是一条 jmp 进派发器,字节码还在,handler 还在。壳解决的是「能不能跑起来」;该被 VM 的函数,脱完也还是 VM。要把那些函数还原成普通 x86,还是得站回函数那台派发器上,用栈变化去认指令——那是另一条线,不是 dump + 修 IAT 能做完的。
怎么知道自己碰到了两台,而不是一台里的两段代码?看同一类字节,在启动阶段和在进了函数之后,派发器跳进去的地方是不是同一处。不是同一处,就是两台。两台要分开跟踪,指令集也分开学。设计是一样的,都是取指、查表、进 handler,只是表不同。
分析格式仍然是 VMP1 那套。差别只是要先站对派发器。站在壳上,看到的是展开过程。站在函数那台上,才能看到用户名和注册码怎么比。
滚动解密,也就是 BL 解密
字节码不会以明文躺在那里。取出一个字节之后,要先用一个一直在变的密钥把它解开,这个密钥的低位通常就是 BL。解完,BL 自己再滚动一下,给下一条用。所以叫滚动解密。
一代的 max 里,这一步比较浅,而且集中在派发器门口。日志里看到一个字节,和当前的 BL 搅一下,就是这条指令真正用的数:
日志里的字节是 20
当前 BL 是 05
真正的操作数 ≈ 20 和 05 做过一轮运算之后的结果
对照真实指令,就是前面那串 add / not / dec / xor / rol,解完马上 jmp [表]。handler 里面如果还要再取一个寄存器编号,会再用 BL 解一次,但公式短,回跳目标固定。
二代的 max 把这一步做深了。那个字节还不是操作数。它要先和 BL 做一次运算,运算结果才是「去虚拟寄存器的第几号」。立即数也一样,字节码里连续的几个字节是密文,要和整个 EBX 搅过一轮,才是压进虚拟栈的那个数。
日志里的字节仍然是 20
它先和 BL 做运算,得到一个编号
handler 再用这个编号,去读虚拟寄存器里的值
所以 20 本身不是数据,只是一把钥匙
同一个 handler 里,一代和二代的「取寄存器编号」也能并排放:
; VMP1.09 MAX 的 V_POP:编号解完就 pop
lodsb
add al, bl
inc al
ror al, 0x01
not al
dec al
add bl, al
pop dword ptr [edi+eax*4]
jmp 派发器的 lodsb ; 回来的路很短
; VMP2 MAX:同类事情埋在更长的 handler 里
; 取字节、和 BL 运算、改虚拟栈,中间还夹着
; 不影响结果的 cmp / test / push / ret
; 做完不是 jmp 回固定地址,而是清掉一截栈再回去
跟踪的时候就要多记一个 BL。只记原始字节,会把密文当成操作数,后面的指令集全部对不上。speed 轻一些,原始字节离真正的操作数近,看栈的变化就能起名。max 必须先把「字节经过 BL 之后是什么」写成一步,再去认加减和读内存。
另一件一起变强的事是查找表。speed 里常见的做法是:程序先放一张 0 到 255 的表,再按一个种子把它打乱,打乱之后就不再动。你可以把这张表抽出来,离线使用。max 里这张表放在虚拟寄存器旁边,一开始也是 0 到 255,但程序跑起来之后还在改它。同一个下标,真正来读的那一刻是一个值,前后可能是另一个值。所以不能在中途把整张表倒出来,当成一份永远不变的常数。
学完指令集之后
speed 学完就可以往下写。字节码翻译成加减、查表、读内存,一个函数能收成普通代码,拿原来的输入跑,中间结果和虚拟机对得上,这台就算做完了。
max 学完指令集,往往还看不到「输入怎么变成那个被比较的数」。中间会隔着很长一串循环移位、乘一个固定数、再加一个固定数、再做一次异或。这些常数看起来眼熟,像某种公开哈希的收尾,拿去对常见哈希通常对不上。不要在这里猜算法名字。
如果目的是拿到注册码,不必把这条链走完。比较发生的时候,程序算出来的数和用户填的数会同时出现在虚拟栈上。自己填一个特别好认的注册码,比如每一组都是同一个数字,再去日志里找「一边是我填的、另一边是别的」那几条比较。另一边就是这台程序想要的值。
这个办法要核对三次才算数。同一组输入再跑两遍,取出来的数必须一样。换一个同样好认、但内容不同的注册码再跑,取出来的数还必须不变,这样才能肯定它只跟用户名有关。改用户名里的一个字符,这个数必须跟着变,旧注册码也必须失败。
放在一起看
|
VMP1 SPEED |
VMP1 MAX |
VMP2 SPEED |
VMP2 MAX |
| 派发器 |
一台,三句能认出来 |
一台,门口多 BL |
一台,ret 进去 |
壳一台 + 函数一台 |
| 你要先做的事 |
找到 lodsb |
同上,再记下 BL |
找到查表那一拍 |
先分清现在是壳还是函数 |
| 记账方式 |
派发器上逐条记,用栈的变化认指令 |
同左,多记 BL |
同左 |
方式相同,两台分开记 |
| 滚动解密 |
几乎没有 |
字节和 BL 搅一轮就接近原值 |
有,但离栈顶近 |
字节还要再运算,结果才是下标或立即数 |
| 查找表 |
固定 |
固定 |
打乱后通常不再变 |
跑的过程中还在改 |
同一份 add_two 膨胀(原文件 115 KB) |
136.5 KB;48 条虚拟 / 约 600 行 x86 |
137 KB;229 条 / 约 4000 行 |
126 KB;152 条 / 约 1.2 万 x86 |
730 KB;函数 1991 条;全程约 34 万条 / 1.34 亿 x86 |
| 做到哪算完 |
指令集齐了,函数能写成普通代码 |
同左 |
同左 |
指令集也能学;要注册码的话,从比较的那一刻取 |
下次可以照着做的三步
- 先分阶段。刚启动就进虚拟机的,先当成壳。等原入口之后,再找函数自己的派发器。两边跳进去的代码不一样,就确定是两台。
- 在函数那台上按 VMP1 的格式记账。每条记下原始字节和当时的 BL。认指令只认栈:这一行算出来的,必须出现在下一行。
- 如果只要最终被比较的那个数,就填一个好认的输入,到比较发生的地方把另一边读出来。先别从第一个字符顺着混淆往下推。
VMP2 没有换掉 VMP1。speed 证明一台派发器时,一代的做法还能走完。max 多出来的是分清两台,以及把 BL 解密当成指令的一部分,而不是取指之前的一个小步骤。