方老湿写不来的系列一:JavaScript核心加密

9,248 阅读13分钟

1、 前言

Web的开放与便捷带来了极高速的发展,但同时也带来了相当多的隐患,特别是针对于核心代码保护上,自作者从事Web前端相关开发的相关工作以来,并未听闻到太多相关于此的方案,『前端代码无秘密』这句话好似一个业界共识一般在前端领域传播。但在日常的开发过程中,我们又会涉及以及需要相当强度的前端核心代码的加密,特别是在于与后端的数据通信上面(包括HTTP、HTTPS请求以及WebSocket的数据交换)。

考虑一个场景,在视频相关的产品中,我们通常需要增加相关的安全逻辑防止被直接盗流或是盗播。特别是对于直播来说,我们的直播视频流文件通常会被划分为分片然后通过协商的算法生成对应的URL参数并逐次请求。分片通常以5至10秒一个间隔,如果将分片URL的获取作为接口完全放置于后端,那么不仅会给后端带来极大的压力外还会带来直播播放请求的延迟,因此我们通常会将部分实现放置于前端以此来减少后端压力并增强体验。对于iOS或是Android来说,我们可以将相关的算法通过C/C++进行编写,然后编译为dylib或是so并进行混淆以此来增加破解的复杂度,但是对于前端来说,并没有类似的技术可以使用。当然,自从asm.js及WebAssembly的全面推进后,我们可以使用其进一步增强我们核心代码的安全性,但由于asm.js以及WebAssembly标准的开放,其安全强度也并非想象中的那么美好。

本文首先适当回顾目前流行的前端核心代码保护的相关技术思路及简要的实现,后具体讲述一种更为安全可靠的前端核心代码保护的思路(SecurityWorker)供大家借鉴以及改进。当然,作者并非专业的前端安全从业者,对部分技术安全性的理解可能稍显片面及不足,欢迎留言一起探讨。

2、 使用JavaScript的混淆器

在我们的日常开发过程中,对于JavaScript的混淆器我们是不陌生的,我们常常使用其进行代码的压缩以及混淆以此来减少代码体积并增加人为阅读代码的复杂度。常使用的项目包括:

JavaScript混淆器的原理并不复杂,其核心是对目标代码进行AST Transformation(抽象语法树改写),我们依靠现有的JavaScript的AST Parser库,能比较容易的实现自己的Javascript混淆器。以下我们借助 acorn 来实现一个if语句片段的改写。

假设我们存在这么一个代码片段:

for(var i = 0; i < 100; i++){
    if(i % 2 == 0){
        console.log("foo");
    }else{
        console.log("bar");
    }
}

我们通过使用UglifyJS进行代码的混淆,我们能够得到如下的结果:

for(var i=0;i<100;i++)i%2==0?console.log("foo"):console.log("bar");

现在让我们尝试编写一个自己的混淆器对代码片段进行混淆达到UglifyJS的效果:


const {Parser} = require("acorn")
const MyUglify = Parser.extend();

const codeStr = `
for(var i = 0; i < 100; i++){
    if(i % 2 == 0){
        console.log("foo");
    }else{
        console.log("bar");
    }
}
`;

function transform(node){
    const { type } = node;
    switch(type){
        case 'Program': 
        case 'BlockStatement':{
            const { body } = node;
            return body.map(transform).join('');
        }
        case 'ForStatement':{
            const results = ['for', '('];
            const { init, test, update, body } = node;
            results.push(transform(init), ';');
            results.push(transform(test), ';');
            results.push(transform(update), ')');
            results.push(transform(body));
            return results.join('');
        }
        case 'VariableDeclaration': {
            const results = [];
            const { kind, declarations } = node;
            results.push(kind, ' ', declarations.map(transform));
            return results.join('');
        }
        case 'VariableDeclarator':{
            const {id, init} = node;
            return id.name + '=' + init.raw;
        }
        case 'UpdateExpression': {
            const {argument, operator} = node;
            return argument.name + operator;
        }
        case 'BinaryExpression': {
            const {left, operator, right} = node;
            return transform(left) + operator + transform(right);
        }
        case 'IfStatement': {
            const results = [];
            const { test, consequent, alternate } = node;
            results.push(transform(test), '?');
            results.push(transform(consequent), ":");
            results.push(transform(alternate));
            return results.join('');
        }
        case 'MemberExpression':{
            const {object, property} = node;
            return object.name + '.' + property.name;
        }
        case 'CallExpression': {
            const results = [];
            const { callee, arguments } = node;
            results.push(transform(callee), '(');
            results.push(arguments.map(transform).join(','), ')');
            return results.join('');
        }
        case 'ExpressionStatement':{
            return transform(node.expression);
        }
        case 'Literal':
            return node.raw;
        case 'Identifier':
            return node.name;
        default:
            throw new Error('unimplemented operations');
    }
}

const ast = MyUglify.parse(codeStr);
console.log(transform(ast)); // 与UglifyJS输出一致

当然,我们上面的实现只是一个简单的举例,实际上的混淆器实现会比当前的实现复杂得多,需要考虑非常多的语法上的细节,此处仅抛砖引玉供大家参考学习。

