17

看了下 jsjiami,简单的一个 console.log("James"),加密出来的结果居然有 3K,说明这个加密转了不知道多少弯在里面。如果要把真正一段业务代码拿来手工解密,应该会挺累的,但是本文不研究工作量的问题,只是尝试一下手工解密,向各位读者介绍一下分析方法和工具应用。

同一句话在 jsjiami 里可能会加密出不同的结果,我相信这个工具上加入了随机因素。但是为了节约篇幅,这里就不贴我用于试验的加密结果了。分析过程中会贴一些代码段。

1. 第一步,可读化

毋庸置疑,要想人工识别,首先需要断句。幸好目前美化(格式化)JS 的工具还是不少,随便找两个试下,看哪个效果好。我这里是用的浏览器插件 FeHelper。

然后注意到,所有变量都改了名字,数字加字母的,怎么读都难受。所以需要使用“重命名”重构工具来改名。这事让 VSCode 干毫无压力。

2. 然后,一点点来分析

2.1. 先看前两行

var _0xodm = "jsjiami.com.v6",
    _0x47c5 = [_0xodm, "wrvCucKGS1U=", "CGdK", "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="];

这一句声明了两个变量,一个显然是 jsjiami 的版本版本;另一个是一个数组,除版本信息外,内容猜测是 Base64,上网用 Base64 解码试了一下,解出来乱码,所以先放着,后面再来看是啥。

为了便于识别,可以 rename 重构一下,顺便按规范拆分声明:

var toolVersion = "jsjiami.com.v6";
var constArray = [toolVersion, "wrvCucKGS1U=", "CGdK", "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="];

2.2. 接下来是一个 IIFE

这个 IIFE 的三个形参,顺便改个名字:p1p2p3。IIFE 里定义了一个局部函数,给它更名为 localFunc1。这个函数定义完之后直接调用,查了一下,没有递归,所以相当于又是一个 IIFE。同样,它的 5 个参数给改个没啥意义,但是好识别的名字,结果:

(function (p1, p2, p3) {
    var localFunc1 = function (lp1, lp2, lp3, p14, lp5) {
        lp2 = lp2 >> 0x8, lp5 = "po";
        var _0x1e174c = "shift",
            _0x5428fe = "push";
        if (lp2 < lp1) {
            while (--lp1) {
                p14 = p1[_0x1e174c]();
                if (lp2 === lp1) {
                    lp2 = p14;
                    lp3 = p1[lp5 + "p"]();
                } else if (lp2 && lp3["replace"](/[QHMuLSPVlrtZMLzQ=]/g, "") === lp2) {
                    p1[_0x5428fe](p14);
                }
            }
            p1[_0x5428fe](p1[_0x1e174c]());
        }
        return 0xaa95b;
    };
    return localFunc1(++p2, p3) >> p2 ^ p3;
}(constArray, 0x1c7, 0x1c700));

2.2.1. 参数干掉一个是一个

注意到,外层 IIFE 的 p1 就是上面改名为 constArray 的那个数组,反正都是作用域内,干脆一不做二不休,给它换掉:

  • p1 更名为 constArray,跟外面的数组同名
  • 同时删除外层 IIFE 的第一个形参和实参

2.2.2. 把绕远的数据操作改回来

既然已经知道 constArray 是个数组,作用在上面的所有属性都应该跟数组相关。就这几行 代码,观察一下不难发现:

  • lp5 只参与了一个表达式,结果是 "pop"
  • var _0x1e174c = "shift", _0x5428fe = "push" 两个变量只是当常量使用的,把 var 改成 const 可以让编辑器帮忙检查是否有写操作 —— 当然结果是没有。

不过很遗憾,VSCode 没提供内联 (inline) 重构工具,所以只能手工操作,把这两个变量直接替换成常量。以 _0x1e174c = "shift" 为例,先把 "shift"(含引号)复制到剪贴板中,然后在 _0x1e174c 使用若干次 Ctrl+D 把所有 _0x1e174c 都选中,再 Ctrl+V 即可。如法炮制处理掉 _0x5428fe = "push"。然后删除两个声明。

手工内联.gif

2.2.3. 简化一下代码,越简单越好懂

不过 constArray["shift"]() 这种写法看起来很不习惯,最好能改成 constArray.shift() —— 这就需要借助一下 ESLint 了。将当前目录初始化为 npm module 项目,安装并初始化 eslint,然后在配置里添加一条规则:

"dot-notation": "error"

这时候 VSCode 会提示

["shift"] is better written in dot notation.

将鼠标移过去,使用快捷修复自动把所有 [] 调用改为 . 调用。

更改属性调用方式.gif

2.2.4. 分析参数作用

接下就很有意思了,看 localFunc1(++p2, p3) 调用,只传入了两个参数,所以除了刚才去掉的 lp5 之外,形参 lp3lp4 并没有起到参数的作用,而是当作局部变量来用的。这里可以把它们从参数列表中删除,使用 let 定义为局部变量 —— 当然,这一步做不做无所谓。

