网站加密和混淆技术简介

我们在爬取网站的时候,会遇到一些需要分析接口或 URL 信息的情况,这时会有各种各样类似加密的情形。

  • 某个网站的 URL 带有一些看不太懂的长串加密参数,要抓取就必须懂得这些参数是怎么构造的,否则我们连完整的 URL 都构造不出来,更不用说爬取了。

  • 在分析某个网站的 Ajax 接口时,可以看到接口的一些参数也是加密的,RequestHeaders 里面也可能带有一些加密参数,如果不知道这些参数的具体构造逻辑,就没法直接用程序来模拟这些 Ajax 请求。

  • 翻看网站的 JavaScript 源代码,可以发现很多压缩了或者看不太懂的字符,比如 JavaScript 文件名被编码,文件的内容被压缩成几行,变量被修改成单个字符或者一些十六进制的字符……这些导致我们无法轻易根据 JavaScript 源代码找出某些接口的加密逻辑。

以上情况基本上是网站为了保护其数据而采取的一些措施,我们可以把它归类为两大类:

  • URL/API 参数加密

  • JavaScript 压缩、混淆和加密

本节中,我们就来了解一下这两类技术的基本原理和一些常见的示例。知己知彼,百战不殆,了解了这些技术的实现原理之后,我们就能更好地去逆向其中的逻辑,从而实现数据爬取。

网站数据防护方案

当今是大数据时代,数据已经变得越来越重要了。网页和 APP 现在是主流的数据载体,如果其数据的 API 没有设置任何保护措施,那么在爬虫工程师解决了一些基本的反爬(如封 IP、验证码)问题之后,数据还是可以被爬取到的。

有没有可能在 URL/API 层面或 JavaScript 层面也加上一层防护呢?答案是可以。

URL/API参数加密

网站运营者首先想到的防护措施可能是对某些数据接口的参数进行加密,比如说给某些 URL 的参数加上校验码,给一些 ID 信息编码,给某些 API 请求加上 token、sign 等签名,这样这些请求发送到服务器时,服务器会通过客户端发来的一些请求信息以及双方约定好的密钥等来对当前的请求进行校验,只有校验通过,才返回对应数据结果。

比如说客户端和服务端约定一种接口校验逻辑,客户端在每次请求服务端接口的时候都会附带一个 sign 参数,这个 sign 参数可能是由当前时间信息、请求的URL、请求的数据、设备的ID、双方约定好的密钥经过一些加密算法构造而成的,客户端会实现这个加密算法来构造 sign,然后每次请求服务器的时候附带上这个参数。服务端会根据约定好的算法和请求的数据对 sign 进行校验,只有校验通过,才返回对应的数据,否则拒绝响应。

当然,登录状态的校验也可以看作此类方案,比如一个 API 的调用必须传一个 token ,这个 token 必须在用户登录之后才能获取,如果请求的时候不带该 token,API 就不会返回任何数据。

倘若没有这种措施,那么 URL 或者 API 接口基本上是完全可以公开访问的,这意味着任何人都可以直接调用来获取数据,几平是零防护的状态,这样是非常危险的,而且数据也可以被轻易地被爬虫爬取。因此,对 URL/API 参数进行加密和校验是非常有必要的。

JavaScript压缩、混淆和加密

接口加密技术看起来的确是一个不错的解决方案,但单纯依靠它并不能很好地解决问题。为什么呢?

对于网页来说,其逻辑是依赖于 JavaScript 来实现的。JavaScript 有如下特点。

  • JavaScript 代码运行于客户端,也就是它必须在用户测览器端加载并运行。

  • JavaScript 代码是公开透明的,也就是说测览器可以直接获取到正在运行的 JavaScript 的源码。

基于这两个原因,JavaScript 代码是不安全的,任何人都可以读、分析、复制、盗用甚至篡改代码。

所以说,对于上述情形,客户端 JavaScript 对于某些加密的实现是很容易被找到或模拟的,了解了加密逻辑后,模拟参数的构造和请求也就轻而易举了,所以如果 JavaScript 没有做任何层面的保护的话,接口加密技术基本上对数据起不到什么防护作用。

如果你不想让自己的数据被轻易获取,不想他人了解 JavaScript 逻辑的实现,或者想降低被不怀好意的人甚至是黑客攻击的风险,那么就需要用到 JavaScript 压缩、混淆和加密技术了。

