字節(jié)是如何用 v8 字節(jié)碼來保護代碼的?
背景
我們有一個項目使用了 Electron 開發(fā)桌面應(yīng)用,使其能夠在 Windows / Mac 兩端上跨平臺運行,因此核心邏輯都是通過 JavaScript 編寫的,黑客非常容易對我們的應(yīng)用進行解包、修改邏輯破解商業(yè)化限制、重新打包,去再分發(fā)破解版。
雖然我們已經(jīng)對應(yīng)用做了數(shù)字簽名,但是這還遠遠不夠。要想真正解決問題,除了把所有商業(yè)化邏輯做到服務(wù)端,我們還需要對代碼進行加固,避免解包、篡改、二次打包、二次分發(fā)。
方案對比
主流方案
Uglify / Obfuscator 介紹:通過對 JS 代碼進行丑化和混淆,盡可能降低其可讀性。 特征: 容易解包、容易閱讀、容易篡改、容易二次打包優(yōu)勢:接入簡單。 劣勢:代碼格式化工具和混淆反解工具都能對代碼進行一定程度的復原。丑化通過修改變量名,可能會引起代碼無法運行?;煜ㄟ^調(diào)整代碼結(jié)構(gòu),對代碼性能有較大的影響,也可能引起代碼無法執(zhí)行。 Native 加解密 介紹:將 Webpack 的構(gòu)建產(chǎn)物 Bundle 通過 XOR 或者 AES 等方案進行加密,封裝進 Node Addon,然后在運行時通過 JS 進行解密。 特征: 解包有成本、容易閱讀、容易篡改、容易二次打包優(yōu)勢:有一定的保護作用,可以阻攔小白。 劣勢:對于熟悉 Node 和 Electron 的黑客來說,解包非常容易。但是如果應(yīng)用支持 DevTools,則可以直接通過 DevTools 看到源代碼然后再分發(fā)。如果應(yīng)用不支持 DevTools,只要把 Node Addon 拷貝到一個支持 DevTools 的 Electron 下執(zhí)行,還是能看到源代碼。 ASAR 加密 介紹:將 Electron ASAR 文件進行加密,并修改 Electron 源代碼,在讀取 ASAR 文件之前對其解密后再運行。 特征: 難以解包、容易閱讀、容易篡改、容易二次打包優(yōu)勢:有較強的保護作用,可以阻攔不少黑客。 劣勢:需要重新構(gòu)建 Electron,初期成本高昂。但是黑客可以通過強制開啟 Inspect 端口或者應(yīng)用內(nèi) DevTools 讀取到源代碼、或者通過 Dump 內(nèi)存等方式解析出源代碼,并且將源代碼重新打包分發(fā)。 v8 字節(jié)碼 介紹:通過 Node 標準庫里的 vm 模塊,可以從 Script 對象中生成其緩存數(shù)據(jù)(參考[1])。該緩存數(shù)據(jù)可以理解為 v8 的字節(jié)碼,該方案通過分發(fā)字節(jié)碼的形式來達到源代碼保護的目的。 特征: 容易解包、難以閱讀、難以篡改、容易二次打包優(yōu)勢:生成的字節(jié)碼,不僅幾乎不可讀,而且難以篡改。且不保存源代碼。 劣勢:對構(gòu)建流程具有較大侵入性,沒有便捷的解決方案。字節(jié)碼里還是可以讀到字符串等數(shù)據(jù),可以進行篡改。
方案介紹
關(guān)于 v8 字節(jié)碼
官方的幾句話介紹:https://v8.dev/blog/code-caching
擴展閱讀:
bytenode/bytenode[2] 理解 V8 的字節(jié)碼「譯」[3] 通過字節(jié)碼保護 Node.js 源碼之原理篇[4]
我們可以理解,v8 字節(jié)碼是 v8 引擎在解析和編譯 JavaScript 后產(chǎn)物的序列化形式,它通常用于瀏覽器內(nèi)的性能優(yōu)化。所以如果我們通過 v8 字節(jié)碼運行代碼,不僅能夠起到代碼保護作用,還對性能有一定的提升。
我們在此不對 v8 字節(jié)碼作為過多的闡述,可以通過閱讀上述兩篇文章去了解通過 v8 字節(jié)碼進行代碼保護的技術(shù)背景和實現(xiàn)方案。
v8 字節(jié)碼的局限性
在代碼保護上的局限
v8 字節(jié)碼不保護字符串,如果我們在 JS 代碼中寫死了一些數(shù)據(jù)庫的密鑰等信息,只要將 v8 字節(jié)碼作為字符串閱讀,還是能直接看到這些字符串內(nèi)容的。當然,簡單一點的方法就是使用 Binary 形式的非字符串密鑰。
另外,如果直接將上面技術(shù)方案中生成的二進制文件進行略微修改,還是可以非常容易地再分發(fā)。比如把 isVip 對應(yīng)的值寫死為 true,或者是把自動更新 URL 改成一個虛假的地址來禁用自動更新。為了避免這些情況,我們希望在這一層之上做更多的保護,讓破解成本更加高。
對構(gòu)建的影響
v8 字節(jié)碼格式的和 v8 版本和環(huán)境有關(guān),不同版本或者不同環(huán)境的 v8,其字節(jié)碼產(chǎn)物不一樣。Electron 存在兩種進程,Browser 進程和 Renderer 進程。兩種進程雖然 v8 版本一樣,但是由于注入的方法不同,運行環(huán)境不同,因此字節(jié)碼產(chǎn)物也有區(qū)別。在 Browser 進程中生成的 v8 字節(jié)碼不能在 Renderer 進程中運行,反之也不行。當然,在 Node.js 中生成的字節(jié)碼也是無法在 Electron 上運行的。因此,我們需要在 Browser 進程中構(gòu)建用于 Browser 進程的代碼,在 Renderer 進程中構(gòu)建用于 Renderer 進程的代碼。
對調(diào)試的影響以及支持 Sourcemap
由于我們將構(gòu)造 vm.Script 所使用的代碼都替換成了 dummyCode 進行占位,所以對 sourcemap 會有影響,并且 filename 也不再起作用。所以對調(diào)試時定位代碼存在一定影響。
對代碼大小的影響
對于只有幾行的 JS 代碼來說,編譯為字節(jié)碼會大大增加文件體積。如果項目中存在大量小體積的 JavaScript 文件,項目體積會有非常大幅度的增長。當然對于幾 M 的 JS Bundle 來說,其體積的增量基本可以忽略不計。
更進一步 - 通過 Node Addon 進行(解)混淆和運行
基于上述的局限性,我們將 v8 字節(jié)碼嵌入到一個 Node.js 可以運行的 Node Addon 之中。并且在這個 Node Addon 里面對嵌入的 v8 字節(jié)碼進行解混淆、運行。如此一來,不僅保護了 v8 字節(jié)碼上的各種常量信息,還將整套字節(jié)碼方案隱藏在了一個 Node Addon 之內(nèi)。
使用 N-API
為了避免 rebuild,我們需要使用 N-API 作為 Node Addon 的方案,具體優(yōu)勢可以查閱:Node-API | Node.js v15.14.0 Documentation[5]
使用 Rust 與 Neon Bindings
使用 Rust[6] 語言為單純的技術(shù)選型偏好,Rust 相較于 C++ 具有 相對的內(nèi)存安全、構(gòu)建工具鏈便于使用、跨平臺能力強大 等特點,所以我們選擇了 Rust 作為 Node Addon 的實現(xiàn)方案。
同時,Rust 具備了 include_bytes! 宏,能夠直接在編譯時,將二進制文件嵌入至構(gòu)建產(chǎn)生的動態(tài)鏈接庫中,相比 C++ 需要實現(xiàn) codegen 的方案更為簡單。
當然,Rust 并不能直接用于編寫 Node Addon,而是需要借助 Neon Bindings 進行開發(fā)。Neon Bindings 是一個對 Node API 進行 Rust 層封裝的庫,它把 Node API 隱藏于底層實現(xiàn)中,并向 Rust 開發(fā)者暴露簡單易用的 Rust API。(Rust Bindings 在之前并不支持 Node API,Node API 的支持進度參考 Quest: N-API Support · Issue #444 · neon-bindings/neon[7])
具體實現(xiàn)
實現(xiàn)上主要是對構(gòu)建工具流的改造,具體構(gòu)建流程可以參考該圖示:

編譯字節(jié)碼
在大多數(shù) Electron 應(yīng)用的場景下,無論是使用 Webpack 還是其他 Bundler 工具,都會產(chǎn)生兩個以上的 Bundle 文件,分別用于主進程和單/多個渲染進程,我們對構(gòu)建產(chǎn)物的名稱進行假定,具體需要結(jié)合實際使用場景。我們通過 Bundler 構(gòu)建出了兩個及以上的 Bundle 文件,假設(shè)名稱分別為:main.js、renderer.js。
完成 Bundle 構(gòu)建之后,需要對兩個 Bundle 編譯成字節(jié)碼。由于我們需要在 Electron 環(huán)境下運行這兩個 Bundle,因此我們需要在 Electron 環(huán)境下完成字節(jié)碼的生成。對于用于主進程的 Bundle,可以直接在主進程中生成字節(jié)碼,而對于用于渲染進程的 Bundle,我們需要新起一個瀏覽器窗口并在其中生成字節(jié)碼。我們分別創(chuàng)建兩個 js 文件:
electron-main.js
// 這個文件可以直接用 electron 命令運行。
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { BrowserWindow, app } = require('electron');
const { compile } = require('./bytecode');
async function main() {
// 輸入目錄,用于存放待編譯的 js bundle
const inputPath = path.resolve(__dirname, 'input');
// 輸出目錄,用于存放編譯產(chǎn)物,也就是字節(jié)碼,文件名對應(yīng)關(guān)系:main.js -> main.bin
const outputPath = path.resolve(__dirname, 'output');
// 清理并重新創(chuàng)建輸出目錄
rimraf.sync(outputPath);
fs.mkdirSync(outputPath);
// 讀取原始 js 并生成字節(jié)碼
const code = fs.readFileSync(path.resolve(inputPath, 'main.js'));
fs.writeFileSync(path.resolve(outputPath, 'main.bin'), compile(code));
// 啟動一個瀏覽器窗口用于渲染進程字節(jié)碼的編譯
await launchRenderer();
}
async function launchRenderer() {
await app.whenReady();
const win = new BrowserWindow({
webPreferences: {
// 我們通過 preload 在 renderer 執(zhí)行 js,這樣就不需要一個 html 文件了。
preload: path.resolve(__dirname, './electron-renderer.js'),
enableRemoteModule: true,
nodeIntegration: true,
}
});
win.loadURL('about:blank');
win.show();
}
main();
electron-renderer.js
// 這個文件是在 electorn-main.js 創(chuàng)建的瀏覽器窗口中運行的。
const fs = require('fs')
const path = require('path')
const { remote } = require('electron')
const { compile } = require('./bytecode');
async function main() {
const inputPath = path.resolve(__dirname, 'input')
const outputPath = path.resolve(__dirname, 'output')
const code = fs.readFileSync(path.resolve(inputPath, 'renderer.js'))
fs.writeFileSync(path.resolve(outputPath, `renderer.bin`), compile(code));
}
// 執(zhí)行完成后需要關(guān)閉瀏覽器窗口,以便通知主進程編譯已完成
main().then(() => remote.getCurrentWindow().close())
接著我們需要實現(xiàn) bytecode.js,也就是編譯字節(jié)碼的邏輯:
bytecode.js
const vm = require('vm');
const v8 = require('v8');
// 這兩個參數(shù)非常重要,保證字節(jié)碼能夠被運行。
v8.setFlagsFromString('--no-lazy');
v8.setFlagsFromString('--no-flush-bytecode');
function encode(buf) {
// 這里可以做一些混淆邏輯,比如異或。
return buf.map(b => b ^ 12345);
}
exports.compile = function compile(code) {
const script = new vm.Script(code);
const raw = script.createCachedData();
return encode(raw);
};
關(guān)于混淆:為了不影響應(yīng)用的啟動速度,不建議使用 AES 等過于復雜的加密算法。因為即便是使用了 AES,字節(jié)碼構(gòu)建產(chǎn)物還是可以通過各種方式(內(nèi)存 Dump、Hook 等)獲取。這里對字節(jié)碼進行混淆,是為了提到破解成本,以避免破解者直接從 Node Addon Binary 的二進制數(shù)據(jù)中提取各種常量。
有上述幾個文件之后,我們就可以直接通過 electron ./electron-main.js 命令,對 input 文件夾里面的 main.js 和 renderer.js 進行字節(jié)碼編譯。產(chǎn)物將會生成在 output 文件夾下。
編譯時會創(chuàng)建一個可見的 BrowserWindow,如果不希望它可見,在創(chuàng)建 BrowserWindow 的參數(shù)中設(shè)置為 hide: true 即可。
封裝 Native Addon
我們使用了 Rust 去開發(fā) Node Addon。
后續(xù)存在不少直接在 Rust 中執(zhí)行 JS 邏輯的操作,其中所涉及了一些引用 Node 模塊、構(gòu)造對象等操作,可以參考 Neon Bindings 文檔:Introduction | Neon[8]。
引用 Node 模塊
我們知道在 Node 中引用模塊需要依賴 require 方法,但是 require 方法并不存在于 Global 對象中,而是存在于模塊代碼執(zhí)行的作用域之中,我們需要了解 Node CommonJS 的實現(xiàn)機制:
(function (exports, require, module, __filename, __dirname) {
/* 模塊文件代碼 */
});
每個文件都會被包裹在上面的匿名函數(shù)中,我們可以看到,module、require、exports、__filename、__dirname 全部都是以局部變量暴露給模塊的,因此 Global 對象是不會持有這些內(nèi)容的。
因此我們無法直接在 Node Addon 中獲取 require 等方法,所以 JS 側(cè)在執(zhí)行 Node Addon 時,必須將 module 對象透傳至 Node Addon 中,Rust 側(cè)才能通過調(diào)用 Module 的 require 方法去引用其他模塊:
require("./loader.node").load({
type: "main",
module // 透傳當前模塊的 Module 對象
})
上面這段代碼會直接替換 main.js 中原來的內(nèi)容,而在 Rust 中,需要實現(xiàn)這么一個方法去方便 Require 操作的進行:
fn node_require(&mut self, id: &str) -> NeonResult<Handle<'a, JsObject>> {
let require_fn: Handle<JsFunction> = self.js_get(self.module, "require")?;
let require_args = vec![self.cx.string(id)];
let result = require_fn.call(&mut self.cx, self.module, require_args)?.downcast_or_throw(&mut self.cx)?;
Ok(result)
}
字節(jié)碼的嵌入和獲取
我們在字節(jié)碼編譯完成之后,通過 JS 生成了下面的 Rust 代碼,以讓 Rust 能夠?qū)⒕幾g出來的字節(jié)碼嵌入至動態(tài)鏈接庫中,并且能夠直接讀?。?/p>
pub fn get_module_main() -> &'static [u8] {
include_bytes!("[...]/output/main.bin")
}
pub fn get_module_renderer() -> &'static [u8] {
include_bytes!("[...]/output/renderer.bin")
}
而 Rust 內(nèi)讀取字節(jié)碼,只需要根據(jù) JS 對 Node Addon 中的函數(shù)調(diào)用時傳入的 type 字段,做一個 match pattern 判斷,再調(diào)用對應(yīng)的二進制數(shù)據(jù)獲取方法即可:
enum LoaderProcessType {
Main,
Renderer
}
let process_type = match process_type_str.value(&mut cx).as_str() {
"main" => LoaderProcessType::Main,
"renderer" => LoaderProcessType::Renderer,
_ => panic!("ERROR")
};
match process_type {
LoaderProcessType::Main => gen_main::get_module(),
LoaderProcessType::Renderer => gen_renderer::get_module()
};
Fix Code 生成和替換
在初始化時,我們首先需要生成 Fix Code。Fix Code 是 4 個字節(jié)的二進制數(shù)據(jù),實際上是 v8 Flags Hash,v8 在運行字節(jié)碼前會進行校驗,如果不一致會導致 cachedDataRejected。為了讓字節(jié)碼能夠在當前環(huán)境中正常運行,我們需要獲取當前環(huán)境的 v8 Flags Hash。
我們通過 Rust 調(diào)用 vm 模塊執(zhí)行一段無意義的代碼,取得 Fix Code:
fn init_fix_code(&mut self) -> NeonResult<()> {
let vm = self.node_require("vm")?;
let vm_script: Handle<JsFunction> = self.js_get(vm, "Script")?;
let code = self.cx.string("\"\"");
let script = vm_script.construct(&mut self.cx, vec![code])?;
let cache: Handle<JsBuffer> = self.js_invoke(script, "createCachedData", Vec::<Handle<JsValue>>::new())?;
let buf: Vec::<u8> = self.buf_to_vec(cache)?;
self.fix_code = Some(buf);
Ok(())
}
接著將待運行的字節(jié)碼的 12~16 字節(jié)替換成剛剛獲取的 4 字節(jié) Fix Code:
data[12..16].clone_from_slice(&fix_code[12..16]);
假源碼生成
接著需要在 Rust 中解析字節(jié)碼的 8~12 位,得到 Source Hash 并算出代碼長度。接著生成一個等長的任意字符串,作為假源碼,以欺騙過 v8 的源代碼長度校驗。
let mut len = 0usize;
for (i, b) in (&data[8..12]).iter().enumerate() {
len += *b as usize * 256usize.pow(i as u32)
};
self.eval(&format!(r#"'"' + "\u200b".repeat({}) + '"'"#, len - 2))?;
此處之所以直接調(diào)用 Eval 去生成二進制數(shù)據(jù),是因為 Rust 的字符串轉(zhuǎn)換為 JsString 存在不小的開銷,所以還是直接在 JS 中生成會比較高效。Eval 的實現(xiàn)本質(zhì)上還是調(diào)用 vm 模塊的 runInThisContext 方法。
解混淆
在運行字節(jié)碼之前,我們需要通過異或運算去解混淆:
buf.into_iter().enumerate().map(|(_, b)| b ^ 12345).collect()
運行字節(jié)碼
接著,就要運行字節(jié)碼了。
首先,為了能夠正常運行之前生成的字節(jié)碼,還需要對 v8 的一些參數(shù)進行設(shè)置,對齊編譯環(huán)境的配置:
fn configure_v8(&mut self) -> NeonResult<()> {
let v8 = self.node_require( "v8")?;
let set_flag: Handle<JsFunction> = self.js_get(v8, "setFlagsFromString")?;
let args1 = vec![self.cx.string("--no-lazy")];
set_flag.call(&mut self.cx, v8, args1)?;
let args2 = vec![self.cx.string("--no-flush-bytecode")];
set_flag.call(&mut self.cx, v8, args2)?;
Ok(())
}
接著我們還是需要在 Rust 中調(diào)用 vm 模塊去運行字節(jié)碼,即使用 Rust 執(zhí)行下面的一段 JS 邏輯(原 Rust 代碼過長就不貼了):
const vm = require('vm');
const script = vm.Script(dummyCode, {
cachedData, // 這個就是字節(jié)碼
filename,
lineOffset: 0,
displayErrors: true
});
script.runInThisContext({
filename,
lineOffset: 0,
columnOffset: 0,
displayErrors: true
});
運行原理
最后,我們的構(gòu)建產(chǎn)物的目錄結(jié)構(gòu)如下:
dist
├─ loader.node - Node Addon,里面包含了混淆過的所有字節(jié)碼數(shù)據(jù),基本不可讀。
├─ main.js - 主進程代碼入口,只有一行加載代碼
├─ renderer.js - 渲染進程代碼入口,只有一行加載代碼
└─ index.html - HTML 文件,用于加載 renderer.js
運行應(yīng)用時,以 main.js 為入口,完整的運行流程如下:

其中,loader.node 里存儲了所有的字節(jié)碼數(shù)據(jù),并且包含了加載字節(jié)碼的邏輯。main.js 和 renderer.js 都會直接去引用 loader.node,并且傳入 type 參數(shù)去指定需要加載的字節(jié)碼。
常見疑問
對構(gòu)建流程有何影響?
對構(gòu)建流程的影響,主要是在 Bundle 構(gòu)建之后、Electron Builder 打包之前,插入了一層字節(jié)碼編譯和 Node Addon 編譯。 對構(gòu)建性能的影響?
啟動 Electron 進程和 BrowserWindow 用于字節(jié)碼的編譯,需要消耗 2s 左右。編譯字節(jié)碼時,對于 10M 左右的 Bundle,得益于 v8 超高的 JavaScript 解析效率,字節(jié)碼生成的時間在 150ms 左右。最后將字節(jié)碼封裝進 Node Addon,由于 Rust 的構(gòu)建比較慢,可能需要 5s~10s。 整體來說,這套方案對構(gòu)建時間會有 10s~20s 的延長。如果是在 CI/CD 上進行構(gòu)建,由于失去了 cargo 緩存,額外算上 cargo 下載依賴的額外耗時,時間可能會延長到 1 分鐘左右。 對代碼組織和編寫的影響?
目前發(fā)現(xiàn)字節(jié)碼方案對代碼的唯一影響,是 Function.prototype.toString()方法無法正常使用,原因是源代碼并不跟隨字節(jié)碼分發(fā),因此取不到函數(shù)的源代碼。對程序性能是否有影響?
對于代碼的執(zhí)行性能沒有影響。對于初始化耗時,有 30% 左右的提升(在我們的應(yīng)用中,Bundle 大小為 10M 左右,初始化時間從 550ms 左右降低到了 370ms)。 對程序體積的影響?
對于只有幾百 KB 的 Bundle 來說,字節(jié)碼體積會有比較明顯的膨脹,但是對于 2M+ 的 Bundle 來說,字節(jié)碼體積沒有太大的區(qū)別。 代碼保護強度如何?
目前來說,還沒有現(xiàn)成的工具能夠?qū)?v8 字節(jié)碼進行反編譯,因此該方案還是還是比較可靠且安全的。但是受限于字節(jié)碼本身的原理,開發(fā)反編譯工具的難度并不高,在未知的將來,字節(jié)碼加固的方案普及之后,v8 字節(jié)碼應(yīng)該會像 Java/C# 那樣能夠被工具反編譯,到時候我們就應(yīng)該繼續(xù)探索其他代碼保護方法。 因此,我們額外地通過 Node Addon 層對字節(jié)碼進行了混淆,能夠在字節(jié)碼保護的基礎(chǔ)上隱藏代碼運行邏輯,不僅增大了解包難度,還增大了代碼篡改、二次分發(fā)的難度。
招聘硬廣
我們是字節(jié)跳動的互娛音樂前端團隊,涉獵跨端、中后臺、桌面端等主流前端技術(shù)領(lǐng)域,是一個技術(shù)氛圍非常濃厚的前端團隊,歡迎各路大佬加入:https://jobs.toutiao.com/s/e7CHDqR
參考資料
參考: https://nodejs.org/api/vm.html#vm_script_createcacheddata
[2]bytenode/bytenode: https://github.com/bytenode/bytenode
[3]理解 V8 的字節(jié)碼「譯」: https://zhuanlan.zhihu.com/p/28590489
[4]通過字節(jié)碼保護 Node.js 源碼之原理篇: https://zhuanlan.zhihu.com/p/359235114
[5]Node-API | Node.js v15.14.0 Documentation: https://nodejs.org/dist/latest-v15.x/docs/api/n-api.html
[6]Rust: https://www.rust-lang.org/
[7]Quest: N-API Support · Issue #444 · neon-bindings/neon: https://github.com/neon-bindings/neon/issues/444
[8]Introduction | Neon: https://neon-bindings.com/docs/intro