p2p3 的值是外部 IIFE 传入的:

(function (p2, p3) {
   ...
}(0x1c7, 0x1c700));

乍一看像变量,仔细一看都是 0x 前缀,明明就是整数。而且 p3 就是比 p2 后面多缀两个 0

再看 localFunc1 内部第一句话就是 lp2 = lp2 >> 0x8(记住 lp2 是传入的 p3),这不就是把 0x1c700 后面两个 0 给去掉变成 0x1c7 吗 —— 现在 lp2p2 的值一样了。而 lp1 是传入的 ++p2,所以在现在 lp1 === lp2 + 1

这样就满足了 if 条件 (lp2 < lp1),这个 if 语句没用了,可以直接解掉。

2.2.5. 神奇的循环

接下来是一个神奇的循环,while (--lp1) { },中间没有 break,也就是说,需要循环 0x1c7 + 1 次,也就是 456 次。基本上可以猜测这个循环干的就是没用的事情,浪费 CPU 而已。

来分析一下是不是:

既然刚才已经说了 lp3lp4 就是局部变量,不妨再改个名,分别改为 local1local2,好识别。现在的 while 循环是这样:

let local1, local2;
while (--lp1) {
    local2 = constArray.shift();
    if (lp2 === lp1) {
        lp2 = local2;
        local1 = constArray.pop();
    } else if (lp2 && local1.replace(/[QHMuLSPVlrtZMLzQ=]/g, "") === lp2) {
        constArray.push(local2);
    }
}

刚才还分析了 lp1 === lp2 + 1,所以 while (--lp1) 第一次执行的时候,lp1lp2 就相等了,进入 if (lp2 === lp1) 分支;此后,都不会再进入这个分支,因为 lp1 一直在减小。

那么第一次循环执行的内容可以写成:

local2 = constArray.shift();   // toolVersion,即 "jsjiami.com.v6"
lp2 = local2;                  // "jsjiami.com.v6"
local1 = constArray.pop();     // "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="

此后,这个循环中再没有对 lp2local1 赋过值。而此时 constArray 的值是

["wrvCucKGS1U=", "CGdK"]    // shift() 和 pop() 操作把头尾的元素干掉了

后面的 local1.replace(...) 这句话可以直接拿到控制台去跑一下,结果让人哭笑不得,就是 "jsjiami.com.v6"。从这个结果来看,else if (...) 条件除第一次不执行,之后都是 true,也就是说,总是执行,那不就和 else 一样了嘛。

好嘛,除去第一次循环,这个循环变成了:

lp1 = 455; // 0x1c7
// 注意,第一次循环已经把头尾两个元素移出了数组
constArray = ["wrvCucKGS1U=", "CGdK"];
while (--lp1) {
    local2 = constArray.shift();
    constArray.push(local2);
}

没别的,就是转圈,一共转了 455 - 1 = 454 次!次数如果算不清楚,写一个循环跑一下就知道了:

let a = 455;
let c = 0
while (--a) { c++ };
console.log(c);

local2 之后再没使用,所以 while 中的两句话可以合并成一句:

constArray.push(constArray.shift())

这和 while 循环之后那一句完全一样。所以这句话执行的次数一共是 454 + 1,也就是 455 次。由于 constArray 现在有两个元素,而 455 是奇数,所以跑完之后 constArray 是这样:

constArray = ["CGdK", "wrvCucKGS1U="];

2.2.6. 都是没用的代码

至此,第一小段代码分析完成,除了改变 constArray 没干任何有意义的事情。

至于这段代码里的两句 return,没半点用,因为外层 IIFE 的返回值直接被丢弃了。所以返回语句里的位运算,都懒得去算了。

整个这一段代码最终变成一句话:

constArray = ["CGdK", "wrvCucKGS1U="];

而且猜测 constArray 其实没啥用

3. 剩下的代码简单分析下

分析了半天,基本上没啥有用的代码。而且基本上可以断定,后面的几十行代码也只是在浪费 CPU。

因为我们知道原代码是 console.log("James")。所以为了加快分析速度,就不再一行行往下读了,直接从后往前看。一眼就看到了

console[_0x2a10("0", "]o48")](_0x2a10("1", "WCmN"));

反推,_0x2a10("0", "]o48") 的结果就是 "log",而 _0x2a10("1", "WCmN") 的结果就是 "James"

猜测,_0x2a10 就是个拼字符串的函数,而第 1 个参数,就是个标记,作分支用。

3.1. 来看 _0x2a10

既然都已经知道 _0x2a10 是拼字符串的了,那改名叫 getString 吧。第一个参数是标记,改名为 flag,第二个参数多半是计算用的初始值,就叫 initValue 好了。

