吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 249|回复: 1
上一主题 下一主题
收起左侧

[Web逆向] JS 盾(JS DUN PROTECT)分析日记

[复制链接]
跳转到指定楼层
楼主
rsds0duck 发表于 2026-6-24 20:40 回帖奖励

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 语句——用 &&|| 短路运算串起来的超长表达式链。

这条链按执行顺序做了三件事:

  1. 初始化阶段:声明变量,缓存原生方法引用(Math.sinJSON.stringifyString.fromCharCode 等)
  2. 字节码解码 + 第一轮 VM 执行:解码一个巨大的编码数组,传入 VM 解释器 p(即函数 X)执行
  3. 第二轮 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 了 pushcall,调度器层面可以感知到异常。

opcode 化:JS 的原生方法被编号成指令,跟 VM 的字节码指令集在同一个抽象层级。整个执行流都在 VM 的控制之下,没有逃逸到外部。


壳与代码生成

第二天的重心放在了壳的机制上。JS盾的壳跟 Android 加固的思路很像,只不过不需要 mmap 去开空间——JS 里直接 Function 构造器搞定。

整体流程:

  1. 最外层字节码(嵌入在 eval 调用中)传给 VM 执行
  2. VM 执行完毕后产出第二块字节码——这块字节码才是真正生成目标 JS 代码的
  3. 壳负责:代码产出、字符串解码、格式化检测(格式化后代码会被检测到并拒绝执行)
  4. 因为要写域名白名单,解密出来的 JS 里还夹带了域名校验和时间格式化

我用模型辅助补了一下环境(documentnavigatorlocation 等),解密产物可以在 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 本体没深追。等哪天真有需求了再说。

感觉写的好水,好像说了什么内容但是又没说什么东西

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
Hmily 发表于 2026-6-25 17:13
文章内容不长,合并到之前帖子一起方便阅读,也方便加分,正好之前帖子也有问题,一起修改一下吧,修改完和我说,我删除这篇帖子。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-26 02:00

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表