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

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 变得更好。

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

10885 次点击
所在节点    程序员
36 条回复
mywaiting
2019-04-05 20:47:03 +08:00
怎么说呢?我觉得代码逆向这事情,还是得看着目标代码的价值。如果价值巨大,管你是 VM 层上再上 VM,那都是时间问题

我始终觉得,前端安全领域,或者说安全领域,代码自身的逻辑安全(指是否能逆向代码的实现过程),都不是决定安全的关键问题。Windows 不开源,底层有些 API 也没有公开,但不妨碍我们完全解构它的实现过程

从另外一个角度来说,VM 这货本身就是个黑客帝国里的模拟空间,执行在 VM 中的代码一旦 hook 在正确的位置,那解析出字节码是必然的
zyEros
2019-04-05 20:51:50 +08:00
@mywaiting 没有 100%的安全
mywaiting
2019-04-05 20:51:52 +08:00
当然了,我并不是否认你实现的价值,很多企业尤其是国字头企业,很喜欢这样的东西的,看在卖钱的份上,必须是个好东西

我就是想表达一下自己的看法吧,无关利益
zyEros
2019-04-05 20:57:32 +08:00
@mywaiting 我觉得更重要的是解决问题的思路,要勇于抛开一些陈旧观念,向前看
Mutoo
2019-04-05 20:59:00 +08:00
LZ 举的加解密的例子并没有什么意义,对前端来说,你把这个接口扔在前端开放了,有心的人根本不需要专门去破解,直接拿来黑盒使用就好了。你把它封装了,人家更容易调用。
zyEros
2019-04-05 21:00:19 +08:00
@Mutoo SecurityWorker 内部有环境判断,能很大程度阻断服务端黑盒使用,你可以试试
zyEros
2019-04-05 21:02:52 +08:00
@Mutoo 还有就是加解密接口数据是有意义的,可以防止数据传输过程中的暴露,你即使上了 https 也会有中间人劫持的风险,我觉得应该多方面看待这个问题
Mutoo
2019-04-05 21:18:25 +08:00
@zyEros 我破解过 flash 的视频请求协议,反编译直接啃 bytecode 然后提取加解密算法。而你这种直接在 web 前端的代码,即使把协议加密了,最终也敌不过 DevTools + 黑盒调用(根本不需要走到 https 协议)。并且在 DevTools 环境中,你的 SecurityWorker 并不能对环境作出有效判断,因为跟它正常运行是一样的。
zyEros
2019-04-05 21:35:27 +08:00
@Mutoo github 里面有实例代码,你可以试试再下结论
zyEros
2019-04-05 21:38:54 +08:00
@Mutoo 另外我觉得黑盒调用应该加个前提,大规模的黑盒调用,你可以用 devtool debug 试试,或者用 node 端拿到核心代码调用试试
whypool
2019-04-05 22:22:44 +08:00
说白了就是毫无卵用

客户端还想加密混淆代码和算法,怕是想得有点多
murmur
2019-04-05 22:26:33 +08:00
多少 c++的 native 应用都跪在了破~~解下
能不能被破解只看你的东西有多少人在用 xx 了有多少意义 各种游戏机还不是软硬结合被干掉
所以还是后端业务安全
zyEros
2019-04-05 22:27:10 +08:00
@whypool 对于你写的代码当然是不需要的。
zyEros
2019-04-05 22:29:21 +08:00
@murmur 尽可能减少损失,提高技术门槛,然后从法律手段来进行根本解决。
l1ve
2019-04-05 22:30:17 +08:00
js 各种调试走起,你再混淆又能怎样
有种上 flash 我认输~~
zyEros
2019-04-05 22:31:33 +08:00
@l1ve 你估计是没看文章
hkitdog
2019-04-05 22:56:27 +08:00
CRUD 有什么好保护的,又不像游戏那样
l1ve
2019-04-05 23:50:16 +08:00
@zyEros 嵌套私有 vm
别的场景不清楚,相对于直播场景里的视频源加密来说这个实现太丑陋了。

说白了目标就两个
1.地址用一次就废掉
2.前端不能知道地址规律

地址由后端生成,基于 token 的算法。限定连接数并且由 session 控制。传输完这个切片中的所有信息就销毁这个地址。

前端安全不可能靠你扔给用户端一个黑盒子就不管了。一个能随意调试的黑盒要远远比后端的黑盒更不安全。
zyEros
2019-04-05 23:53:45 +08:00
@l1ve 我不清楚你做没做过直播,了不了解现有的直播的 CDN 对接方案,在我有限的经验中,你说的限定连接数和 session 控制我觉得是很难完成的任务。

另外 SecurityWorker 无法被随意调试(反调试),并且内部有基于环境的判断,能够阻断大部分的大规模黑盒调用。
zyEros
2019-04-05 23:55:26 +08:00
@l1ve 另外我想强调的是,SecurityWorker 这个方案是整个方案中的一环,并不是说用上了就安全 100 分了,我觉得你们都是在故意放大『前端无加密』这种论调。

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

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

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

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

© 2021 V2EX