这里压缩、混淆和加密技术简述如下。

  • 代码压缩:去除 JavaScript 代码中不必要的空格、换行等内容,使源码都压缩为几行内容,降低代码的可读性,当然同时也能提高网站的加载速度。

  • 代码混淆:使用变量替换、字符串阵列化、控制流平坦化、多态变异、僵尸函数、调试保护等手段,使代码变得难以阅读和分析,达到最终保护的目的。但这不影响代码的原有功能,是理想、实用的 JavaScript 保护方案。

  • 代码加密:可以通过某种手段将 JavaScript 代码进行加密,转成人无法阅读或者解析的代码,如借用 WebAssembly 技术,可以直接将 JavaScript 代码用 C/C++ 实现,JavaScript 调用其编译后形成的文件来执行相应的功能。

下面我们对上面的技术分别予以介绍。

URL/API参数加密

现在绝大多数网站的数据一般都是通过服务器提供的 API 来获取的,网站或 App 可以请求某个数据 API 获取到对应的数据,然后再把获取的数据展示出来。但有些数据是比较宝贵或私密的,这些数据肯定需要一定层面上的保护。所以不同 API 的实现也就对应着不同的安全防护级别,我们这里来总结下。

为了提升接口的安全性,客户端会和服务端约定一种接口校验方式,一般来说会用到各种加密和编码算法,如 Base64、Hex编码,MD5、AES、DES、RSA等对称或非对称加密。

举个例子,比如说客户端和服务器双方约定一个 sign 用作接口的签名校验,其生成逻辑是客户端将 URL 路径进行 MD5 加密,然后拼接上 URL 的某个参数再进行 Base64 编码,最后得到一个字符串 sign ,这个 sign 会通过 Request URL 的某个参数或 Request Headers 发送给服务器。服务器接收到请求后,对 URL 路径同样进行 MD5 加密,然后拼接上 URL 的某个参数,进行 Base64 编码,也会得到一个 sign。接着比对生成的 sign 和客户端发来的 sign 是否一致,如果一致,就返回正确的结果,否则拒绝响应。这就是—个比较简单的接口参数加密的实现。如果有人想要调用这个接口的话,必须定义好 sign 的生成逻辑,否则是无法正常调用接口的。

当然,上面的这个实现思路比较简单,这里还可以增加一些时间戳信息增加时效性判断,或增加一些非对称加密进一步提高加密的复杂程度。但不管怎样,只要客户端和服务器约定好了加密和校验逻辑,任何形式的加密算法都是可以的。

这里要实现接口参数加密,就需要用到一些加密算法,客户端和服务器肯定也都有对应的 SDK 实现这些加密算法,如 JavaScript 的 crypto-js、Python 的 hashlib、Crypto,等等。

但还是如上文所说,如果是网页的话,客户端实现加密逻辑使用 JavaScript 的话,其源代码对用户是完全可见的,如果没有对 JavaScript 做任何保护的话,很容易弄清楚客户端加密的流程。

因此,我们需要对 JavaScript 利用压缩、混淆等方式来对客户端的逻辑进行一定程度的保护。

JavaScript压缩

这个非常简单,JavaScript 压缩即去除 JavaScript 代码中不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都压缩为几行内容,代码的可读性变得很差,同时也能提高网站的加载速度。

如果仅仅是去除空格、换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。因为我们有一些格式化工具可以轻松将 JavaScript 代码变得易读,比如利用 IDE、在线工具或 Chrome 测览器都能还原格式化的代码。

这里举一个最简单的 JavaScript 压缩示例。原来的 JavaScript 代码是这样的:

function echo(stringA, stringB){
    const name = "Germey";
    alert("hello " + name);
}

压缩之后就变成这样子:

function echo(d,c){const e="Germey";alert("hello "+e)};

可以看到,这里参数的名称都被简化了,代码中的空格也被去掉了,整个代码也被压缩成了一行,代码的整体可读性降低了。

目前主流的前端开发技术大多都会利用 webpack、Rollup 等工具进行打包。webpack、Rollup 会对源代码进行编译和压缩,输出几个打包好的 JavaScript 文件,其中我们可以看到输出的 JavaScript 文件名带有一些不规则的字符串,同时文件内容可能只有几行,变量名都用一些简单字母表示。这其中就包含 JavaScript 压缩技术,比如一些公共的库输出成 bundle 文件,一些调用逻辑压缩和转义成冗长的几行代码,这些都属于JavaScript 压缩。另外,其中也包含了一些很基础的 JavaScript 混淆技术,比如把变量名、方法名替换成—些简单字符,降低代码的可读性。

但整体来说,JavaScript 压缩技术只能在很小的程度上起到防护作用,要想真正提高防护效果,还得依靠 JavaScript 混淆和加密技术。

JavaScript混淆

JavaScript 混淆完全是在 JavaScript 上面进行的处理,它的目的就是使得 JavaScript 变得难以阅读和分析,大大降低代码的可读性,是一种很实用的 JavaScript 保护方案。

