如何防止代碼被抄襲,淺談前端代碼加密
說到 Web 前端開發(fā),我們首先能夠想到的是瀏覽器、HTML、CSS 以及 JavaScript 這些開發(fā)時所必備使用的軟件工具和編程語言。而在這個專業(yè)領(lǐng)域中,作為開發(fā)者我們眾所周知的是,所有來自前端的數(shù)據(jù)都是“不可信”的,由于構(gòu)成前端業(yè)務(wù)邏輯和交互界面的所有相關(guān)代碼都是可以被用戶直接查看到的,所以我們無法保證我們所確信的某個從前端傳遞到后端的數(shù)據(jù)沒有被用戶曾經(jīng)修改過。
那么是否有辦法可以將前端領(lǐng)域中那些與業(yè)務(wù)有關(guān)的代碼(比如數(shù)據(jù)處理邏輯、驗證邏輯等,通常是 JavaScript 代碼)進(jìn)行加密以防止用戶進(jìn)行惡意修改呢?本文我們將討論這方面的內(nèi)容。
提到“加密”,我們自然會想到眾多與“對稱加密”、“非對稱加密”以及“散列加密”相關(guān)的算法,比如 AWS 算法、RSA 算法與 MD5 算法等。在傳統(tǒng)的 B-S 架構(gòu)下,前端通過公鑰進(jìn)行加密處理的數(shù)據(jù)可以在后端服務(wù)器再通過相應(yīng)私鑰進(jìn)行解密來得到原始數(shù)據(jù),但是對于前端的業(yè)務(wù)代碼而言,由于瀏覽器本身無法識別運行這些被加密過的源代碼,因此實際上傳統(tǒng)的加密算法并不能幫助我們解決“如何完全黑盒化前端業(yè)務(wù)邏輯代碼”這一問題。
既然無法完全隱藏前端業(yè)務(wù)邏輯代碼的實際執(zhí)行細(xì)節(jié),那我們就從另一條路以“降低代碼可讀性”的方式來“偽黑盒化前端業(yè)務(wù)邏輯代碼”。通常的方法有如下幾種:
第三方插件
我們所熟知的可用在 Web 前端開發(fā)中的第三方插件主要有:Adobe Flash、Java Applet 以及 Silverlight 等。由于歷史原因這里我們不會深入介紹基于這些第三方插件的前端業(yè)務(wù)代碼加密方案。其中 Adobe 將于 2020 年完全停止對 Flash 技術(shù)的支持,Chrome、Edge 等瀏覽器也開始逐漸對使用了 Flash 程序的 Web 頁面進(jìn)行阻止或彈出相應(yīng)的警告。同樣的,來自微軟的 Silverlight5 也會在 2021 年停止維護(hù),并完全終止后續(xù)新版本功能的開發(fā)。而 Java Applet 雖然還可以繼續(xù)使用,但相較于早期上世紀(jì) 90 年代末,現(xiàn)在已然很少有人使用(不完全統(tǒng)計)。并且需要基于 JRE 來運行也使得 Applet 應(yīng)用的運行成本大大提高。
代碼混淆
在現(xiàn)代前端開發(fā)過程中,我們最常用的一種可以“降低源代碼可讀性”的方法就是使用“代碼混淆”。通常意義上的代碼混淆可以壓縮原始 ASCII 代碼的體積并將其中的諸如變量、常量名用簡短的毫無意義的標(biāo)識符進(jìn)行代替,這一步可以簡單地理解為“去語義化”。以我們最常用的 “Uglify” 和 “GCC (Google Closure Compiler)” 為例,首先是一段未經(jīng)代碼混淆的原始 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));
經(jīng)過 UglifyJS3 的代碼壓縮混淆處理后的結(jié)果:
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));
經(jīng)過 Google Closure Compiler 的代碼壓縮混淆處理后的結(jié)果:
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));
對比上述兩種工具的代碼混淆壓縮結(jié)果我們可以看到,UglifyJS 不會對原始代碼進(jìn)行“重寫”,所有的壓縮工作都是在代碼原有結(jié)構(gòu)的基礎(chǔ)上進(jìn)行的優(yōu)化。而 GCC 對代碼的優(yōu)化則更靠近“編譯器”,除了常見的變量、常量名去語義化外,還使用了常見的 DCE 優(yōu)化策略,比如對常量表達(dá)式(constexpr)進(jìn)行提前求值(0.1 * 8 + 1)、通過 “inline” 減少中間變量的使用等等。
UglifyJS 在處理優(yōu)化 JavaScript 源代碼時都是以其 AST 的形式進(jìn)行分析的。比如在 Node.js 腳本中進(jìn)行源碼處理時,我們通常會首先使用 UglifyJS.parse 方法將一段 JavaScript 代碼轉(zhuǎn)換成其對應(yīng)的 AST 形式,然后再通過 UglifyJS.Compressor 方法對這些 AST 進(jìn)行處理。最后還需要通過print_to_string 方法將處理后的 AST 結(jié)構(gòu)轉(zhuǎn)換成相應(yīng)的 ASCII 可讀代碼形式。UglifyJS.Compressor 的本質(zhì)是一個官方封裝好的 “TreeTransformer” 類型,其內(nèi)部已經(jīng)封裝好了眾多常用的代碼優(yōu)化策略,而通過對 UglifyJS.TreeTransformer 進(jìn)行適當(dāng)?shù)姆庋b,我們也可以編寫自己的代碼優(yōu)化器。
如下所示我們編寫了一個實現(xiàn)簡單“常量傳播”與“常量折疊”(注意這里其實是變量,但優(yōu)化形式同 C++ 中的這兩種基本優(yōu)化策略相同)優(yōu)化的 UglifyJS 轉(zhuǎn)化器。
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 節(jié)點類型(UglifyJS.AST_Binary / UglifyJS.AST_VarDef)來達(dá)到對代碼進(jìn)行精準(zhǔn)處理的目的。可以看到,變量 x 和 y 的值在代碼處理過程中被提前計算。不僅如此,其作為變量的值還被傳遞到了表達(dá)式 a + b 中,此時如果能夠再結(jié)合簡單的 DCE 策略便可以完成最初級的代碼優(yōu)化效果。類似的,其實通過 Babel 的 @babel/traverse 插件,我們也可以實現(xiàn)同樣的效果,其所基于的原理也都大同小異,即對代碼的 AST 進(jìn)行相應(yīng)的轉(zhuǎn)換和處理。
WebAssembly
關(guān)于 Wasm 的基本介紹,這里我們不再多談。那么到底應(yīng)該如何利用 Wasm 的“字節(jié)碼”特性來做到盡可能地做到“降低 JavaScript 代碼可讀性”這一目的呢?一個簡單的 JavaScript 代碼“加密”服務(wù)系統(tǒng)架構(gòu)圖如下所示:

這里整個系統(tǒng)分為兩個處理階段:
第一階段:先將明文的 JavaScript 代碼轉(zhuǎn)換為基于特定 JavaScript 引擎(VM)的 OpCode 代碼,這些二進(jìn)制的 OpCode 代碼會再通過諸如 Base64 等算法的處理而轉(zhuǎn)換為經(jīng)過編碼的明文 ASCII 字符串格式;
第二階段:將上述經(jīng)過編碼的 ASCII 字符串連同對應(yīng)的 JavaScript 引擎內(nèi)核代碼統(tǒng)一編譯成完整的 ASM / Wasm 模塊。當(dāng)模塊在網(wǎng)頁中加載時,內(nèi)嵌的 JavaScript 引擎便會直接解釋執(zhí)行硬編碼在模塊中的、經(jīng)過編碼處理的 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 執(zhí)行流程,上述代碼在執(zhí)行后會返回計算結(jié)果 82。這里我們以 JerryScript 這個開源的輕量級 JavaScript 引擎來作為例子,第一步首先將上述 ASCII 形式的代碼 Feed 到該引擎中,然后便可以獲得對應(yīng)該引擎中間狀態(tài)的 ByteCode 字節(jié)碼。

然后再將這些二進(jìn)制的字節(jié)碼通過 Base64 算法編碼成對應(yīng)的可見字符形式。結(jié)果如下所示:
WVJSSgAAABYAAAAAAAAAgAAAAAEAAAAYAAEACAAJAAEEAgAAAAAABwAAAGcAAABAAAAAWDIAMhwyAjIBMgUyBDIHMgY6CCAIwAIoAB0AAToARscDAAAAAAABAAMBAQAhAgIBAQAAACBFAQCPAAAAAAABAAICAQAhAgICAkUBAIlhbQADAAYAcHVkZXIAAGVjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
按照我們的架構(gòu)思路,這部分被編碼后的可見字符串會作為“加密”后的源代碼被硬編碼到包含有 VM 引擎核心的 Wasm 模塊中。當(dāng)模塊被加載時,VM 會通過相反的順序解碼這段字符串,并得到二進(jìn)制狀態(tài)的 ByteCode。然后再通過一起打包進(jìn)來的 VM 核心來執(zhí)行這些中間狀態(tài)的比特碼。這里我們上述所提到的 ByteCode 實際上是以 JerryScript 內(nèi)部的 SnapShot 快照結(jié)構(gòu)存在于內(nèi)存中的。
最后這里給出上述 Demo 的主要部分源碼,詳細(xì)代碼可以參考 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
當(dāng)然這里我們只是基于 JerryScript 做了一個利用 Wasm 進(jìn)行 JavaScript 代碼“加密”的最簡單 Demo,代碼并沒有處理邊界 Case,對于非 Top-Level 的代碼也并沒有進(jìn)行測試。如果需要進(jìn)一步優(yōu)化,我們可以思考如何利用 “jerry-libm” 來處理 JavaScript 中諸如 Math.abs 等常見標(biāo)準(zhǔn)庫;對于平臺依賴的符號(比如 window.document 等平臺依賴的函數(shù)或變量)怎樣通過 Wasm 的導(dǎo)出段與導(dǎo)入段進(jìn)行處理等等。
本文作者:于航
本文鏈接:https://www.yhspy.com/2019/04/10/淺談前端代碼加密/