从上面的实现我们可以看出,JavaScript混淆器只是将JavaScript代码变化为另一种更不可读的形式,以此来增加人为分析的难度从而达到增强安全的目的。这种方式在很久以前具有很不错的效果,但是随着开发者工具越来越强大,实际上通过单步调试可以很容易逆向出原始的Javascript的核心算法。当然,后续也有相当多的库做了较多的改进,JavaScript Obfuscator Tool 是其中的代表项目,其增加了诸如反调试、变量前缀、变量混淆等功能增强安全性。但万变不离其宗,由于混淆后的代码仍然是明文的,如果有足够的耐心并借助开发者工具我们仍然可以尝试还原,因此安全性仍然大打折扣。

3、 使用Flash的C/C++扩展方式

在Flash还大行其道的时期,为了更好的方便引擎开发者使用C/C++来提升Flash游戏相关引擎的性能,Adobe开源了 CrossBridge 这个技术。在这种过程中,原有的C/C++代码经过LLVM IR变为Flash运行时所需要的目标代码,不管是从效率提升上还是从安全性上都有了非常大的提升。对于目前的开源的反编译器来说,很难反编译由CorssBridge编译的C/C++代码,并且由于Flash运行时生产环境中禁用调试,因此也很难进行对应的单步调试。

使用Flash的C/C++扩展方式来保护我们的前端核心代码看起来是比较理想的方法,但Flash的移动端上已经没有任何可被使用的空间,同时Adobe已经宣布2020年不再对Flash进行维护,因此我们完全没有理由再使用这种方法来保护我们前端的核心代码。

当然,由于Flash目前在PC上仍然有很大的占有率,并且IE10以下的浏览器仍然有不少份额,我们仍旧可以把此作为一种PC端的兼容方案考虑进来。

4、使用asm.js或WebAssembly

为了解决Javascript的性能问题,Mozilla提出了一套新的面相底层的Javascript语法子集 -- asm.js,其从JIT友好的角度出发,使得Javascript的整体运行性能有了很大的提升。后续Mozilla与其他厂商进行相关的标准化,产出了WebAssembly标准。

不管是asm.js或是WebAssembly,我们都可以将其看作为一个全新的VM,其他语言通过相关的工具链产出此VM可执行的代码。从安全性的角度来说,相比单纯的Javascript混淆器而言,其强度大大的增加了,而相比于Flash的C/C++扩展方式来说,其是未来的发展方向,并现已被主流的浏览器实现。

可以编写生成WebAssembly的语言及工具链非常多,我们使用C/C++及其Emscripten作为示范编写一个简单的签名模块进行体验。

#include <string>
#include <emscripten.h>
#include <emscripten/bind.h>
#include "md5.h"

#define SALTKEY "md5 salt key"

std::string sign(std::string str){
    return md5(str + string(SALTKEY));
}

// 此处导出sign方法供Javascript外部环境使用
EMSCRIPTEN_BIND(my_module){
    emscripten::function("sign", &sign);
}

接着,我们使用emscripten编译我们的C++代码,得到对应的生成文件。

em++ -std=c++11 -Oz --bind \
    -I ./md5 ./md5/md5.cpp ./sign.cpp \
    -o ./sign.js

最后,我们引入生成sign.js文件,然后进行调用。

<body>
    <script src="./sign.js"></script>
    <script>
        // output: 0b57e921e8f28593d1c8290abed09ab2
        Module.sign("This is a test string");
    </script>
</body>

目前看起来WebAssembly是目前最理想的前端核心代码保护的方案了,我们可以使用C/C++编写相关的代码,使用Emscripten相关工具链编译为asm.js和wasm,根据不同的浏览器的支持情况选择使用asm.js还是wasm。并且对于PC端IE10以下的浏览器,我们还可以通过CrossBridge复用其C/C++代码,产出对应的Flash目标代码,从而达到非常好的浏览器兼容性。

然而使用asm.js/wasm后对于前端核心代码的保护就可以高枕无忧了么?由于asm.js以及wasm的标准规范都是完全公开的,因此对于asm.js/wasm标准实现良好反编译器来说,完全可以尽可能的产出阅读性较强的代码从而分析出其中的核心算法代码。但幸运的是,目前作者还暂时没有找到实现良好的asm.js/wasm反编译器,因此我暂时认为使用此种方法在保护前端核心代码的安全性上已经可堪重用了。

5、SecurityWorker - 更好的思路及其实现

作者在工作当中经常性会编写前端核心相关的代码,并且这些代码大部分与通信相关,例如AJAX的请求数据的加解密,WebSocket协议数据的加解密等。对于这部分工作,作者通常都会使用上面介绍的asm.js/wasm加CrossBridge技术方案进行解决。这套方案目前看来相当不错,但是仍然存在几个比较大的问题:

  1. 前端不友好,大部分前端工程师不熟悉C/C++、Rust等相关技术体系
  2. 无法使用庞大的npm库,增加了很多工作成本
  3. 长远来看并非会有很大的破解成本,还需要进一步对安全这块进行提升

因此我们花费两周时间编写一套基于asm.js/wasm更好的前端核心代码保护方案:SecurityWorker