JavaScript 混淆技术主要有以下几种:

  • 变量名混淆:将带有含义的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码的可读性,如转成单个字符或十六进制字符串。

  • 字符串混淆:将字符串阵列化集中放置并可进行 MD5 或 Base64 加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口。

  • 对象键名替换:针对 JavaScript 对象的属性进行加密转化,隐藏代码之间的调用关系。

  • 控制流平坦化:打乱函数原有代码的执行流程及函数调用关系,使代码逻辑变得混乱无序。

  • 无用代码注入:随机在代码中插入不会被执行到的无用代码,进一步使代码看起来更加混乱。

  • 调试保护:基于调试器特性,对当前运行环境进行检验,加入一些 debugger 语句,使其在调试模式下难以顺利执行 JavaScript 代码。

  • 多态变异:使 JavaScript 代码每次被调用时,将代码自身立刻自动发生变异,变为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析和调试。

  • 域名锁定:使 JavaScript 代码只能在指定域名下执行。

  • 代码自我保护:如果对 JavaScript 代码进行格式化,则无法执行,导致浏览器假死。

  • 特殊编码:将 JavaScript 完全编码为人不可读的代码,如表情符号、特殊表示内容,等等。

总之,以上方案都是 JavaScript 混淆的实现方式,可以在不同程度上保护 JavaScript 代码。

在前端开发中,现在 JavaScript 混淆的主流实现是 javascript-obfuscator 和 terser 这两个库。它们都能提供—些代码混淆功能,也都有对应的 webpack 和 Rollup 打包工具的插件。利用它们,我们可以非常方便地实现页面的混淆,最终输出压缩和混淆后的 JavaScript 代码,使得 JavaScript 代码的可读性大大降低。

下面我们以 javascript-obfuscator 为例来介绍一些代码混淆的实现。了解了实现,那么我们自然就对混淆的机理有了更加深刻的认识。

javascript-obfuscator 的官方介绍内容如下:

它是支持 ES8 的免费、高效的 JavaScript 混淆库,可以使得 JavaScript 代码经过混淆后难以被复制、盗用,混淆后的代码具有和原来的代码一模一样的功能。

怎么使用呢?首先,我们需要安装好 Node.js 12.x 及以上版本,确保可以正常使用 npm 命令,具体的安装方式可以参考: https://setup.scrape.center/nodejs。

接着新建一个文件夹,比如 js-obfuscate,然后进入该文件夹,初始化工作空间:

npm init

这里会提示我们输入一些信息,然后创建 package.json 文件,这就完成了项目初始化了。

接下来,我们来安装 javascript-obfuscator 这个库:

npm i -D javascript-obfuscator

稍等片刻,即可看到本地 js-obfuscate 文件夹下生成了一个 node_modules 文件夹(如图 11-1 所示),里面就包含了 javascript-obfuscator 这个库,这就说明安装成功了。

图 11-1 js-obfuscate 文件夹

接下来,我们就可以编写代码来实现一个混淆样例了。比如,新建 main.js 文件,其内容如下:

const code = `
let x = '1' + 1
console.log('x', x)
`

const options = {
    compact: false,
    controlFlowFlattening: true
}

const obfuscator = require('javascript-obfuscator')
function obfuscate(code, options) {
    return obfuscator.obfuscate(code, options).getObfuscatedCode()
}
console.log(obfuscate(code, options))

这里我们定义了两个变量:一个是 code,即需要被混淆的代码; 另一个是混淆选项 options,是一个 Object。接下来,我们引入了 javascript-obfuscator 这个库,然后定义了一个方法,给其传入 code 和 options 来获取混淆后的代码,最后控制台输出混淆后的代码。

代码逻辑比较简单,我们来执行一下代码:

node main.js

输出结果如下:

