内容和思路可以看看,有一点点概念就好,也可能还是我对VMP的思考太浅显了,道可道非常道的感觉吧
上文:七神VMP真正的分析与还原
作者: 人生导师
日期: 2026 年 9 月 28 日
注意: 这篇风格属于从头到尾开始写的,我是从创建项目时候就开了这个文档,所以一些踩坑思路就没有删除,只会在后面推翻,
从上篇来看,结构可以看出来了,但是还是没还原到程序,得到的是去虚拟化的 trace 日志,而非程序,现在应该想点办法,把我们这个第二代日志再收缩和结构化
这时候就真应该想一下收缩什么东西,怎么收缩,以什么进行收缩,其实可以回想一下程序都有什么
不是收缩指令,收缩是指“重复的执行行为”,第二代日志 → Structural IR 的过程,本质上是把动态执行序列中的重复、分叉、回边和调用边压缩成结构节点。
其实这五点已经说清楚了,然后来说后续思考和思路
第一种代码形式很简单,根据 worklist 思想把没有跳转(确定执行路径的内容)归类成一块,这块就不变了,也不会动
第二种带有条件的,这种在 VMP 内的范式和体现就是对 IP 的不确定推进,但是但是,VMP 的字节码是非常非常规范的(在当时执行的 handler 情况下,不考虑跨 handler 导致的指令不定长)不会说一个分支+1 一个分支+1.5,那加 1.5 那个分支还走不走了,下一个字节码没办法执行了,这时候其实可以考虑找看一下加的内容(true 和 false 分别加多少,然后我们手动的加减后续执行?有点抽象了,我也写懵逼了)
第三种是循环结构,为什么我敢直接说是循环呢?因为我从 VSCode 的侧边缩略图看它执行了 N 次一模一样,并且还有条件判断,比如下面的案例,可以得知上一条是 0x11b 当前是 0x104 这时候就可以反推走了什么分支了,首先减去 1 得到 0x103 再让 0x11b - 0x103 = 0x18,也就是说这两个是不相等的,再看最后一行,这玩意是相等的所以 IP 等于 0x11c 了,也就是这玩意极有可能是一个 while(reg_a != reg_b)的循环结构
第四种函数调用这个,直接当作默认的基本块处理就好了,调用不是分支,是一个必然会走的指令,只不过跳转到了其他地方然后再回来,并不会影响程序的继续
第五种错误处理就应该看虚拟机有没有实现了,京东的 VMP 是实现了的,错误会有一个小的分支处理然后直接 ret 回去,不过也是防止虚拟机出现意外并不是还原向的错误处理
其实到这里,我在想是不是应该重构一下我的 IR 产出呢?这玩意特太不规律了,现在这个更像是漂亮的日志,不像是能稳定提供下文的 IR ,作用与还原的 IR 其实应该像伪汇编一样,结构化起来,opcode 可以直接获取到,然后就是 operands 的操作顺序了,怎么说呢?搞这东西这么久了,其实也有点感觉了,上面那种产出说明已经可以做到识别操作顺序了,搞一个归一化,opcode dst src(一个或者两个操作数),还有一件事就是复合指令集做了很多步骤,多步怎么像 P-code 一样解析成单步
做出来是真出来了,但是吧,有点操蛋的是它真的太汇编了,并且还给我上了一个伪 SSA,看一下输出吧
新版本的引入了相对中间值,在全局看可能会发现有很多 t 系列变量,但是这些变量的作用域是本次 IR 产出,边界就是 IP 的变化
IP 实际值,而非理论值因为我们只有 trace,0x119+1=0x11a 这个是最后一步发生的事情,但是它的 IP 在被解析的时候就已经成了 0x11a,所以 IR 也就显示的是 0x11a,哈哈哈哈哈,我想起来一个小妙招,直接在写文件的时候直接输出一条线进行拦截,这样就能明确表示每个 handler 的范围了
根据我们前面说的内容,可以尝试写一下这方面的代码和内容了,首先这玩意肯定是有一个蓝本的,也就是全部解析的内容,不过有必要再次重申我们得到的:
根据设备 trace 解析得到去虚拟化的 trace,根据去虚拟化的 trace 对虚拟机结构进行恢复
哈哈哈哈哈哈哈,这东西感觉用图算法或者自己硬写缓存+匹配也可以,图算法肯定是方便一些的,甚至我觉得都可以用 shell 脚本进行收缩,我称这个过程为去 trace 化,把 trace 给折叠收缩起来,不知道啥时候能来一个灵感让我对 mem 有使用空间啊
去 trace 化做出来了,prog.txt 落了盘。但先说个丢人的:卡住我很久的那个点,上次其实已经摸到边了,只是我没敢往下想。
上次写到这里的时候我说过这么一句:
IP 实际值,而非理论值因为我们只有 trace,0x119+1=0x11a 这个是最后一步发生的事情,但是它的 IP 在被解析的时候就已经成了 0x11a,所以 IR 也就显示的是 0x11a
我当时把它当成一个「显示上的小瑕疵」,随手加条横线拦截一下就完事了。其实它就是答案。
行尾那个 <vPC> 不是「这条 handler 执行时的 IP」,是 handler 回写 [x23] 之后的值 —— 也就是 post-IP。lifter 里 IR.rIP 的注释其实早就写明白了,原话是「该 handler 回写后的运行时 vPC」,是我自己没当回事。
一旦认下这条,一堆东西自动就位了:
把控制线的形状全捞出来看,就二十来种模板,核心其实是一个:
于是分支、循环、跳转全归一到这一个式子,解出来就是「真枝去哪、假枝去哪」。
我第一版就是这么写的:按 IP 全局去重,同一个 IP 归成一条指令。跑出来一看,1789 个不同的 IP,1177 个是撞车的 —— 同一个 IP 底下挂着好几种完全不同的指令。
一开始以为是 VM 又在搞混淆,后来才反应过来是我蠢:每个 VM 函数各有一套 IP 空间,都从 0 起。 所以「0x79」在这份 trace 里同时是六个不同函数里的六条不相干的指令,跨函数 grep 这个数字必然串味。
那怎么认函数?试过拿操作数表地址聚类 —— 也不行,那是每个调用点各一份的副本:同一个函数从两个地方调进来,ldr t2, [0x7d93c34f70] 里的地址就换了。实测有两个函数(我编的 func 27 / func 31)999 行里只差这一个地址,差点被它骗过去,以为是两个函数。
最后落到「同一 IP 上是什么指令」的指纹聚类。指纹里还得再抹掉一样东西:≥5 位十六进制的常量。因为那些是运行时折叠出来的值,同一条指令每轮跑出来都不一样:
阈值卡在 5 位是权衡出来的:帧大小 -0x4a0、栈槽偏移 0x498、跳转偏移 0x1b 这些结构常量都在 4 位以内,留着刚好够认函数。
VM 的调用约定是:先抬栈,紧接着把返回址存进新帧。
我一开始就靠这个认调用,跑出来 38 个帧、看着挺美。结果发现有 31 个返回配不上 —— 前面明明没进过这么多帧。
原因是有批函数压根不抬栈 —— 叶子函数,序言一个字都不写。只能退回来看 IP 断口:
顺序执行时 post 恒大于 pre(post = pre + 编码长度),post 反而变小,就是换了 IP 空间。
但这个判据我第一版写错了,写成跟「上一条」比,立刻炸出 9000 多个假帧。原因很简单:回边也是 post 变小。
判据必须落在当前这条上 —— 回边自己一定含 add ip,,而调用跳进来那一条是普通指令。改完 69 个帧、零漏配。
工具叫 hy.py,输出 prog.txt,33 个函数、9183 行。长这样:
顺手把 VM 自己的取数舞折了。每条 VM 指令都要先把操作数从表里挖出来,handlers.log 里最开头那条长这样:
这三行是编码不是语义,等价于「这条指令的立即数」,只不过提升器没内存、折不出值,只能原样摊开。顺着临时量的 def 链把它们拼成表达式再代回去,四条并成一条:
中间那串 *(*0x7e23caa3e8 + 0x2) 就是操作数槽的运行时地址,留着当回查 IDA 的锚点,别丢。
还原对不对,得有东西验。能拿来验的只有 trace 自己:
trace 里每一步的实际后继,应该落在解出来的两路落点里。
129648 条转移,对得上 129645 条(99.998%),剩下 3 条散在三个函数里各一处。到这个数我才敢说这东西能用了。
得诚实,这三堵不是我写得烂,是方法本身的天花板:
一、动态 trace 永远只有一条路径。 164 个跳转落点里 27 个(16%)指向从没执行过的槽位:
这次 v5 == v0 一直是假,那一支一次没走,所以 func 0 里根本没有 0x79 这条指令。想要它,只能换输入重跑。没有一个动态 trace 能给你整个程序。
二、常量是每次调用解密出来的。 就是前面说的 func 16 / func 18 —— 同一个函数调两次,999 条指令只差 3 条,全在 mov VREG[0xf], 0x40a1... 这种地方。这批值不在字节码里,在密钥流里,把 VM 拆得再干净也拿不到。