JS 盾(JS DUN PROTECT)分析日记
作者: 人生导师
日期: 2025 年 6 月
结论先说:JS盾的设计思路很强——双层动态代码执行、VM指令集覆盖完整、虚拟方法表(X.$)的设计在国内JSVMP里算独一档。但它的强度核心在于膨胀而非精巧:13000+行的JSVMP、层层嵌套的短路混淆、壳产出的运行时检测代码。追了两天,摸清了整体结构,结论是除非有明确的业务需求,不要对抗这个东西,非常非常浪费生命。
下面从头拆。
测试代码很简单,就一段 console.log:
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 函数
两轮 VM 之间的衔接点长这样:
前面是超长字节码数据 "H77"]))) || 1) && (T = 0) && 0 || (V = p(Q, 后面跟N个函数))
拆开看:
| 片段 |
作用 |
]))) |
关闭字节码数组和第一轮 p(...) 调用 |
\|\| 1) |
无论第一轮返回什么都为 truthy |
&& (T = 0) && 0 |
重置计数器 T,然后故意让左侧变 falsy |
\|\| (V = p(Q, ...)) |
触发第二轮 VM 调用,结果赋给 V |
这种 && 0 || expr 的写法在整个文件里到处都是。本质就是「先执行副作用,再跳到下一个表达式」——把顺序语句伪装成了一条表达式。
核心设计: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 把真实的函数引用藏了起来——你用 Object.keys 遍历根本看不到。
第二层:双层 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 的控制之下,没有逃逸到外部。
壳与代码生成
第二天的重心放在了壳的机制上。JS盾的壳跟 Android 加固的思路很像,只不过不需要 mmap 去开空间——JS 里直接 Function 构造器搞定。
整体流程:
- 最外层字节码(嵌入在
eval 调用中)传给 VM 执行
- VM 执行完毕后产出第二块字节码——这块字节码才是真正生成目标 JS 代码的
- 壳负责:代码产出、字符串解码、格式化检测(格式化后代码会被检测到并拒绝执行)
- 因为要写域名白名单,解密出来的 JS 里还夹带了域名校验和时间格式化
我用模型辅助补了一下环境(document、navigator、location 等),解密产物可以在 Node 上跑了。补环境的代码大概长这样:
const realNow = Date.now;
Object.defineProperty(Date, "now", {
configurable: true, enumerable: false, writable: true,
value: function () { return 1782277958218 }
});
globalThis.location = {
href: 'https://www.baidu.com/',
host: 'www.baidu.com',
hostname: 'www.baidu.com',
};
globalThis.window = globalThis;
globalThis.self = globalThis;
// ... 补了一堆 document / navigator / screen / history ...
补环境这块不算难——缺什么补什么,配合模型很快。
JSVMP 本体:太大了
壳解密出来的 JSVMP 代码,格式化后 24000 行(原始 13000 行),是我见过最大的 JSVMP。
见过第一天的 VM 结构和 X.$ 设计之后,已经能判断这块的本质:用膨胀换安全。opcode 足够多、调用链足够乱、字节码足够长——但设计上没有超出第一天看到的那套框架。只是量变,不是质变。
所以没深入追。一些字符串可以通过 AST 直接看到明文。花时间进去能扣出来,但性价比太低。如果有业务需求——比如要过某个用了 JS盾的站的协议——那另说。纯研究角度,不值得。
这里有个坑:代码格式化后会触发壳的检测逻辑,导致拒绝执行。所以如果想自动化处理,要么不格式化直接补环境跑,要么自己写一个可靠的反混淆器(这条路的成本远超预期)。
值得学习的思路
拆完之后想了一下:如果让我自己实现一个类似的保护方案,应该怎么分层递进?以下是整理后的思路。
第一阶段:简单 Loader
- Base64 编码/压缩
- 运行时恢复
Function 构造器执行
目的是熟悉代码隐藏→恢复→执行的完整流程。不涉及 VM。
第二阶段:小型 VM
支持几十条基础指令:
LOAD_CONST LOAD_VAR STORE_VAR ADD SUB
CALL RETURN JMP JMP_IF
把简单 JS 编译成自己的字节码,再写解释器执行。到这一步就能理解「VM 怎么把 JS 变成字节码再执行回来」。
第三阶段:自动变换
在第二阶段的基础上增加随机化:
- 指令集随机化(每次构建 opcode 映射不同)
- 常量池布局变化
- 控制流布局变化
- 字节码格式变化
这样不同构建产物之间不完全一致——即使算法相同,静态特征也对不上。
对于环境检测部分,建议交给被加密的业务代码自己处理。做的是盾,不是整体反爬方案。关注点收敛,否则越做越散。
总结
JS盾在 JSVMP 这个方向上做到了国内 T0 级的程度。核心亮点是 X.$ 虚拟方法表——用数组索引 + 双层 call 跳板隐藏函数调用,再通过 opcode 化把原生方法纳入 VM 控制。这套设计值得学,也值得在自己的项目里借鉴。
但是它的问题也很明显:强度靠膨胀撑起来的。 13000 行的字节码、层层嵌套的短路混淆、格式化检测——这些会让逆向的人很痛苦,但不代表攻不破。只是时间成本太高,对单兵来说不划算。
对比 Android 端的 VMP,JS 盾已经算仁慈了——至少不用读超级碎片化的汇编。但结论不变:没有业务需求驱动的话,追这个东西是在浪费生命。
壳的代码生成、字符串解码、格式化检测这些机制是通的,JSVMP 本体没深追。等哪天真有需求了再说。
感觉写的好水,好像说了什么内容但是又没说什么东西