const _0x6b8724 = _0x1b41;
(function (_0x1ed3d9, _0x8bfdbc) {
    const _0x1938b9 = _0x1b41, _0x45ab8a = _0x1ed3d9();
    while (!![]) {
        try {
            const _0x3eec64 = -parseInt(_0x1938b9(0x8a)) / 0x1 + -parseInt(_0x1938b9(0x88)) / 0x2 + parseInt(_0x1938b9(0x86)) / 0x3 * (parseInt(_0x1938b9(0x81)) / 0x4) + -parseInt(_0x1938b9(0x82)) / 0x5 + parseInt(_0x1938b9(0x89)) / 0x6 * (parseInt(_0x1938b9(0x87)) / 0x7) + -parseInt(_0x1938b9(0x83)) / 0x8 + parseInt(_0x1938b9(0x85)) / 0x9;
            if (_0x3eec64 === _0x8bfdbc)
                break;
            else
                _0x45ab8a['push'](_0x45ab8a['shift']());
        } catch (_0x192474) {
            _0x45ab8a['push'](_0x45ab8a['shift']());
        }
    }
}(_0x3a13, 0x39f2f));
function _0x3a13() {
    const _0x2c3906 = [
        '323216FMLTCZ',
        'log',
        '5119884bxduBS',
        '6EIegLg',
        '7EzzWfP',
        '929706NkpjlH',
        '2312778lBMGhj',
        '73260HQHniV',
        '468444KDZjhF',
        '1863435mXOntC'
    ];
    _0x3a13 = function () {
        return _0x2c3906;
    };
    return _0x3a13();
}
let x = '1' + 0x1;
function _0x1b41(_0x4d852c, _0x209460) {
    const _0x3a1310 = _0x3a13();
    return _0x1b41 = function (_0x1b41aa, _0x3e09b0) {
        _0x1b41aa = _0x1b41aa - 0x81;
        let _0x22a7e7 = _0x3a1310[_0x1b41aa];
        return _0x22a7e7;
    }, _0x1b41(_0x4d852c, _0x209460);
}
console[_0x6b8724(0x84)]('x', x);

看到了吧,那么简单的代码,被我们混淆成了这个样子,其实这里我们就是设定了 “控制流平坦化” 选项。整体看来,代码的可读性大大降低了,JavaScript 调试的难度也大大加大了。

好,那么我们来跟着 javascript-obfuscator 走一遍,就能具体知道 JavaScript 混淆到底有多少方法了。

由于这些例子中调用 javascript-obfuscator 进行混淆的实现是一样的,所以下文的示例只说明 code 和 options 变量的修改,完整代码请自行补全。

代码压缩

这里 javascript-obfuscator 也提供了代码压缩的功能,使用其参数 compact 即可完成 JavaScript 代码的压缩,输出为一行内容。参数 compact 的默认值是 true,如果定义为 false,则混淆后的代码会分行显示。

示例如下:

const code = `
let x = '1' + 1
console.log('x', x)
`

const options = {
    compact: false
}

这里我们先把代码压缩选项的参数 compact 设置为 false,运行结果如下

let x = '1' + 0x1;
console[_0x413afe(0x1d4)]('x', x);

如果不设置 compact 或把 compact 设置为 true,结果如下:

