做者简介:于航,PayPal Senior Software Engineer,在 PayPal 上海负责 Global GRT 平台相关的技术研发工做。曾任职于阿里巴巴、Tapatalk 等企业。freeCodeCamp 上海社区负责人。研究领域主要为前端基础技术架构、Serverless、WebAssembly、LLVM 及编译器等相关方向。javascript
说到 Web 前端开发,咱们首先可以想到的是浏览器、HTML、CSS 以及 JavaScript 这些开发时所必备使用的软件工具和编程语言。而在这个专业领域中,做为开发者咱们众所周知的是,全部来自前端的数据都是“不可信”的,因为构成前端业务逻辑和交互界面的全部相关代码都是能够被用户直接查看到的,因此咱们没法保证咱们所确信的某个从前端传递到后端的数据没有被用户曾经修改过。前端
那么是否有办法能够将前端领域中那些与业务有关的代码(好比数据处理逻辑、验证逻辑等,一般是 JavaScript 代码)进行加密以防止用户进行恶意修改呢?本文咱们将讨论这方面的内容。java
提到“加密”,咱们天然会想到众多与“对称加密”、“非对称加密”以及“散列加密”相关的算法,好比 AWS 算法、RSA 算法与 MD5 算法等。在传统的 B-S 架构下,前端经过公钥进行加密处理的数据能够在后端服务器再经过相应私钥进行解密来获得原始数据,可是对于前端的业务代码而言,因为浏览器自己没法识别运行这些被加密过的源代码,所以实际上传统的加密算法并不能帮助咱们解决“如何彻底黑盒化前端业务逻辑代码”这一问题。node
既然没法彻底隐藏前端业务逻辑代码的实际执行细节,那咱们就从另外一条路以“下降代码可读性”的方式来“伪黑盒化前端业务逻辑代码”。一般的方法有以下几种:ios
第三方插件算法
咱们所熟知的可用在 Web 前端开发中的第三方插件主要有:Adobe Flash、Java Applet 以及 Silverlight 等。因为历史缘由这里咱们不会深刻介绍基于这些第三方插件的前端业务代码加密方案。其中 Adobe 将于 2020 年彻底中止对 Flash 技术的支持,Chrome、Edge 等浏览器也开始逐渐对使用了 Flash 程序的 Web 页面进行阻止或弹出相应的警告。一样的,来自微软的 Silverlight5 也会在 2021 年中止维护,并彻底终止后续新版本功能的开发。而 Java Applet 虽然还能够继续使用,但相较于早期上世纪 90 年代末,如今已然不多有人使用(不彻底统计)。而且须要基于 JRE 来运行也使得 Applet 应用的运行成本大大提升。编程
代码混淆后端
在现代前端开发过程当中,咱们最经常使用的一种能够“下降源代码可读性”的方法就是使用“代码混淆”。一般意义上的代码混淆能够压缩原始 ASCII 代码的体积并将其中的诸如变量、常量名用简短的毫无心义的标识符进行代替,这一步能够简单地理解为“去语义化”。以咱们最经常使用的 “Uglify” 和 “GCC (Google Closure Compiler)” 为例,首先是一段未经代码混淆的原始 ECMAScript5 源代码:浏览器
let times = 0.1 * 8 + 1; function getExtra(n) { return [1, 4, 6].map(function(i) { return i * n; }); } var arr = [8, 94, 15, 88, 55, 76, 21, 39]; arr = getExtra(times).concat(arr.map(function(item) { return item * 2; })); function sortarr(arr) { for(i = 0; i < arr.length - 1; i++) { for(j = 0; j < arr.length - 1 - i; j++) { if(arr[j] > arr[j + 1]) { var temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } return arr; } console.log(sortarr(arr));
通过 UglifyJS3 的代码压缩混淆处理后的结果:服务器
let times=1.8;function getExtra(r){return[1,4,6].map(function(t){return t*r})}var arr=[8,94,15,88,55,76,21,39];function sortarr(r){for(i=0;i<r.length-1;i++)for(j=0;j<r.length-1-i;j++)if(r[j]>r[j+1]){var t=r[j];r[j]=r[j+1],r[j+1]=t}return r}arr=getExtra(times).concat(arr.map(function(r){return 2*r})),console.log(sortarr(arr));
通过 Google Closure Compiler 的代码压缩混淆处理后的结果:
var b=[8,94,15,88,55,76,21,39];b=function(a){return[1,4,6].map(function(c){return c*a})}(1.8).concat(b.map(function(a){return 2*a}));console.log(function(a){for(i=0;i<a.length-1;i++)for(j=0;j<a.length-1-i;j++)if(a[j]>a[j+1]){var c=a[j];a[j]=a[j+1];a[j+1]=c}return a}(b));
对比上述两种工具的代码混淆压缩结果咱们能够看到,UglifyJS 不会对原始代码进行“重写”,全部的压缩工做都是在代码原有结构的基础上进行的优化。而 GCC 对代码的优化则更靠近“编译器”,除了常见的变量、常量名去语义化外,还使用了常见的 DCE 优化策略,好比对常量表达式(constexpr)进行提早求值(0.1 * 8 + 1)、经过 “inline” 减小中间变量的使用等等。
UglifyJS 在处理优化 JavaScript 源代码时都是以其 AST 的形式进行分析的。好比在 Node.js 脚本中进行源码处理时,咱们一般会首先使用 UglifyJS.parse 方法将一段 JavaScript 代码转换成其对应的 AST 形式,而后再经过 UglifyJS.Compressor 方法对这些 AST 进行处理。最后还须要经过 print_to_string 方法将处理后的 AST 结构转换成相应的 ASCII 可读代码形式。UglifyJS.Compressor 的本质是一个官方封装好的 “TreeTransformer” 类型,其内部已经封装好了众多经常使用的代码优化策略,而经过对 UglifyJS.TreeTransformer 进行适当的封装,咱们也能够编写本身的代码优化器。
以下所示咱们编写了一个实现简单“常量传播”与“常量折叠”(注意这里实际上是变量,但优化形式同 C++ 中的这两种基本优化策略相同)优化的 UglifyJS 转化器。
const UglifyJS = require('uglify-js'); var symbolTable = {}; var binaryOperations = { "+": (x, y) => x + y, "-": (x, y) => x - y, "*": (x, y) => x * y } var constexpr = new UglifyJS.TreeTransformer(null, function(node) { if (node instanceof UglifyJS.AST_Binary) { if (Number.isInteger(node.left.value) && Number.isInteger(node.right.value)) { return new UglifyJS.AST_Number({ value: binaryOperations[node.operator].call(this, Number(node.left.value), Number(node.right.value)) }); } else { return new UglifyJS.AST_Number({ value: binaryOperations[node.operator].call(this, Number(symbolTable[node.left.name].value), Number(symbolTable[node.right.name].value)) }) } } if (node instanceof UglifyJS.AST_VarDef) { // AST_VarDef -> AST_SymbolVar; // 经过符号表来存储已求值的变量值(UglifyJS.AST_Number)引用; symbolTable[node.name.name] = node.value; } }); var ast = UglifyJS.parse(` var x = 10 * 2 + 6; var y = 4 - 1 * 100; console.log(x + y); `); // transform and print; ast.transform(constexpr); console.log(ast.print_to_string()); // output: // var x=26;var y=-96;console.log(-70);
这里咱们经过识别特定的 Uglify AST 节点类型(UglifyJS.AST_Binary / UglifyJS.AST_VarDef)来达到对代码进行精准处理的目的。能够看到,变量 x 和 y 的值在代码处理过程当中被提早计算。不只如此,其做为变量的值还被传递到了表达式 a + b 中,此时若是可以再结合简单的 DCE 策略即可以完成最初级的代码优化效果。相似的,其实经过 Babel 的 @babel/traverse 插件,咱们也能够实现一样的效果,其所基于的原理也都大同小异,即对代码的 AST 进行相应的转换和处理。
WebAssembly
关于 Wasm 的基本介绍,这里咱们再也不多谈。那么到底应该如何利用 Wasm 的“字节码”特性来作到尽量地作到“下降 JavaScript 代码可读性”这一目的呢?一个简单的 JavaScript 代码“加密”服务系统架构图以下所示:
这里整个系统分为两个处理阶段:
第一阶段:先将明文的 JavaScript 代码转换为基于特定 JavaScript 引擎(VM)的 OpCode 代码,这些二进制的 OpCode 代码会再经过诸如 Base64 等算法的处理而转换为通过编码的明文 ASCII 字符串格式;
第二阶段:将上述通过编码的 ASCII 字符串连同对应的 JavaScript 引擎内核代码统一编译成完整的 ASM / Wasm 模块。当模块在网页中加载时,内嵌的 JavaScript 引擎便会直接解释执行硬编码在模块中的、通过编码处理的 OpCode 代码;
好比咱们如下面这段处于 Top-Level 层的 JavaScript 代码为例:
[1, 2, 3, 5, 6, 7, 8, 9].map(function(i) { return i * 2; }).reduce(function(p, i) { return p + i; }, 0);
按照正常的 VM 执行流程,上述代码在执行后会返回计算结果 82。这里咱们以 JerryScript 这个开源的轻量级 JavaScript 引擎来做为例子,第一步首先将上述 ASCII 形式的代码 Feed 到该引擎中,而后即可以得到对应该引擎中间状态的 ByteCode 字节码。
而后再将这些二进制的字节码经过 Base64 算法编码成对应的可见字符形式。结果以下所示:
WVJSSgAAABYAAAAAAAAAgAAAAAEAAAAYAAEACAAJAAEEAgAAAAAABwAAAGcAAABAAAAAWDIAMhwyAjIBMgUyBDIHMgY6CCAIwAIoAB0AAToARscDAAAAAAABAAMBAQAhAgIBAQAAACBFAQCPAAAAAAABAAICAQAhAgICAkUBAIlhbQADAAYAcHVkZXIAAGVjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
按照咱们的架构思路,这部分被编码后的可见字符串会做为“加密”后的源代码被硬编码到包含有 VM 引擎核心的 Wasm 模块中。当模块被加载时,VM 会经过相反的顺序解码这段字符串,并获得二进制状态的 ByteCode。而后再经过一块儿打包进来的 VM 核心来执行这些中间状态的比特码。这里咱们上述所提到的 ByteCode 其实是以 JerryScript 内部的 SnapShot 快照结构存在于内存中的。
最后这里给出上述 Demo 的主要部分源码,详细代码能够参考 Github:
#include "jerryscript.h" #include "cppcodec/base64_rfc4648.hpp" #include <iostream> #include <vector> #define BUFFER_SIZE 256 #ifdef WASM #include "emscripten.h" #endif std::string encode_code(const jerry_char_t*, size_t); const unsigned char* transferToUC(const uint32_t* arr, size_t length) { auto container = std::vector<unsigned char>(); for (size_t x = 0; x < length; x++) { auto _t = arr[x]; container.push_back(_t >> 24); container.push_back(_t >> 16); container.push_back(_t >> 8); container.push_back(_t); } return &container[0]; } std::vector<uint32_t> transferToU32(const uint8_t* arr, size_t length) { auto container = std::vector<uint32_t>(); for (size_t x = 0; x < length; x++) { size_t index = x * 4; uint32_t y = (arr[index + 0] << 24) | (arr[index + 1] << 16) | (arr[index + 2] << 8) | arr[index + 3]; container.push_back(y); } return container; } int main (int argc, char** argv) { const jerry_char_t script_to_snapshot[] = u8R"( [1, 2, 3, 5, 6, 7, 8, 9].map(function(i) { return i * 2; }).reduce(function(p, i) { return p + i; }, 0); )"; std::cout << encode_code(script_to_snapshot, sizeof(script_to_snapshot)) << std::endl; return 0; } std::string encode_code(const jerry_char_t script_to_snapshot[], size_t length) { using base64 = cppcodec::base64_rfc4648; // initialize engine; jerry_init(JERRY_INIT_SHOW_OPCODES); jerry_feature_t feature = JERRY_FEATURE_SNAPSHOT_SAVE; if (jerry_is_feature_enabled(feature)) { static uint32_t global_mode_snapshot_buffer[BUFFER_SIZE]; // generate snapshot; jerry_value_t generate_result = jerry_generate_snapshot( NULL, 0, script_to_snapshot, length - 1, 0, global_mode_snapshot_buffer, sizeof(global_mode_snapshot_buffer) / sizeof(uint32_t)); if (!(jerry_value_is_abort(generate_result) || jerry_value_is_error(generate_result))) { size_t snapshot_size = (size_t) jerry_get_number_value(generate_result); std::string encoded_snapshot = base64::encode( transferToUC(global_mode_snapshot_buffer, BUFFER_SIZE), BUFFER_SIZE * 4); jerry_release_value(generate_result); jerry_cleanup(); // encoded bytecode of the snapshot; return encoded_snapshot; } } return "[EOF]"; } void run_encoded_snapshot(std::string code, size_t snapshot_size) { using base64 = cppcodec::base64_rfc4648; auto result = transferToU32( &(base64::decode(code)[0]), BUFFER_SIZE); uint32_t snapshot_decoded_buffer[BUFFER_SIZE]; for (auto x = 0; x < BUFFER_SIZE; x++) { snapshot_decoded_buffer[x] = result.at(x); } jerry_init(JERRY_INIT_EMPTY); jerry_value_t res = jerry_exec_snapshot( snapshot_decoded_buffer, snapshot_size, 0, 0); // default as number result; std::cout << "[Zero] code running result: " << jerry_get_number_value(res) << std::endl; jerry_release_value(res); } #ifdef WASM extern "C" { void EMSCRIPTEN_KEEPALIVE run_core() { // encoded snapshot (will be hardcoded in wasm binary file); std::string base64_snapshot = "WVJSSgAAABYAAAAAAAAAgAAAAAEAAAAYAAEACAAJAAEEAgAAAAAABwAAAGcAAABAAAAAWDIAMhwyAjIBMgUyBDIHMgY6CCAIwAIoAB0AAToARscDAAAAAAABAAMBAQAhAgIBAQAAACBFAQCPAAAAAAABAAICAQAhAgICAkUBAIlhbQADAAYAcHVkZXIAAGVjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; run_encoded_snapshot(base64_snapshot, 142); } } #endif
固然这里咱们只是基于 JerryScript 作了一个利用 Wasm 进行 JavaScript 代码“加密”的最简单 Demo,代码并无处理边界 Case,对于非 Top-Level 的代码也并无进行测试。若是须要进一步优化,咱们能够思考如何利用 “jerry-libm” 来处理 JavaScript 中诸如 Math.abs 等常见标准库;对于平台依赖的符号(好比 window.document 等平台依赖的函数或变量)怎样经过 Wasm 的导出段与导入段进行处理等等。