只对 VMP 本身进行研究与还原不对其中算法还原,我这个思路投了SDC了,但是现在又感觉这东西算是工程方面非常强但是思路组合,适合作为长篇大作来讲或许不太适合当成一个小篇章的上台发言吧,既然我发了那能不能选上就听天由命
作者: 人生导师
日期: 2026 年 8 月 31 日
注意: 这篇风格属于从头到尾开始写的,我是从创建项目时候就开了这个文档,所以一些踩坑思路就没有删除,只会在后面推翻,
心血来潮想再看看七神,最新40.2.0版本的,豌豆荚最新,依旧是老生常谈的定位、找入口
应用信息:
liba.so: 0x370B64
libb.so: 0x2e01b8
Android Version: 40.2.0
关于定位这块我突然想到一个方法,直接 objdump 找出来blr x23然后上下找类似下面的结构,这样直接定位到了,不过非常非常脆弱,还是推荐搜字符串或者直接啃出来它能这样做的原因,我是找不出来这玩意为啥能跨 so 调用
这特征就很复合函数指针的用法,变量调用
特别说明:本帖不提供一键脱壳或过检测代码,仅公开我在还原 VMP 虚拟机执行框架时的寄存器锚定方法论和 IR 提升过程中的卡点。我知道它不完整,但市面上公开资料里,大多数还是在讲 handler 和字节码,手动分析
依旧是 VMP 方面的内容但 VMP 也确实是一个难点,各种各样的无限变体,各种各样的思路,还是先来回顾一下
Unpacking Virtualization Obfuscators(WOOT 2009)的六步法则
然后看我们做了什么方面的简化和优化
首先直接来一遍流程
这就是从外层混淆一步一步进到 VM 的流程了,来看第一个 handler 的汇编逻辑,我也是小小做了一些语义化,这个还好追一些,如果是 OLLVM 太长就参考六神的处理方式吧,我是简单小看了一下
那么也就差不多理解了锚点位置了
IDA + trace 交叉验证
sub_2E5C68(0x2e5c68,由 0x2d3780 调用,初始化 VM 上下文):
trace 里 VM 入口前 [x24+8] = 0x7853c84200 = ctx+0x6030,与上完全吻合。
全 trace 共 34.3 万次 [x24, idx, lsl/uxtw #3] 索引访问,索引值严格落在 [0, 31] → 32 个 8 字节槽 = 0x100,恰好填满 0x6070 → 0x6170
所有 handler 一致:a1=x8(当前 VM 指令),操作数在 [x8+8]/[x8+9]/[x8+0xA],访问虚拟寄存器一律 *(x24 + 8*idx)。实测示例:
reg[dst] = reg[src] + signed_offset,其中 src = 字节码[8],dst = 字节码[9](上文第二个版本注释写反了):
来看一个实际的 handler 吧,从 trace 里面拿一条简单的,handler 表的数量是真不少的,基础 handler 是从 0x3E1E58 到 0x3E3708 一共是 1580 个,调用到了 335 个(日志量 340 万),然后还有 VMP 专属的 CF 系列函数调用,也是要单独处理的,还有 VMP 的栈操作或者寄存器归一化操作,这里就可以直接采用编译器前人所说的多趟(Pass)了,最著名的就是 LLVM Pass 了,相信各位大佬有研究过 OLLVM 的也看过这东西
首先我们确定一下我们要注意的:IP、(src1、src2、dest)三个寄存器,还有一个棘手的东西x8寄存器,这东西改后给下一条使用的,这时候就需要一个标记位置,我斗胆猜测一下nop就是给x8的变化准备的,随后就给我打死了,这玩意是真没啥规律啊,我也是真没招了
这时候就需要冷静下来了,从简单的开始想:
这三条主要的线就迭代出来了,第一个负责我们切割 handler 的边界,第二可以看执行的对不对,第三个就是 handler 逻辑方面的了
在具体看之前,我们来找一下控制 VM 返回的函数,找到以后就可以知道 VMP 的嵌套深度了,入口 0x2e6774(sub_2E66D0 里 bl vmOneHandler)和返回 0x2ebc54 能精确对上之后,调用深度就有戏了。这里记一下怎么找到的这个 RET,算是 log + IDA 对拍的一个标准打法。
VM 里所有 handler 都是 br 尾跳(threaded interpreter),唯一一次真正的 ret 就是返回。而每个 wrapper 都是 bl vmOneHandler 进 VM,bl 会写 LR,所以:
在 code.log 里搜 ret 指令,且执行前 LR = bl 设下的返回地址,命中的就是 VMP 的 RET。
全 trace 61 次 VM 执行(bl 在 0x2e6774,LR=0x794da10778),全部命中同一个 ret —— 模块偏移 0x2ebc54,返回点 0x2e6778,精确弹回 wrapper。
它就是个普通 handler,不是特殊路径,opcode 0xbd 分派过来的:
链尾的普通 handler(0x2e8e7c)读到下一条 opcode 0xbd → handlerTables[0xbd] = 0x2ebc2c → br x11 接进返回 handler,跟其它分派一模一样。
其实到这块以后我都想直接写汇编还原了,我也感觉可以像一个办法尝试汇编去做这块内容(?自己写一个库),因为到 IR 层面以后就真很难直接对应 trace 了,这块就很蛋疼
文本解析之类的就不提了,我们直接定 x25 寄存器,然后看 x25 取值后的内容有没有被 br 跳转或者 br 跳转寄存器的值是不是被 x25 赋的,具体操作可以看看代码,我写的也是非常非常简单了
在 handler 切割的基础上,直接找 x23 寄存器,看它取的地方和存的地方,代入 trace 看看加了多少或者 P-code 画图找操作位置
这也是最难的点也是全篇的精华所在,这块就先上帝视角一下,先尝试解决难点
根据 ARM64 的指令集地址长度一致,从 bl、blr 等等调用指令的地址到加 4 的地址,被调用的内容写一个列表内,handler 逻辑写在一个列表内,这时候我在想要是 VM 调用 VM 怎么办?难不成真写那么多?
好像又不需要,对 CALL 类直接根据 trace 提取相关信息,调用点写描述而非全部内容
但是折的极限呢?VM 调用自己、封装的逻辑函数、libc 等等系统函数这些怎么处理?有一个思路,但是不是很靠谱直接深度优先,处理最深层次的 handler,然后逐步回溯,但是好像又不太需要想这种问题,我最前面都已经切出来 handler 分块了,我处理的只有 handler 这种颗粒度的东西,感觉还需要一次折叠,然后根据 vmp 的锚点寄存器进行数据流追踪,这时候就差不多可以理解到什么函数是操作 vm 相关(栈、寄存器),什么是原生函数,然后开始折叠
第一次折叠拆出来 handler 的操作,第二次折叠拆出来 VM 在执行原生逻辑前做了什么东西,怎么准备参数和返回值的
第二次折叠这块还是图算法,看看都准备了什么锚点寄存器或者是对上下文怎么操作的,然后怎么传递给原生函数的,原生函数的还原不在我们考虑方法内,这块可以考虑直接输出原始日志交给模型看
参数方面也要对齐,什么参数交给谁了,如果不写明白模型就跟傻子一样,瞎对照了
(上帝视角:这块我后面直接选择了递归函数调用栈了,不过是只看 blr 和 bl 两个内容,看看 blr 第二次走的都是谁,是不是 x8 这个标记,如果不是的话走的是谁,因为我们已经有了全部的调用栈了,遍历一下也不难)
现在又迷起来了,这是真有点懵逼了,卡了两个点
外层像是一个小跳板层,bl 到一个函数内,但是那个函数除了开栈之外什么都没做
这种这种情况很多,但是再仔细观看一下,就能发现这玩意两个参数,第一个参数是字节码,第二个参数是 VM CTX,我们就可以利用这一点进行思考,因为这种特征也非常非常少,这块也可以作为一个特征进行匹配
接下来看 CALL 类调用的 handler,这也挺抽象的
一步一步的调用进来的情况,这块最难的也是 x8 的处理,万一它不走 x8 呢?这块不炸了?(哈哈哈哈哈,上帝视角来了,这确实全走 x8 间接调用)我想的是加一个 if 或者 try,看看不走的情况,这种思路的好处就是可以看见走或者不走都经历了什么可以二分定位,OK 接下来就可以确定写法了
我打算用 Python 的match ... case ...写,首先就是特征的匹配,其次是对间接调用的匹配(上帝视角:后面用的递归最深栈调用进行二分排查了,没有软件流水线)
其实还可以外层 if 语句来判断是不是复合语句,然后在间接匹配时候用 match 看有没有走 x8 间接调用
这点就也需要一个函数来进行操作,我们来看一个实例
我们可以看见它直接通过索引访问虚拟机的整数寄存器文件,现在函数名也不是很好,但是先这样吧
这块吧其实也可以继续做折叠然后看全部的东西,每个到这的就输出它这个函数又调用了什么函数,做特征匹配,这还是得益于 VMP 的精确构造,要不然是真没办法一直对特征进行总结,就算是 Windows 上的 VMP 本尊也是巨大的特征否则也不会被整理出来那么多还原资料了
(上帝视角:对于这块其实可能真的只有普通的整数寄存器有,浮点数寄存器我是完完全全没找到相关的内容,这主要和 VM 构造有关了)
昨天给一个 CTF 出题去了,虽然只有一道安卓题目,今天继续思考接下来的处理
首先是改了一下对于调用栈的东西,换成递归形式了,这样也可以更好的查找和拼接数据,感觉马上要开始写图算法了,好难啊,不过车到山前必有路,船到桥头自然直,不用担心这一块,思考一下前瞻性问题吧,说难也难说不难也不难,可能是我畏难心理吧
基本 handler 图算法
bl 调用的复合 handler 图算法
调用其他函数的 handler 图算法
VM CTX 的相关的函数操作,这也是图算法所需要实现的内容,不过这块肯定不会非常多的,实在不行就再 tm 的找规律去
逻辑函数的处理
wc 了,就在刚刚模型给了我一个顶级思路
影子轨道
由于我们选择 P-code(实际是任意的,只需要保证 IR 的不变性,不能让汇编每次提升出来有随机性) 进行 IR 的提升,所以这时候就可以考虑提升两代出来
不过啊,还是有一些难点的,有 IR、实现、代入等等方面的,我先说我想到的困难
解法来了,也是吃上 IR 的红利了,我用 P-code 的
首先汇编会提升出来,这样就有了模板了,这时候我们会有varnode,这玩意不知道是啥就去看 P-code 的使用基础中的基础了,然后我们拿这个模板进行寄存器的遍历与代换,把实际 trace 记录值代入模板得到第二代产物,也就是有了具体值,这时候我们就解决了所遇见的大难题,接下来我拿一条汇编进行演示
在 varnode 层折叠,把 varnode 的 space 换掉
这里我引入进来那个影子轨道的思路吧,让模型整理的
两代 IR,同源同 varnode:
关键认知:值来自 trace(动态执行),不是第一代自己算出来的。第一代不执行不产值,只回答"这段指令长啥样";trace 回答"这次跑起来值是啥"。两代是同一批指令的两个视图,别混成一个东西。
对齐(影子身份):两代从同一批字节、同一套 SLEIGH 提升,unique varnode 对同一条指令分配是确定性的,所以共享同一套 varnode 命名空间。影子身份靠这个,不靠"两代汇编都合法"。PC 锚点用 RVA(trace 第三列),不能用绝对地址(ASLR 漂移)。
折叠的本质:varnode = (space, offset, size),折叠 = 把 varnode 从 register space 踢到 const space。分两步:
unique 生命周期只在单条指令内,trace 不记它,只能靠"输入寄存器折成常量后化简"消掉。顺序:先折输入,再化简。
折叠边界(trace 给了啥才能折啥):
varnode 红利:SLEIGH 白送寄存器重命名 + 值编号。汇编里 x10 复用、内存别名、条件码这些最脏的隐式数据流,提升时全显式化,每个值一个 varnode,跨指令 def-use 现成。这条 def-use = DFG 骨架,直接接"DFG + 反向污点"那套。所以 P-code 不是"任意的"——换个没 varnode / 显式 def-use 的 IR,这层得自己重写。
两个坑:
难点答案:
这块实现直接看我代码好了,然后遇见一个小屌毛的地方,发现我没寄存器全部的映射啊,然后自己问模型然后给自己气半死,最后还是翻代码,然后找到 ctx 函数可以获取全部寄存器的偏移,最终实现如下,用的时候直接 Python 语法拿就行了
给各位看一段吧,避免不知道发生了什么,这个是我替换出来的东西,然后就是准备拿这个进行还原的,分号后面是地址、汇编,还原期间没分号行内容
这时候我们有了值,但是吧,还得想一下怎么去做分析我的思路是直接写三个函数进行,因为上述三条线,但是其中 x25 的寄存器跳转又现的不那么重要,所以主线就是先定把这些寄存器明确出来
燃尽了,脑子给我看麻了啊,不过还是冷静一下看看来源再说,这可见另一篇文章了,P-code IR 解析与使用篇章
思路理清了就该动手。三条线里我先啃 x23 —— 它一条就能定下这条 VM 指令的控制流效果(顺序还是跳转),后面几条线都得落在它上面。
一开始想得挺简单:handler 里 ldr wN, [x23] 和 str wN, [x23] 就那两条,读 trace 的读列写列,取出来的值、存回去的值不就都有了?汇编层确实能做。
但是不对 —— 我都把 IR 提升到 P-code 了,再回头去抠汇编字符串,那这一代提升是白干的。
真上 P-code 才发现问题:
x23 在 P-code 上不是「被读的寄存器」,是「被 COPY 出去的一个地址常量」。
所以按 space == "register" 去扫 inputs,收上来一堆 COPY,啥也说明不了。写侧同理:
「存回去的值」确实在 STORE 第 3 个操作数上,但「哪个 STORE 才是存 IP 的」得先顺着 varnode 把它找出来。
先记一个坑:锚点寄存器一律按 offset 认,不按名字。同一个 offset 上 w23/x23 是两个视图(size 4 和 8),按名字认会漏。x23 = 0x40B8。
然后扫一趟 shadow(SSA 序,def 先于 use,单趟够用),给每个 varnode 记一个「你的值是从哪条线派生来的」:
最后那条得强调一下:unique 的 offset 会被后面的指令复用,扫完再回头看那一格早就被覆盖了,分类必须在扫的过程中做。
这个函数不是只为 x23 写的 —— x24(虚拟寄存器文件)、x25(handler 表)就是同一个函数换个种子,DEREF 表里位置都留好了。
两个点都在 shadow 上取:
一个 handler 可能写两次 IP,取最后一条。
2M 行切片,46899 条走到这条线的 handler:
三个发现:
① step=1 只是顺序落下一个 slot,但 step=3/4/5/6 是常态。 我原来以为「一条 VM 指令 = IP+1」,被打脸了。实测 0x2fa4d8:ldr w11,[x23](0x19)→ add w11,w11,#4 → str w11,[x23](0x1d),然后 smaddl x10, w11, #0x30, base 算出 base + 0x1d*0x30,跟 trace 完全吻合 —— IP 确实是 0x30 字节 slot 的下标,这条指令就是占 4 个 slot。
② 负数 = 条件跳转。 抽到 0x2eae28,正是前面表里那个 CMP/BEQ:pre=17, post=6, step=-11,CSEL 选出 offset=-12,-12+1=-11 对上「if(eq) offset=0; IP += offset+1」。
③ 41 条「只写不读」全是 0x2ebc2c —— 就是前面那个 RET handler。ldrb w8,[x8,#8]; ldr x8,[x24,x8,lsl#3]; str w8,[x23]:把 regs[idx] 当 IP 写进 ctx+0xC(槽复用),压根不读旧 IP。分类和前面的分析对上了,算是交叉验证。
还有个值的说道:0x2eae28 那条里 X23 的值是 0x7853c6aefc,而 0x2e87e8 里是 0x7853c7e1dc。同一条 handler 在不同 VM 调用里,ctx 基址是不一样的 —— 按寄存器值去认锚点行不通,必须按 offset。这个设计躲过一坑。
上面那个「1 条拿不到新 IP」本来没在意,结果一查带出来个大的。
先是发现一批 handler 取不到值(一开始 38 条),追下去发现它们的 entry_off 是 0x2e6778 —— 这不是 handler,是 wrapper 里 bl vmOneHandler 的下一条指令。把那段 dump 出来更离谱:
根因是**「段」没跟着调用栈走**。current_depth 只是个计数器 —— 它知道「现在在第几层 VM」,但不知道「每一层的段从哪开始、断在哪、返回后该续到哪」。段是一个全局 cur_handler,没有栈。
两个具体的点:
量了一下:切片里 46 次 ENTRY,45 次是嵌套触发的,每次丢掉的段长 57~1997 条指令。也就是说大批 handler 的头(含 ldr w9,[x23] 那一段)都是被丢掉的,剩下的尾巴被粘进假段里。
修法很简单,段入栈:
修完:
包装层那几条会续进外层 handler 的段 —— 这是对的:外层 handler 本来就是 bl 到 VM 入口的,折叠层会把它们连同被调的整个函数体折进那个调用节点的 body,不进 main。
修完还剩 1 条,0x2e837c。它是真 handler,只是不走 x23:
[x23] 和 [x19, #0xc] 是同一个槽的两种寻址 —— x23 就是 ctx+0xC 的指针。这条 handler 走的是后者,所以 x23 的种子扫不到它。
覆盖情况(2M 行切片):
切片里就这一种 handler 走 x19 路径,而且只跑过 1 次。要覆盖它得让标记函数认「地址 = INT_ADD ctx, 0xc」这个形状(等于给色彩加偏移量),为一个跑 1 次的 handler 加这套不划算,先挂着。
倒是它那个 /4 值得记一笔:尾巴 dispatch 用的是 IP * 0x30,这里是 /4,说明它算目标走的是另一套寻址约定。样本太少,等写到 CF 类语义再回头看。
x23 那条线定的是控制流,x24/x26/x27 这三条定的是操作数。动手之前我以为得顺着指令往下推 —— 从 handler 头开始,看它一步步算出什么。真写起来发现得反着走:从写虚拟寄存器文件的 STORE 起,往上游跟 def-use。
理由很简单:一条 VM 指令的结果就落在那几个 STORE 上。往前推是发散的(一条指令写几个槽,每个槽的值又各有一堆中间量),往回追是收敛的(每个 varnode 只有一处定义)。
两条线:
p-code 不是严格 SSA(寄存器槽反复写、unique 槽跨指令复用),但块内是顺序语义:「use 之前最后一次写」就是它的 def。所以反向走是确定的,不用解数据流方程组。
切片本身只要结构,但后面的折叠要值。值还是走影子轨道那一套,三档:
反查那条要按 LOAD 自己的宽度截断(后面 sext 了不算),否则 ldrsh 读出来的 u16 会带着符号扩展。
第一条能跑的 handler 是干净的,两行就完了。换一个就崩了 —— 0x2ea7cc 直接吐出来一棵几百个节点的树:
SLEIGH 把 BFI W8, W9, #16, #8 展成 (dst & ~mask) | (rot(src) & mask),再叠上 UBFX/LSR 的提取,一条指令膨胀成几十个算符。这些移位/掩码是解码头 —— 把字节从字节码里挑出来按位拼成操作数,不是 VM 语义。不折的话,IR 就是这堵墙。
不猜、也不写规则表,用 trace 值本身当判据:算式的第 k 字节是从哪个内存字节来的。位对齐(整字节的移位/掩码)才追得动,追不动就不折。
掩码/或/移位按字节逐道推:& mask 掩掉的道是恒 0、整字节保留的道跟着走、半字节跨算的不追;>> 8n 把道整体挪下来;| 两道合一,一边是常量 0 就让另一边过。
够不够格折成"读",三条判据缺一不可:
两个坑都值得记:
① 只比值会认错字节。 0x2ea7cc 这次 W8 & 0xff 和 W8 >> 16 都等于 0x14 —— 字节 +8 和字节 +0xa 恰好同值,拿值去撞候选就撞到别的字节上了。所以顺序必须是先由来历定是哪个字节,值只做确认。
② 来历相同 ≠ 位置相同。 第 2 条判据就是这么来的:u16[+8] << 8 的字节 0 也来自 +8,但那是移位不是提取。这个是在 0x2fdaf0 上抓到的 —— 那次字节值恰好是 0,只靠值比对判不出来(0 和 0 相等),只能靠"落在哪一道"。
折完就是手画的那棵树:
(| 0xa4 是回写后的 IP,见下面 IR 那段。)
中间换过几版形态(语义摘要 + 表达式树 → 逐指令左右对照 → 只要 IR),最后定下来是一条 VM 指令一行:
几条规则:
上面那行 *(VREG[0x1] + 0x498) = VREG[0x3] 是后来才有的。中间有个 handler 让我一度怀疑反向是错的:
它一个字节都不落 x24 —— 这是 VM 的 store 指令:把 VREG[目标] 写进「VREG[源] 指向的对象 + 偏移」。我原来把种子定成「写 x24 的 STORE」,这一整类全被过滤掉了,看起来就像"这条 handler 没算东西"。
结论是:反向没问题,是种子太窄。 种子应该是所有 STORE —— 写虚拟寄存器文件和写内存都是「这条 VM 指令干了什么」;读(load)本来就会在值表达式里被反向带出来,不用单独收。
改完之后它出来的是:
拿 trace 对:ldrb w10,[x8,#8]=1(源)、ldrb w13,[x8,#9]=3(目标)、ldrsh x14,[x8,#0xa]=0x498、X10=0x6d9da72d60=VREG[1]、X12=0x0=VREG[3],全对上。
全量 749 万行 trace 跑一遍:
「每个 entry 都有」这句是放宽种子之后才成立的:之前有 15 个 entry 一行都打不出来,因为它们是纯写内存的(一个字节不落 x24)—— 种子一放宽,这 15 个全出来了,正好补上 266 这个数。
40 万行抽样那趟也顺手量了:4567 个带 IR 的段、12003 行 IR、0 条表达式撞上限 —— 折叠把位拼装真的收干净了,不是靠截断遮丑。
最后是那个感悟的引子,一屏 IR 里规律自己往外冒:
偏移每次 −8、源槽号递减 —— 一个结构体字段的批量搬运,展开成直线 IR 之后规律自己浮出来了。同理 (*(0x70173276a8) + 0x0/0xe0/0x140) 是同一个对象的字段访问:基址一样、常量偏移不同。
顺着这个再往下走一步就很自然:现在行尾只有一个 IP(回写后的),把取出来那个 pre 也带上写成 0x2 -> 0x3,这 23 万行 IR 就不再是孤立片段,而是 VM 层的执行序列 —— IP+1 是顺序、跳变是分支、回跳是循环,CFG 和循环边界自己就掉出来了。

很容易看出来程序一些结构,后续再进行还原到伪 C,不知道自己能最终做到什么程度,这也让我开始回想程序还原和逆向的真谛,尤其是还原二字的含义到底是什么,忆往昔魏武挥鞭,想到从计算机诞生到现在我坐在这里还原这个东西了。
就这样吧,看雪版本到此完,还是那个理由,再多发就该被律师函警告了,有思路相互或者大佬可以加我一起学习,我也在想怎么去拓宽思路,怎么真正的做到还原一个程序而非一条看出来vm结构的东西
| 地址 |
语义 |
| 0x2eae28 |
CMP/BEQ:if(reg[a]==reg[b]) offset=0; IP += offset+1 |
| 0x2eb03c |
SHL:reg[dst] = reg[src] << (shift+32) |
| 0x2e92bc |
OR:reg[dst] = reg[a] | reg[b] |
| 0x2e96b8 |
LDRB:reg[dst] = *(u8*)(reg[base] + i16) |
| 0x2e8e40 |
STR:*(reg[base] + i16) = reg[val] |
| 0x2e95cc |
LDR32→32 位浮点文件:*(u32*)(x26 + 4*idx) = *(u32*)(reg[base]+i16) |
| 0x2e9e90 |
64 位浮点 →STR:*(u64*)(reg[base]+i16) = *(u64*)(x27 + 8*idx) |
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。