5.1 目标

SecurityWorker的目标相当简单:能够尽可能舒适的编写具有极强安全强度的核心算法模块。其拆分下来实际上需要满足以下8点:

  1. 代码使用Javascript编写,避免C/C++、Rust等技术体系
  2. 能够很顺利的使用npm相关库,与前端生态接轨
  3. 最终代码尽可能小
  4. 保护性足够强,目标代码执行逻辑及核心算法完全隐匿
  5. Browser/小程序/NodeJS多环境支持(默认不允许Node端使用,防止大规模黑盒调用)
  6. 良好的兼容性,主流浏览器全兼容
  7. 易于使用,能够复用标准中的技术概念
  8. 易于调试,源码不混淆,报错信息准确具体

接下来我们会逐步讲解SecurityWorker如何达成这些目标并详细介绍其原理,供大家参考改进。

5.2 实现原理

如何在WebAssembly基础上提升安全性?回想之前我们的介绍,WebAssembly在安全性上一个比较脆弱的点在于WebAssembly标准规范的公开,如果我们在WebAssembly之上再创建一个私有独立的VM是不是可以解决这个问题呢?答案是肯定的,因此我们首要解决的问题是如何在WebAssembly之上建立一个Javascript的独立VM。这对于WebAssembly是轻而易举的,有非常多的项目提供了参考,例如基于SpiderMonkey编译的 js.js 项目。但我们并没有考虑使用SpiderMonkey,因为其产出的wasm代码达到了50M,在Web这样代码体积大小敏感的环境基本不具有实际使用价值。但好在ECMAScirpt相关的嵌入式引擎非常之多:

  1. JerryScript
  2. V7
  3. duktape
  4. Espruino
  5. ...

经过比较选择,我们选择了duktape作为我们基础的VM,我们的执行流程变成了如下图所示:



01.jpg

当然,从图中我们可以看到整个过程实际上会有一个比较大的风险点,由于我们的代码是通过字符串加密的方式嵌入到C/C++中进行编译的,因此在执行过程中,我们是能在内存的某一个运行时期等待代码解密完成后拿到核心代码的,如下图所示:



02.jpg

如何解决这个问题?我们的解决思路是将Javascript变成另一种表现形式,也就是我们常见的opcode,例如假设我们有这样的代码:

1 + 2;

我们会将其转变类似汇编指令的形式:

SWVM_PUSH_L 1  # 将1值压入栈中
SWVM_PUSH_L 2  # 将2值压入栈中
SWVM_ADD       # 对值进行相加,并将结果压入栈中

最后我们将编译得到的opcode bytes按照uint8数组的方式嵌入到C/C++中,然后进行整体编译,如图所示:

03.jpg

整个过程中,由于我们的opcode设计是私有不公开的,并且已经不存在明文的Javascript代码了,因此安全性得到了极大的提升。如此这样我们解决了目标中的#1、#2、#4。但Javascript已经被重新组织为opcode了,那么如何保证目标中的#8呢?解决方式很简单,我们在Javascript编译为opcode的关键步骤上附带了相关的信息,使得代码执行出错后,能够根据相关信息进行准确的报错。与此同时,我们精简了opcode的设计,使得生成的opcode体积小于原有的Javascript代码。

duktape除了语言实现和部分标准库外并不还有一些外围的API,例如AJAX/WebSocket等,考虑到使用的便捷性以及更容易被前端开发者接收并使用,我们为duktape实现了部分的WebWorker环境的API,包括了Websocket/Console/Ajax等,并与Emscripten提供的Fetch/WebSocket等实现结合得到了SecurityWorker VM。

那么最后的问题是我们如何减小最终生成的asm.js/wasm代码的体积大小?在不进行任何处理的时候,我们的生成代码由于包含了duktape以及诸多外围API的实现,即使一个Hello World的代码gzip后也会有340kb左右的大小。为了解决这个问题,我们编写了SecurityWorker Loader,将生成代码进行处理后与SecurityWorker Loader的实现一起编译得到最终的文件。在代码运行时,SecurityWorker Loader会对需要运行的代码进行释放然后再进行动态执行。如此一来,我们将原有的代码体积从原有gzip也会有340kb左右的大小降低到了180kb左右。

5.3 局限性

SecurityWorker解决了之前方案的许多问题,但其同样不是最完美的方案,由于我们在WebAssembly上又创建了一个VM,因此当你的应用对于体积敏感或是要求极高的执行效率时,SecurityWorker就不满足你的要求了。当然SecurityWorker可以应用多种优化手段在当前基础上再大幅度的缩减体积大小以及提高执行效率,但由于其已经达到我们自己现有的需求和目标,因此目前暂时没有提升的相关计划。

6、结语

我们通过回顾目前主流的前端核心保护方案,并详细介绍了基于之前方案做的提升方案SecurityWorker,相信大家对整个前端核心算法保护的技术方案已经有一个比较清晰的认识了。当然,对于安全的追求没有终途,SecurityWorker也不是最终完美的方案,希望本文的相关介绍能让更多人参与到WebAssembly及前端安全领域中来,让Web变得更好。

方老湿,您学会了么?