其中第一句:flag = ~~"0x".concat(flag);。这句就是把 flag 按 16 进制转换成数值类型的值而已。根据实际的调用参数,去控制台跑一下 ~~"0x1"~~"0x2" 就知道了,还可以试验一下 ~~"0xa"

接下来的 var _0x1fb2c5 = constArray[flag]; 也就好理解了,而且到这里总算明白了,原来 constArray 是用来提供拼接字符串的部分因素的。既然如此,给它更名为 factor

3.2. 接下来是个长长的 if 语句

如果不管这个长长的 if 语句内部那些复杂的逻辑,精简下来就是:

var getString = function (flag, initValue) {
    if (getString.iOaiiU === undefined) {
        ...
        getString.LaMLHS = _0xbe9954;
        getString.WTsNMX = {};
        getString.iOaiiU = !![];
    }
    ...
}

也就是在第一次运行 getString 的时候对它进行初始化。

其中 .iOaiiU 只有两处引用,一处判断,一处赋值 —— 明显是个初始化标记,可以改名为 initialized。只不过这时候 rename 重构工具似乎不能用,手工更名吧。

3.3. 确保 globalThis 上有 atob()

if 分支内第一段代码又是个 IIFE,单独拷贝出来放到一个独立的 js 文件中,VSCode 并没有提示找不到变量之类的事情。所以这段代码是可以独立运行的。

(function () {
    var _0xea3c63 = typeof window !== "undefined"
        ? window
        : typeof process === "object" && typeof require === "function" && typeof global === "object"
            ? global
            : this;
    var _0x5b626 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    _0xea3c63.atob || (_0xea3c63.atob = function (_0x1e0fac) {
        var _0x57beec = String(_0x1e0fac).replace(/=+$/, "");
        for (var _0x1f3b8d = 0x0, _0x154b1d, _0xad5277, _0x306ad8 = 0x0, _0xcb4400 = ""; _0xad5277 =
            _0x57beec.charAt(_0x306ad8++); ~_0xad5277 && (_0x154b1d = _0x1f3b8d % 0x4 ?
                _0x154b1d * 0x40 + _0xad5277 : _0xad5277, _0x1f3b8d++ % 0x4) ? _0xcb4400 +=
            String.fromCharCode(0xff & _0x154b1d >> (-0x2 * _0x1f3b8d & 0x6)) : 0x0) {
            _0xad5277 = _0x5b626.indexOf(_0xad5277);
        }
        return _0xcb4400;
    });
}());

第一句很明显是在找 global 对象,相当于 var _0xea3c63 = globalThis

第二句先忽略,第三句明显是看 globalThis 上有没有 atob(),如果没有就给它一个。既然 atob()在多数环境下都存在,那就不用纠结其内容了。

那么,这段 IIFE 就是保证 atob() 可用,可以直接删掉不看。

3.4. 一个看起来比较有用的函数

接下来又定义了一个函数,去掉内容,长这样:

var _0xbe9954 = function (_0x333549, _0x3c0fbb) {
    ...
};
getString.LaMLHS = _0xbe9954;

通过后面的调用来用,应该是个比较有用的函数。为了方便识别,把两个参数分别更名为 firstsecond

我们也把它摘出来拷贝到一个独立的 .js 文件中,发现也没有缺失变量,说明可以单独拿出来分析,就是个工具函数。

这个函数一来定义了 5 个变量,先不管,用到的时候再去找。

3.4.1. 用到了 atob

下面的代码是:

let _0x2591ef = "";   // 5 个变量中的一个
first = atob(first);
for (var i = 0x0, len = first.length; i < len; i++) {
    _0x2591ef += "%" + ("00" + first.charCodeAt(i).toString(0x10)).slice(-0x2);
}
first = decodeURIComponent(_0x2591ef);

这段代码不用仔细看,大概知道是把一个 Base64 转成 %xx 的形式,而这个形式的字符串用 decodeURICompoment() 可以再转成字符串(绕好大一圈)。

回想一下 constArray 的元素,确实长得像 Base64,所以这里应该是处理那些元素了。

3.4.2. 然后是烧脑时刻

接下来的代码就是通过一大堆的数学计算,从 initValueconstArray[i] 把我们需要的字符串恢复出来。算法肯定是加密工具自己设计的,懒得去分析了。计算都不难,就是烧脑,需要仔细,一点不能出差错。

4. 结束

是的,结束了,戛然而止。

写这篇文章的目的并不是要把代码完全解出来,只是证明其可能性,同时介绍分析方法和工具应用。第 2 部分写完就该结束的,因为后面也没有用到什么新的方法。

总的来说,jsjiami 向原始代码中添加了非常多无用而烧脑的程序来提高解码的难度,这么简单的一句话都解了这么久,生产代码就更不用说了。代价也是有的 —— 真烧 CPU。

好吧,我又干了一件无聊的事情!


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!