function _0x5d9d(_0x121cb6,_0x39a5be){const _0x33354f=_0x3335();return _0x5d9d=function(_0x5d9d93,_0x764f35){_0x5d9d93=_0x5d9d93-0x1e3;let _0x2b5feb=_0x33354f[_0x5d9d93];return _0x2b
5feb;},_0x5d9d(_0x121cb6,_0x39a5be);}const _0x4b715b=_0x5d9d;(function(_0xe65a90,_0x57b32f){const _0x3b6935=_0x5d9d,_0x3f29af=_0xe65a90();while(!![]){try{const _0x4f7330=-parseInt(_0
x3b6935(0x1e7))/0x1*(-parseInt(_0x3b6935(0x1ea))/0x2)+parseInt(_0x3b6935(0x1e4))/0x3*(-parseInt(_0x3b6935(0x1ee))/0x4)+parseInt(_0x3b6935(0x1ed))/0x5+parseInt(_0x3b6935(0x1e3))/0x6*(
-parseInt(_0x3b6935(0x1eb))/0x7)+parseInt(_0x3b6935(0x1e8))/0x8+parseInt(_0x3b6935(0x1e5))/0x9+parseInt(_0x3b6935(0x1e9))/0xa*(-parseInt(_0x3b6935(0x1ec))/0xb);if(_0x4f7330===_0x57b3
2f)break;else _0x3f29af['push'](_0x3f29af['shift']());}catch(_0x33b324){_0x3f29af['push'](_0x3f29af['shift']());}}}(_0x3335,0x57829));function _0x3335(){const _0x476fc2=['3nhkCdw','6
32439rFMzrR','log','36001oKTpVU','1786696vINLxY','625930WFAuDL','34KodHUh','108549BGSMbD','55eHxsHz','3290030nENykL','1335892rahDVU','216LLuxnk'];_0x3335=function(){return _0x476fc2;};return _0x3335();}let x='1'+0x1;console[_0x4b715b(0x1e6)]('x',x);

可以看到,单行显示的时候,对变量名进行了进—步的混淆,这里变量的命名都变成了十六进制形式的字符串,这是因为启用了一些默认压缩和混淆配置。总之,我们可以看到代码的可读性相比之前大大降低了。

变量名混淆

变量名混淆可以通过在 javascript-obfuscator 中配置 identifierNamesGenerator 参数来实现。我们通过这个参数可以控制变量名混淆的方式,如将其值设为 hexadecimal,则会将变量名替换为十六进制形式的字符串。该参数的取值如下。

  • hexadecimal: 将变量名替换为十六进制形式的字符串,如 0xabc123

  • mangled: 将变量名替换为普通的简写字符,如 a、b、c等

该参数的默认值为 hexadecimal 。

我们将该参数修改为 mangled 来试一下:

const code = `
let hello = '1' + 1
console.log('hello', hello)
`

const options = {
    compact: true,
    identifierNamesGenerator: 'mangled'
}

运行结果如下:

const i=b;(function(c,d){const h=b,e=c();while(!![]){try{const f=parseInt(h(0x172))/0x1+parseInt(h(0x173))/0x2+parseInt(h(0x16c))/0x3*(-parseInt(h(0x16d))/0x4)+-parseInt(h(0x170))/0x
5+parseInt(h(0x174))/0x6+parseInt(h(0x16e))/0x7+-parseInt(h(0x16b))/0x8*(parseInt(h(0x16f))/0x9);if(f===d)break;else e['push'](e['shift']());}catch(g){e['push'](e['shift']());}}}(a,0
x3eb46));function b(c,d){const e=a();return b=function(f,g){f=f-0x16b;let h=e[f];return h;},b(c,d);}function a(){const j=['hello','284904hNCoMK','21092BbvmRU','1975248MBjueh','1191864xYzIqf','1392948ZnzluV','4pXOnYl','2503137BdrQzu','9TQmOgs','560560jnURtX'];a=function(){return j;};return a();}let hello='1'+0x1;console['log'](i(0x171),hello);

可以看到,这里的变量名都变成了 a、b 等形式。

如果我们将 identifierNamesGenerator 修改为 hexadecimal 或者不设置,运行结果如下:

可以看到,选用了 mangled,其代码体积会更小,但选用 hexadecimal 的可读性会更低。

另外,我们还可以通过设置 identifiersPrefix 参数来控制混淆后的变量前缀,示例如下:

const code = `
let hello = '1' + 1
console.log('hello', hello)
`

const options = {
    identifiersPrefix: 'germey'
}

运行结果如下:

function germey_0x5078(){const _0xeb5c78=['220231IgLEmx','804PNYByp','564708TXZyWI','196370yiUnGo','73968JxOdNz','423GBGxrE','14094330pyNNWV','9qwhmnB','4xGsykY','186ZGhIyo','5406655
TEygXB','330918bCSRyC'];germey_0x5078=function(){return _0xeb5c78;};return germey_0x5078();}function germey_0x4722(_0x53b50a,_0x553278){const _0x5078f6=germey_0x5078();return germey_
0x4722=function(_0x472248,_0xf2380b){_0x472248=_0x472248-0x154;let _0x988d83=_0x5078f6[_0x472248];return _0x988d83;},germey_0x4722(_0x53b50a,_0x553278);}(function(_0x1a416c,_0x44cb6f
){const _0x4e2e96=germey_0x4722,_0x3ffeea=_0x1a416c();while(!![]){try{const _0x20a2b9=parseInt(_0x4e2e96(0x15d))/0x1+parseInt(_0x4e2e96(0x15e))/0x2*(-parseInt(_0x4e2e96(0x156))/0x3)+
-parseInt(_0x4e2e96(0x157))/0x4*(parseInt(_0x4e2e96(0x159))/0x5)+-parseInt(_0x4e2e96(0x158))/0x6*(parseInt(_0x4e2e96(0x15a))/0x7)+-parseInt(_0x4e2e96(0x15f))/0x8*(-parseInt(_0x4e2e96
(0x154))/0x9)+parseInt(_0x4e2e96(0x155))/0xa+parseInt(_0x4e2e96(0x15b))/0xb*(parseInt(_0x4e2e96(0x15c))/0xc);if(_0x20a2b9===_0x44cb6f)break;else _0x3ffeea['push'](_0x3ffeea['shift']());}catch(_0x1d8b20){_0x3ffeea['push'](_0x3ffeea['shift']());}}}(germey_0x5078,0xdddba));let hello='1'+0x1;console['log']('hello',hello);

可以看到,混淆后的变量前缀加上了我们自定义的字符串 germey。

另外,renameGlobals 这个参数还可以指定是否混淆全局变量和函数名称,默认值为 false。示例如下:

const code = `
var $ = function(id) {
    return document.getElementById(id);
};
`

const options = {
    renameGlobals: true
}

运行结果如下:

function _0x1681(){var _0xc0a52f=['9aeQsGz','getElementById','12747992zdxQza','968139MPgKKe','118958LpBsVe','2209BeZREq','715uZnDpc','2119885iblOmt','545892shqtAW','4218080CIwcLW','4
aCPJdp','802rCbEOs','354ewbqNu'];_0x1681=function(){return _0xc0a52f;};return _0x1681();}function _0x308f(_0x3eb664,_0x375f8c){var _0x1681c5=_0x1681();return _0x308f=function(_0x308f
a3,_0x148d4b){_0x308fa3=_0x308fa3-0x81;var _0x3a4ea1=_0x1681c5[_0x308fa3];return _0x3a4ea1;},_0x308f(_0x3eb664,_0x375f8c);}(function(_0x1bd730,_0x5df440){var _0x26cecd=_0x308f,_0x256
4f2=_0x1bd730();while(!![]){try{var _0x2ec333=-parseInt(_0x26cecd(0x82))/0x1*(-parseInt(_0x26cecd(0x88))/0x2)+parseInt(_0x26cecd(0x8d))/0x3+-parseInt(_0x26cecd(0x87))/0x4*(parseInt(_
0x26cecd(0x84))/0x5)+parseInt(_0x26cecd(0x89))/0x6*(parseInt(_0x26cecd(0x81))/0x7)+parseInt(_0x26cecd(0x8c))/0x8*(parseInt(_0x26cecd(0x8a))/0x9)+parseInt(_0x26cecd(0x86))/0xa+parseIn
t(_0x26cecd(0x83))/0xb*(-parseInt(_0x26cecd(0x85))/0xc);if(_0x2ec333===_0x5df440)break;else _0x2564f2['push'](_0x2564f2['shift']());}catch(_0x1ba333){_0x2564f2['push'](_0x2564f2['shift']());}}}(_0x1681,0xce70f));var _0x83ad74=function(_0x5ea064){var _0x44d522=_0x308f;return document[_0x44d522(0x8b)](_0x5ea064);};

可以看到,这里我们声明了一个全局变量 $,在 renameGlobals 设置为 true 之后,$ 这个变量也被替换了。如果后文用到了这个 $ 对象,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。

如果我们不设置 renameGlobals 或者将其设置为 false,结果如下:

可以看到,最后还是有 $ 的声明,其全局名称没有被改变。

字符串混淆

字符串混淆,即将一个字符串声明放到一个数组里面,使之无法被直接搜到。这可以通过 stringArray 参数来控制,默认为 true。

此外,我们还可以通过 rotateStringArray 参数来控制数组化后结果的元素顺序,默认为 true。还可以通过 stringArrayEncoding 参数来控制数组的编码形式,默认不开启编码。如果将其设置为 true 或 base64,则会使用 Base64 编码; 如果设置为 rc4,则使用 RC4 编码。另外,可以通过 stringArrayThreshold 来控制启用编码的概率,其范围为 O 到 1,默认值为 O.8。

示例如下:

const code = `
var a = 'hello world'
`

const options = {
    stringArray: true,
    rotateStringArray: true,
    stringArrayEncoding: true, // 'base64' 或 'rc4' 或 false
    stringArrayThreshold: 1,
}

运行结果如下:

可以看到,这里就把字符串进行了 Base64 编码,我们再也无法通过查找的方式找到字符串的位置了。

如果将 stringArray 设置为 false 的话,输出就是这样:

var a='hello\x20world';

字符串就仍然是明文显示的,没有被编码。

另外,我们还可以使用 unicodeEscapeSequence 这个参数对字符串进行 Unicode 转码,使之更加难以辨认,示例如下:

const code = `
var a = 'hello world'
`

const options = {
    compact: false,
    unicodeEscapeSequence: true,
}

运行结果如下:

可以看到,这里字符串被数字化和 Unicode 化,非常难以辨认。

在很多 JavaScript 逆向的过程中,一些关键的字符串可能会作为切入点来查找加密入口。用了这种混淆之后,如果有人想通过全局搜索的方式搜索 hello 这样的字符串找加密入口,也没法搜到了。

代码自我保护

我们可以通过设置 selfDefending 参数来开启代码自我保护功能。开启之后,混淆后的 JavaScript 会强制以一行形式显示。如果我们将混淆后的代码进行格式化或者重命名,该段代码将无法执行。

示例如下:

const code = `
console.log('hello world');
`

const options = {
    selfDefer: true,
}

运行结果如下:

如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。

如果我们将其进行格式化,然后贴到测览器控制台里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。

控制流平坦化

控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂、难读。其基本思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断和分发,构成一个个闭环逻辑,这导致整个执行逻辑十分复杂、难读。

比如说这里有一段示例代码:

console.log(c);
console.log(a);
console.log(b);

代码逻辑一目了然,依次在控制台输出了 c、a、b 三个变量的值。但如果把这段代码进行控制流平坦化处理,代码就会变成这样:

const s = "3|1|2".split("|");
let x = 0;

while (true) {
    switch (s[x++]) {
        case "1":
            console.log(a);
            continue;
        case "2":
            console.log(b);
            continue
        case "3":
            console.log(c);
            continue;
    }
    break;
}

可以看到,混淆后的代码首先声明了一个变量 5,它的结果是一个列表,其实是["3","1","2"],然后下面通过 switch 语句对 s 中的元素进行了判断,每个 case 都加上了各自的代码逻辑。通过这样的处理,一些连续的执行逻辑就被打破了,代码被修改为一个 switch 语句,原本我们可以一眼看出的逻辑是控制台先输出 c,然后才是 a、b,但是现在我们必须结合 switch 的判断条件和对应 Case 的内容进行判断,我们很难再一眼看出每条语句的执行顺序了,这大大降低了代码的可读性。

在 javascript-obfuscator 中,我们通过 controlFlowFlattening 变量可以控制是否开启控制流平坦化,示例如下:

const options = {
    compact: false,
    controlFlowFlattening: true
}

使用控制流平坦化可以使得执行逻辑更加复杂、难读,目前非常多的前端混淆都会加上这个选项。但启用控制流平坦化之后,代码的执行时间会变长,最长达 1.5 倍之多。

另外,我们还能使用 controlFlowFlatteningThreshold 这个参数来控制比例,取值范围是 0 到 1,默认值为 0.75。如果将该参数设置为 0,那相当于将 controlFlowFlattening 设置为 false,即不开启控制流扁平化。

无用代码注入

无用代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的 JavaScript 代码的阅读形成干扰。我们可以使用 deadCodeInjection 参数开启这个选项,其默认值为 false。

比如,这里有一段代码:

const a = function () {
    console.log('hello world');
}

const b = function () {
    console.log('nice to meet you');
}

a();
b();

这里声明了方法 a 和 b,然后依次进行调用,分别输出两句话。

经过无用代码注入处理之后,代码就会变成类似这样:

可以看到,每个方法内部都增加了额外的 if…​else 语句,其中 if 的判断条件还是一个表达式,其结果是 true 还是 false 我们还不能一眼看出来,比如说 _0xlf7292 这个方法,它的 if 判断条件是:

"xmv2nOdfy2N".charAt(4) !== String.fromCharCode(110)

在不等号前面其实是从字符串中取出指定位置的字符,不等号后面则调用了 fromCharCode 方法来根据 ASCII 码转换得到一个字符,然后比较两个字符的结果是否是不一样的。前者经过推算,我们可以知道结果是 n;但对于后者,多数情况下我们还得去查一下 ASCII 码表,才能知道其结果也是 n。

最后两个结果是相同的,整个表达式的结果是 false,所以 if 后面跟的逻辑实际上就是不会被执行到的无用代码,但这些代码对我们阅读代码起到了一定的干扰作用。

因此,这种混淆方式通过混入一些特殊的判断条件并加入一些不会被执行的代码,可以对代码起到一定的混淆、干扰作用。

在 javascript-obfuscator 中,我们可以通过 deadCodeInjection 参数控制无用代码的注入,配置如下:

const options = {
    compact: false,
    deadCodeInjection: true
}

另外,我们还可以通过设置 deadCodeInjectionThreshold 参数来控制无用代码注入的比例。该参数的取值范围为 0 到 1,默认值是 0.4。

对象键名替换

如果是一个对象,可以使用 transformObjectKeys 来对对象的键值进行替换,示例如下:

const code = `(
    function () {
        var object = {
            foo: 'test1',
            bar: {
                baz: 'test2',
            }
        };
    }
)();
`
const options = {
    compact: false,
    transformObjectKeys: true
}

输出结果如下:

可以看到,Object 的变量名被替换为了特殊的变量,代码的可读性变差,这样我们就不好直接通过变量名进行搜寻了,这也可以起到一定的防护作用。

禁用控制台输出

我们可以使用 disableConsoleOutput 来禁用掉 console.log 输出功能,加大调试难度,示例如下:

const code = `
console.log('Hello World')
`

const options = {
    disableConsoleOutput: true,
}

运行结果如下:

此时,我们如果执行这段代码,发现是没有任何输出的,这里实际上就是将 console 的一些功能禁用了。

调试保护

我们知道,如果在 JavaScript 代码中加入 debugger 关键字,那么执行到该位置的时候,就会进入断点调试模式。如果在代码多个位置都加入 debugger 关键字,或者定义某个逻辑来反复执行 debugger,就会不断进人断点调试模式,原本的代码就无法顺畅执行了。这个过程可以称为调试保护,即通过反复执行 debugger 来使得原来的代码无法顺畅执行。

其效果类似于执行了如下代码:

setInterval(() => {debugger;}, 3000)

如果我们把这段代码粘贴到控制台,它就会反复执行 debugger 语句,进入断点调试模式,从而干扰正常的调试流程。

在 javascript-obfuscator 中,我们可以使用 debugProtection 来启用调试保护机制,还可以使用 debugProtectionInterval 来启用无限调试(debug),使得代码在调试过程中不断进入断点模式,无法顺畅执行。配置如下:

const options = {
    debugProtection: true,
    debugProtectionInterval: true,
}

混淆后的代码会不断跳到 debugger 代码的位置,使得整个代码无法顺畅执行,对 JavaScript 代码的调试形成一定的干扰。

域名锁定

我们还可以通过控制 domainLock 来控制 JavaScript 代码只能在特定域名下运行,这样就可以降低代码被模拟或盗用的风险。

示例如下:

const code = `
console.log('hello world')
`

const options = {
    domainLock: ['cuiqingcai.com']
}

这里我们使用 domainLock 指定了一个域名 cuiqingcai.com,也就是设置了一个域名白名单,混淆后的代码结果如下:

这段代码就只能在指定域名 cuiqingcai.com 下运行,不能在其他网站运行。这样的话,如果一些相关 JavaScript 代码被单独剥离出来,想在其他网站运行或者使用程序模拟运行的话,运行结果只有失败,这样就可以有效降低代码被模拟或盗用的风险。

特殊编码

另外,还有一些特殊的工具包(比如 aaencode、jjencode、jsfuck 等),它们可以对代码进行混淆和编码。

示例如下:

var a = 1

使用 jsfuck 工具的结果:

使用 aaencode 工具的结果:

使用 jjencode 工具的结果:

可以看到,通过这些工具,原本非常简单的代码被转化为一些几乎完全不可读的代码,但实际上运行效果还是相同的。这些混淆方式比较另类,看起来虽然没有什么头绪,但实际上找到规律是非常好还原的,并没有真正达到强力混淆的效果。

以上便是对 JavaScript 混淆方式的介绍和总结。总的来说,经过混淆的 JavaScript 代码的可读性大大降低,同时其防护效果也大大增强。

WebAssembly

随着技术的发展,WebAssembly 逐渐流行起来。不同于 JavaScript 混淆技术,WebAssembly 的基本思路是将一些核心逻辑使用其他语言(如 C/C++ 语言)来编写,并编译成类似字节码的文件,并通过 JavaScript 调用执行,从而起到二进制级别的防护作用。

WebAssembly 是一种可以使用非 JavaScript 编程语言编写代码并且能在浏览器上运行的技术方案,比如我们能将 C/C++ 文件利用 Emscripten 编译工具转成 wasm 格式的文件,JavaScript 可以直接调用该文件执行其中的方法。

WebAssembly 是经过编译器编译之后的字节码,可以从 C/C++ 编译而来,得到的字节码具有和 JavaScript 相同的功能,运行速度更快,体积更小,而且在语法上完全脱离 JavaScript, 同时具有沙盒化的执行环境。

比如,这就是一个基本的 WebAssembly 示例:

这里其实是利用 WebAssembly 定义了两个方法,分别是 add 和 square,分别用于求和和开平方计算。那这两个方法是在哪里声明的呢?其实它们被隐藏在 Uint8Array 里面。仅仅查看明文代码,我们确实无从知晓里面究竟定义了什么逻辑,但确实是可以执行的。我们将这段代码输入到浏览器控制台下,运行结果如下:

2 + 4 = 6
3^2 = 9
(2 + 5)^2 = 49

由此可见,通过 WebAssembly 我们可以成功将核心逻辑 “隐藏” 起来,这样某些核心逻辑就不能被轻易找出来了。

所以,很多网站越来越多地使用 WebAssembly 技术来保护一些核心逻辑不轻易被人识别或破解,可以起到更好的防护效果。

总结

在本节中,我们介绍了接口加密技术和 JavaScript 的压缩、混淆技术,也初步了解了 WebAssembly 技术。知己知彼方能百战不殆,了解了原理,我们才能更好地去实现 JavaScript 的逆向。

由于本节涉及一些专业名词,部分内容参考来源如下:

  • javascript-obfuscator 官方 GitHub 仓库

  • javascript-obfuscator 官网

  • 阮一峰的 “asm.js 和 Emscripten 入门教程”

  • 掘金上的 “JavaScript 混淆安全加固” 文章