Jsvmp反编译

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 中可以最快被识别的地方,重点还是要理解指令集之外声明的那些变量的作用,下面依次进行解读分析:

  1. var r = c.length;
  2. 保存字节码的总长度,用来做 for (let s = p; s < r; s++) 的循环边界。
  3. let t, l = 0;
  4. t → 当前计算结果的寄存器。几乎每条指令都会读写它,比如 t = u + i; 。
  5. l → 最近一次对象操作的上下文。
  6. var b = [], k = [];
  7. case 16: k.push([c[++s], a]); → 保存 catch 地址和当前作用域。
  8. catch 时 恢复指令位置和作用域:
if (!k.length) throw e;
var h = k.pop();
s = h[0] - 1, a = h[1];
  1. b → 辅助栈 ,主要给迭代器和临时保存用。
  2. k → 异常处理栈 ,存储 try/catch 的返回点。
  3. let i, u = 0;
  4. i, u → 临时寄存器,与t 不同的是它俩存储的是操作数。
  5. 大多数算术/逻辑运算指令都用 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 。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