[硬核文] 前端核心代码保护技术面面观

2019-04-05 20:00:07 +08:00
 zyEros

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 多环境支持
  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,我们的执行流程变成了如下图所示:

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

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

1 + 2;

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

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

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

整个过程中,由于我们的 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 变得更好。

转载请私信征求作者意愿,谢谢

10551 次点击
所在节点    程序员
36 条回复
fengjianxinghun
2019-04-06 00:33:55 +08:00
感谢楼主,很好的文章。
在浏览器人机识别领域,前端机密是必须的
比如 Google 的的 ReCaptcha

https://github.com/ReCaptchaReverser/InsideReCaptcha


Technical Highlights:

To obfuscate the code, Google created a new VM based language run inside Javascript
The code run in this VM is encrypted
The encrypted code can dynamically change it's own encryption key at runtime
The VM code can also dynamically change what VM instructions do ​
What data Google collects:

Browser Plug-Ins
User-Agent
Screen resolution
Timestamp
Time zone
Cookies
Number of click/keyboard/touch actions
Known browser-specific CSS quirks
Tests HTML5 canvas support
l1ve
2019-04-06 00:35:49 +08:00
@zyEros 确实没做过直播,不过有句话听过吗。把字刻在石头上。
录屏做视频流其实就搞定了。

另外,贵司官网打开极慢,试着加密了一个
```
a = 1;
b = 2;
alert(a+b);
console.log("Hello World!");
```
结果是这样,Safari 和 chrome 都挂
https://2up.ooo/file/get/415/WX20190406-003102@2x.png
可惜了,浪费好久时间
envylee
2019-04-06 00:42:54 +08:00
前面几楼的傻逼都是张口就来?
zyEros
2019-04-06 00:43:19 +08:00
@l1ve 希望你有阅读文档和看注意事项的能力。还 alert,呵呵
Mutoo
2019-04-06 10:45:05 +08:00
@zyEros 大规模的话当然不会用 devtools 而且用 headless chrome 的爬虫集群了。本质一样的,这些环境并不是 node.js 而是中规中矩的浏览器环境。
zyEros
2019-04-06 11:08:43 +08:00
@Mutoo 就算盗流这种场景,你用 headless 去获取播放地址提供给大量在线用户,我觉得这个成本可以再想想看。我觉得还是不要为了杠而杠,因为我觉得你没遇到过其场景,所以目前说的东西并不可具备实用性。
Mutoo
2019-04-06 14:04:58 +08:00
@zyEros 我就是想不到有啥很好的使用场景,所以希望你作为作者可以提供更合理的案例。
zyEros
2019-04-06 14:36:33 +08:00
@Mutoo 上面不是有人说了么,google recaptcha,你觉得有信心的话可以去参加 google recaptcha 的破解比赛。我们自己的项目不太能对外说。

https://github.com/ReCaptchaReverser/InsideReCaptcha
janxin
2019-04-06 16:42:33 +08:00
wasm 这种没什么用的,跟 JS 的伪代码可以等同
beordle
2019-04-06 16:43:08 +08:00
光一个 vmp 楼主还起这个 title 很不可思议,你被黑我觉得和 title 有关,很容易让人反感..至少得有下服务器动态算法代码下发,vmp 动态更新等,所谓的安全,毕竟是一个系统。面面观确实有点大了...
zyEros
2019-04-06 18:24:31 +08:00
@janxin 看文章
zyEros
2019-04-06 18:28:19 +08:00
@beordle 从这边我看到在杠的基本上都是不看文章的,我想了一下这个 title 然后问了下其他人,加上前面的定语我觉得并没有问题,Web 前端目前的核心代码保护确实只有这几种,而且前言也已经提到了整个文章脉络。谢谢建议。
gkiwi
2019-04-07 19:56:42 +08:00
听说过楼主的思路,楼主讲解的也比较明了。安全没有 100%,但是可以不停的提高门槛。
估计楼上好多人都不知道 wasm,也不知道 webworker~
fengjianxinghun
2019-06-02 10:00:12 +08:00
我也试了下楼主这个方案,核心是 duktape 这个嵌入式 jsvm 工程。
1: 修改 duktape 的 makefile 中的 dukweb target 把默认 emcc 的 wasm 改成 asm.js ,导出合适的函数,emcc 链接改成 c++14。
2: 编译出 duk 命令行程序,这个命令行程序可以编译 js 为 duktape 的字节码。
3: 写个脚本把字节码变成一个 c 字节数组,include 到 dukweb cpp 里
4: 通过 emcc 的 cpp api bind 一些浏览器函数到 duktape vm 里
5: 编译成最终的 asm.js
zyEros
2021-05-19 09:50:07 +08:00
SecurityWorker 不再维护,你可以选择更好的代替方案 https://github.com/sablejs/sablejs
sirz
130 天前
楼主这个方案咋用啊?能本地运行测试吗,有没有相关文档

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/552383

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX