【Web技術】399- 淺談前端代碼加密

  • 2019 年 11 月 5 日
  • 筆記

說到 Web 前端開發,我們首先能夠想到的是瀏覽器、HTML、CSS 以及 JavaScript 這些開發時所必備使用的軟件工具和編程語言。而在這個專業領域中,作為開發者我們眾所周知的是,所有來自前端的數據都是「不可信」的,由於構成前端業務邏輯和交互界面的所有相關代碼都是可以被用戶直接查看到的,所以我們無法保證我們所確信的某個從前端傳遞到後端的數據沒有被用戶曾經修改過。

那麼是否有辦法可以將前端領域中那些與業務有關的代碼(比如數據處理邏輯、驗證邏輯等,通常是 JavaScript 代碼)進行加密以防止用戶進行惡意修改呢?本文我們將討論這方面的內容。

提到「加密」,我們自然會想到眾多與「對稱加密」、「非對稱加密」以及「散列加密」相關的算法,比如 AWS 算法、RSA 算法與 MD5 算法等。在傳統的 B-S 架構下,前端通過公鑰進行加密處理的數據可以在後端服務器再通過相應私鑰進行解密來得到原始數據,但是對於前端的業務代碼而言,由於瀏覽器本身無法識別運行這些被加密過的源代碼,因此實際上傳統的加密算法並不能幫助我們解決「如何完全黑盒化前端業務邏輯代碼」這一問題。

既然無法完全隱藏前端業務邏輯代碼的實際執行細節,那我們就從另一條路以「降低代碼可讀性」的方式來「偽黑盒化前端業務邏輯代碼」。通常的方法有如下幾種:

第三方插件

我們所熟知的可用在 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 的導出段與導入段進行處理等等。