做者簡介:於航,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 的導出段與導入段進行處理等等。