Jsvmp反编译
原创 dlopen Frida and So 2025-08-18 09:20
简要
jsvmp 是一种用于保护JavaScript 代码的技术。将JavaScript 代码转换为自定义字节码,依靠自定义虚拟机执行。vmp 技术在PC ,Android ,Web 算得上是代码保护的顶点。而目前Web 端主流的vmp 还是堆栈式。
主流逆向手段
目前最常用的两种手段分别是补环境,插桩 。
反编译技术 在web 端实现算是三端最为轻松的了。
源代码
如下代码为原始代码,最后反编译完成后会与其进行比对。由于vmp 保护后的代码太长,在此不展示,如有需要,请联系我。
//这些代码+默认打印,vmp需要执行60次操作,可见单步调试的困难
let a = 2;
let b = 4;
if(a > b){
a = a + 2;
}else{
a = a - 1;
}
let c = b + a;
console.log(c);
VMP分析
首先对VMP 的构造进行分析,encode_str 为解码函数,解码后返回一个数组,数组存放数据依次为字节码数组,字符串常量池,堆栈,作用域,this 指向。入口处通过apply 方法调用,作用域为window 全局,并将返回的数组传入。
VMrun.apply(null, encode_str);
/*
c → 字节码数组(指令流,switch 的 case 就是 opcode)
n → 字符串常量池
e → 运行堆栈
a → 当前作用域对象
o → this指向
p → 程序计数器起始位置
*/
function VMrun(c, n, e, a, o, p) {
var r = c.length;
let t, l = 0;
var b = [], k = [];
let i, u = 0;
//下面就是具体的指令集....
}
指令集handle 可以说是vmp 中可以最快被识别的地方,重点还是要理解指令集之外声明的那些变量的作用,下面依次进行解读分析:
- var r = c.length;
- 保存字节码的总长度,用来做 for (let s = p; s < r; s++) 的循环边界。
- let t, l = 0;
- t → 当前计算结果的寄存器。几乎每条指令都会读写它,比如 t = u + i; 。
- l → 最近一次对象操作的上下文。
- var b = [], k = [];
- case 16: k.push([c[++s], a]); → 保存 catch 地址和当前作用域。
- catch 时 恢复指令位置和作用域:
if (!k.length) throw e;
var h = k.pop();
s = h[0] - 1, a = h[1];
- b → 辅助栈 ,主要给迭代器和临时保存用。
- k → 异常处理栈 ,存储 try/catch 的返回点。
- let i, u = 0;
- i, u → 临时寄存器,与t 不同的是它俩存储的是操作数。
- 大多数算术/逻辑运算指令都用 i 和 u 配合。
反编译
本次反编译不会介绍太难编译的一些节点,这些节点后续单独出,比如条件分支
首先引入Babel 插件,并构造一个根节点,后续用于存放生成的AST 节点,然后在switch 处下断点,下面就是根据不同的指令操作编译不同的节点。
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generator = require("@babel/generator").default;
const types = require("@babel/types");
let astBody = []
astTree = types.program(astBody)
function VMrun(c, n, e, a, o, p,astbody){//给入参添加一个astbody
指令 26
第一条指令,从字符串常量池取对应索引的值入栈。对应修改如下
case 26:
e.push(n[c[++s]]);
break;
//===========
e.push(types.stringLiteral(n[c[++s]]))
指令 2
根据c[++s] 选择将取出的值存放到哪个临时寄存器。相当于声明一个字符串变量let t = string
case 2:
c[++s] ? l = e.pop() : t = e.pop();
break;
指令 10
将临时变量进行入栈操作。
case 10:
e.push(t);
break;
指令 30
弹出临时变量,判断是否存在于当前作用域,若没有且存在父作用域则继续向上查找,然后从作用域取出对应的值并存到t 中。为了便于理解,直接默认u = this 。
for (i = e.pop(), u = a; !(i in u) && u["PAR SCOPE"]; u = u["PAR SCOPE"]) ;
t = u[i]; //u[i]成员表达式
//=========
t = types.memberExpression(types.thisExpression(),e.pop(),true);
指令 31
通过先入后出原则,把栈顶的两个元素交换顺序。
case 31:
e.push(e.pop(), e.pop());
break;
指令 14
成员访问操作,给l 赋值一个this 指向,为函数调用做铺垫。类似于这种操作,t = {“a”:1},l=t,t=t[“a”] 。
case 14:
t = (l = t)[e.pop()];
break;
//=========
l = t;
t = types.memberExpression(t,e.pop(),true);
指令 15
函数调用,调用完成后将栈中函数参数弹出,将l 赋值为当前作用域的this 。
case 15:
t = t.apply(l, e.slice(e.length - c[++s])), e.length -= c[s], l = o;
break;
//=========
let args = e.slice(e.length - c[++s]);
let callexp = types.callExpression(types.memberExpression(
t,types.identifier('apply')
),[
l,types.arrayExpression(args)
])
let val3 = types.identifier('v_0');
let ast3 = types.variableDeclaration('let',[
types.variableDeclarator(val3,callexp)
])
astbody.push(ast3);
e.length -= c[s]
t = val3;
l = types.thisExpression();
指令 0
入栈一个number 值作为索引。
case 0:
e.push(c[++s]);
break;
//===
e.push(types.numericLiteral(c[++s]));
指令 28
给临时变量存储一个值,这个值就是源代码中a,b 声明的数值。
case 28:
t = c[++s];
break;
//========
t = types.numericLiteral(c[++s]);
指令 8
把这个值按索引存储到了作用域对象中。为了便于理解还是默认作用域为this 。
case 8:
a[e.pop()] = t;
break;
//=======
//将变量的值添加
let ast1 = types.assignmentExpression('=',types.memberExpression(types.thisExpression(),e.pop(),true),t)
astbody.push(ast1)
指令 34
根据c[++s] 的值选择对应的判断条件。
case 34:
t = c[++s] ? e.pop() < e.pop() : e.pop() <= e.pop();
break;
//========
if(c[++s]){
t = types.binaryExpression('<',e.pop(),e.pop())
}else{
t = types.binaryExpression('<=',e.pop(),e.pop())
}
指令 13
这就是条件分支了,对于这种分支节点,面对新手来说选择最简单的办法,在t 处下断点,手动去判断它走哪个分支,这个节点的编译可以单独开一篇,在此不进行过多讲解。
case 13:
t ? s++ : s = c[++s] - 1;
break;
//根据条件手动去改
指令 3
加法操作,把栈顶的两个操作数弹出来。
case 3:
i = e.pop(), u = e.pop(), t = u + i;
break;
//============
t = types.binaryExpression('+',u,i)
指令 19
创建一个新的作用域对象并连接到父作用域。无视即可。
case 19:
a = {"PAR SCOPE": a};
break;
指令 9
跟指令30类似的操作,不过这次是赋值。
case 9:
for (i = e.pop(), u = a; !(i in u) && u["PAR SCOPE"]; u = u["PAR SCOPE"]) ;
u[i] = t;
break;
//=========
let ast7 = types.assignmentExpression('=',types.memberExpression(
types.thisExpression(),e.pop(),true
),t);
astbody.push(ast7)
指令 1
栈顶弹出。
case 1:
e.pop();
break;
指令 20
退出当前作用域,恢复到父作用域
case 20:
a = a["PAR SCOPE"] || a;
break;
指令 11
跳转语句,这边都是if-else 遗留问题。
case 11:
s = c[++s] - 1;
break;
结果
编译完成后,generator(astTree).code 生成反编译后的代码。之前为了便于理解编译,默认全部为this 作用域,现在修改一下代码,然后放到浏览器运行出值。
let v_0 = this["console"]["log"].apply(this["console"], ["Frida and So"]);//网站默认的打印信息,我在此修改了
//下面才是源代码
let varobj = {}
varobj[1] = 2
varobj[2] = 4
let v_1 = varobj[1] + 2;
varobj[1] = v_1
varobj[3] = varobj[2] + varobj[1]
let v_2 = this["console"]["log"].apply(this["console"], [varobj[3]]);
总结
目前我对国内、国外的一些vmp 进行了反编译,国内的vmp 有些的结构都差不多,指令集也不多,混淆也不多;国外的有些vmp 对自身代码进行了保护,指令集巨多,让你在分析vmp 结构的时候更加困难。
这三种vmp 解决办法各有优缺点,要互相结合使用,最后wasmvm 也已经出现(某乎 ,cctv ),目前还没有trace 工具的情况下逆向算法是非常困难的,起码so 还有个unidbg trace ,而wasm 只能单步调试,静态看ida 。