JS 盾(JS DUN PROTECT)加固小记
JS 盾是猿人学自主开发的 JSVMP 加固方案,国内 JSVMP 难度算 T0 级。今天下午闲来无事看了看,感觉还挺有意思的,在它身上能看见历代先贤的影子。也有一些非常创新的设计,比如后面会提到的动态代码构造(可惜最终还是没搞到)
我做的事情非常简单,就把下面这段代码丢进去加固了一下:
function myScript(){
console.log("把要加固的代码粘贴到框框里,剩下的就交给我们吧")
};
myScript();
膨胀出来的文件巨大,字节码很多,调用链相当杂乱。不过由于源码简单,加固产物不会难上天际,正好拿来摸清整体结构。
工具和初步观感
扣了一个小时后发现,这个加固不能按之前阿卡迈那种细节扣法来搞。得先通读一遍,理解整体结构,然后才能逐块分析。
工具方面,VSCode 直接报废——括号全乱了,格式化也不可信,甚至会报错。最后用 Vim 手动看的。搞完 AKM 以后有个经验:对于混淆严重的代码,一定要先看括号闭合状态,看的是结构而不是变量名。
整体结构
整个文件是一个 eval 包裹的立即执行函数:
eval(function r(n, Z) {
var r = "JS DUN PROTECT", e, k, y, t, f, ...;
return (((((...) && (...) || (...)) && ...));
}(...))
eval 里面的函数 r 接收两个参数 n(字节码)和 Z(始终为 undefined,用于短路判断)。整个函数体就是一条 return 语句,用 && / || 短路运算串成的超长表达式链。
这条链按执行顺序做了三件事:
- 初始化阶段:声明变量,缓存原生方法引用(
Math.sin、JSON.stringify、String.fromCharCode 等)
- 字节码解码 + 第一轮 VM 执行:解码一个巨大的编码数组,传入 VM 解释器
p(即函数 X)执行
- 第二轮 VM 执行:重置计数器,再次调用
p,这次传入代码生成器相关的 handler 函数
第二轮和第三轮之间的衔接点长这样:
前面是超长字节码数据 "H77"]))) || 1) && (T = 0) && 0 || (V = p(Q, 后面跟N个函数))
拆开看:
]))) 关闭字节码数组和第一轮 p(...) 调用
|| 1) 无论第一轮返回什么都为 truthy
&& (T = 0) && 0 重置计数器 T,然后故意让左侧变 falsy
|| (V = p(Q, ...)) 触发第二轮 VM 调用,结果赋给 V
这种 && 0 || expr 的写法在整个文件里到处都是,本质就是「先执行副作用,再跳到下一个表达式」,相当于把顺序语句伪装成了一条表达式。
VM 解释器
VM 主体是函数 X(在代码里别名为 p),它是一个标准的 while + switch 字节码解释器,不过 switch 被 if-else 链和短路运算替代了,混淆得几乎没法直接看。
VM 维护的核心状态:
- 操作数栈
y:所有运算通过栈操作完成
- 字节码数组
r + 程序计数器 n:逐条读取并执行
- 作用域链
e:嵌套数组,模拟 JS 的词法作用域
指令集大约 50+ 条 opcode(0~54),覆盖了算术运算、比较、位运算、属性访问、函数调用、异常处理等。举几个例子:
| opcode |
操作 |
| 2 |
加载立即数(从字节码读取常量压栈) |
| 6 |
加法(弹出两个值相加,结果压栈) |
| 5 |
减法 |
| 0 |
属性访问(obj[key]) |
| 15 |
创建函数(闭包) |
| 19 |
函数调用(支持 call / apply) |
| 20 |
返回(终止 VM 循环) |
核心设计:X.$ 虚拟方法表
这是我觉得最有意思的部分,也是我目前没见别家用过的手法。
X.$ = [
X.$ = "1.1",
X.apply[X.$] = X.call[X.$] = X.call,
X.apply,
[].push,
[].pop,
[].concat,
[].slice,
X.bind,
function (r, n, Z, e, k) {
return 7 == r ?
X.$[1][X.$[0]](X.$[3], n, Z, e, k) : 2 == r ?
X.$[1][X.$[0]](X.$[r], n, Z, e) :
X.$[1][X.$[0]](X.$[r], n, Z)
}
]
这三行代码构造了一个数组,充当 VM 的操作原语表(vtable):
| index |
值 |
含义 |
| 0 |
"1.1" |
属性名 key |
| 1 |
Function.prototype.call |
call 间接引用 |
| 2 |
Function.prototype.apply |
apply |
| 3 |
Array.prototype.push |
压栈 |
| 4 |
Array.prototype.pop |
弹栈 |
| 5 |
Array.prototype.concat |
数组合并 |
| 6 |
Array.prototype.slice |
数组截取 |
| 7 |
Function.prototype.bind |
绑定 |
| 8 |
dispatcher 函数 |
统一调度入口 |
第一层:函数对象属性污染
数组构造过程中有个前置副作用:
X.$ = "1.1" // 先赋值字符串
X.call["1.1"] = X.call // 给 Function.prototype.call 挂了个 "1.1" 属性,指向自身
X.apply["1.1"] = X.call // 同理
JS 函数本质是对象,可以挂任意属性。混淆利用这一点,用字符串 key 隐藏真实的函数引用。
第二层:双层 call 跳板
调度器里的核心表达式:
X.$[1][X.$[0]](func, thisArg, ...args)
展开执行过程:
X.$[1] → Function.prototype.call
X.$[0] → "1.1"
X.$[1]["1.1"] → Function.prototype.call(刚才挂上去的属性)
调用 call,this 指向 call 本身
→ call.call(func, thisArg, ...args)
→ func.call(thisArg, ...args)
两层 .call 绕了一圈,本质就是 func.call(thisArg, ...args)。但静态分析工具看到的是 X.$[1][X.$[0]](...),完全没法推断出实际调用了什么。
第三层:opcode 化分发
调度器通过参数 r 选择目标函数:
X.$[8](3, arr, val) → push.call(arr, val) // 压栈
X.$[8](4, arr) → pop.call(arr) // 弹栈
X.$[8](5, arr1, arr2) → concat.call(arr1, arr2) // 合并
X.$[8](6, arr, n) → slice.call(arr, n) // 截取
X.$[8](7, arr, a, b, c) → push.call(arr, a, b, c) // 多值压栈
X.$[8](2, fn, ctx, args) → fn.apply(ctx, args) // 函数调用
r 不是普通参数,而是函数 opcode。整个 VM 的栈操作和函数调用全部收敛到 X.$[8] 这一个入口。
这种设计巧妙在哪
破坏 AST 可读性:没有任何直接函数调用,全部变成数组索引访问加间接 call,AST 分析工具基本废了。
统一执行入口:所有底层操作走一个调度器,方便做 hook 检测。如果有人 hook 了 push 或 call,调度器层面可以感知到。
opcode 化:JS 的原生方法被编号成指令,跟 VM 的字节码指令集在同一个抽象层级,整个执行流都在 VM 的控制之下。
后续
目前只摸清了 VM 的整体结构和 vtable 机制,代码生成那块还没深入。尝试了两个小时解混淆(把 && / || 链还原成正常语句),发现自己还是太菜了,短路混淆的自动化处理不太好写,但思路是有的,不至于死路一条。
值得庆幸的是 JS 盾目前还没大规模铺开。不确定购买定制化以后会不会加上类似 5s 那样的风控检测,不过对猿人学来说这应该不是什么难事。
等追到代码生成的完整链路再接着写,现在卡在代码格式化后被检测了,明天再看看怎么说吧,对比 Android 感觉还是简单许多,最起码不至于读超级碎片化的汇编
也欢迎各位来报猿人学的 JS 逆向课程,广告还是留给平哥吧,我就不打了
附件上不去,没招了,一直重试但是没用