咱們有一個項目使用了 Electron 開發桌面應用,使其可以在 Windows / Mac 兩端上跨平臺運行,所以核心邏輯都是經過 JavaScript 編寫的,黑客很是容易對咱們的應用進行解包、修改邏輯破解商業化限制、從新打包,去再分發破解版。html
雖然咱們已經對應用作了數字簽名,可是這還遠遠不夠。要想真正解決問題,除了把全部商業化邏輯作到服務端,咱們還須要對代碼進行加固,避免解包、篡改、二次打包、二次分發。前端
容易解包
、容易閱讀
、容易篡改
、容易二次打包
解包有成本
、容易閱讀
、容易篡改
、容易二次打包
難以解包
、容易閱讀
、容易篡改
、容易二次打包
容易解包
、難以閱讀
、難以篡改
、容易二次打包
官方的幾句話介紹:v8.dev/blog/code-c…node
擴展閱讀:git
咱們能夠理解,v8 字節碼是 v8 引擎在解析和編譯 JavaScript 後產物的序列化形式,它一般用於瀏覽器內的性能優化。因此若是咱們經過 v8 字節碼運行代碼,不只可以起到代碼保護做用,還對性能有必定的提高。github
咱們在此不對 v8 字節碼做爲過多的闡述,能夠經過閱讀上述兩篇文章去了解經過 v8 字節碼進行代碼保護的技術背景和實現方案。web
v8 字節碼不保護字符串,若是咱們在 JS 代碼中寫死了一些數據庫的密鑰等信息,只要將 v8 字節碼做爲字符串閱讀,仍是能直接看到這些字符串內容的。固然,簡單一點的方法就是使用 Binary 形式的非字符串密鑰。算法
另外,若是直接將上面技術方案中生成的二進制文件進行略微修改,仍是能夠很是容易地再分發。好比把 isVip 對應的值寫死爲 true,或者是把自動更新 URL 改爲一個虛假的地址來禁用自動更新。爲了不這些狀況,咱們但願在這一層之上作更多的保護,讓破解成本更加高。數據庫
v8 字節碼格式的和 v8 版本和環境有關,不一樣版本或者不一樣環境的 v8,其字節碼產物不同。Electron 存在兩種進程,Browser 進程和 Renderer 進程。兩種進程雖然 v8 版本同樣,可是因爲注入的方法不一樣,運行環境不一樣,所以字節碼產物也有區別。在 Browser 進程中生成的 v8 字節碼不能在 Renderer 進程中運行,反之也不行。固然,在 Node.js 中生成的字節碼也是沒法在 Electron 上運行的。所以,咱們須要在 Browser 進程中構建用於 Browser 進程的代碼,在 Renderer 進程中構建用於 Renderer 進程的代碼。api
因爲咱們將構造 vm.Script 所使用的代碼都替換成了 dummyCode 進行佔位,因此對 sourcemap 會有影響,而且 filename 也再也不起做用。因此對調試時定位代碼存在必定影響。瀏覽器
對於只有幾行的 JS 代碼來講,編譯爲字節碼會大大增長文件體積。若是項目中存在大量小體積的 JavaScript 文件,項目體積會有很是大幅度的增加。固然對於幾 M 的 JS Bundle 來講,其體積的增量基本能夠忽略不計。
基於上述的侷限性,咱們將 v8 字節碼嵌入到一個 Node.js 能夠運行的 Node Addon 之中。而且在這個 Node Addon 裏面對嵌入的 v8 字節碼進行解混淆、運行。如此一來,不只保護了 v8 字節碼上的各類常量信息,還將整套字節碼方案隱藏在了一個 Node Addon 以內。
爲了不 rebuild,咱們須要使用 N-API 做爲 Node Addon 的方案,具體優點能夠查閱:Node-API | Node.js v15.14.0 Documentation
使用 Rust 語言爲單純的技術選型偏好,Rust 相較於 C++ 具備 相對的內存安全、構建工具鏈便於使用、跨平臺能力強大 等特色,因此咱們選擇了 Rust 做爲 Node Addon 的實現方案。
同時,Rust 具有了 include_bytes!
宏,可以直接在編譯時,將二進制文件嵌入至構建產生的動態連接庫中,相比 C++ 須要實現 codegen 的方案更爲簡單。
固然,Rust 並不能直接用於編寫 Node Addon,而是須要藉助 Neon Bindings 進行開發。Neon Bindings 是一個對 Node API 進行 Rust 層封裝的庫,它把 Node API 隱藏於底層實現中,並向 Rust 開發者暴露簡單易用的 Rust API。(Rust Bindings 在以前並不支持 Node API,Node API 的支持進度參考 Quest: N-API Support · Issue #444 · neon-bindings/neon)
實現上主要是對構建工具流的改造,具體構建流程能夠參考該圖示:
在大多數 Electron 應用的場景下,不管是使用 Webpack 仍是其餘 Bundler 工具,都會產生兩個以上的 Bundle 文件,分別用於主進程和單/多個渲染進程,咱們對構建產物的名稱進行假定,具體須要結合實際使用場景。咱們經過 Bundler 構建出了兩個及以上的 Bundle 文件,假設名稱分別爲:main.js
、renderer.js
。
完成 Bundle 構建以後,須要對兩個 Bundle 編譯成字節碼。因爲咱們須要在 Electron 環境下運行這兩個 Bundle,所以咱們須要在 Electron 環境下完成字節碼的生成。對於用於主進程的 Bundle,能夠直接在主進程中生成字節碼,而對於用於渲染進程的 Bundle,咱們須要新起一個瀏覽器窗口並在其中生成字節碼。咱們分別建立兩個 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');
// 輸出目錄,用於存放編譯產物,也就是字節碼,文件名對應關係:main.js -> main.bin
const outputPath = path.resolve(__dirname, 'output');
// 清理並從新建立輸出目錄
rimraf.sync(outputPath);
fs.mkdirSync(outputPath);
// 讀取原始 js 並生成字節碼
const code = fs.readFileSync(path.resolve(inputPath, 'main.js'));
fs.writeFileSync(path.resolve(outputPath, 'main.bin'), compile(code));
// 啓動一個瀏覽器窗口用於渲染進程字節碼的編譯
await launchRenderer();
}
async function launchRenderer() {
await app.whenReady();
const win = new BrowserWindow({
webPreferences: {
// 咱們經過 preload 在 renderer 執行 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 建立的瀏覽器窗口中運行的。
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));
}
// 執行完成後須要關閉瀏覽器窗口,以便通知主進程編譯已完成
main().then(() => remote.getCurrentWindow().close())
複製代碼
接着咱們須要實現 bytecode.js,也就是編譯字節碼的邏輯:
bytecode.js
const vm = require('vm');
const v8 = require('v8');
// 這兩個參數很是重要,保證字節碼可以被運行。
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);
};
複製代碼
關於混淆:爲了避免影響應用的啓動速度,不建議使用 AES 等過於複雜的加密算法。由於即使是使用了 AES,字節碼構建產物仍是能夠經過各類方式(內存 Dump、Hook 等)獲取。這裏對字節碼進行混淆,是爲了提到破解成本,以免破解者直接從 Node Addon Binary 的二進制數據中提取各類常量。
有上述幾個文件以後,咱們就能夠直接經過 electron ./electron-main.js
命令,對 input
文件夾裏面的 main.js
和 renderer.js
進行字節碼編譯。產物將會生成在 output
文件夾下。
編譯時會建立一個可見的 BrowserWindow,若是不但願它可見,在建立 BrowserWindow 的參數中設置爲 hide: true
便可。
咱們使用了 Rust 去開發 Node Addon。
後續存在很多直接在 Rust 中執行 JS 邏輯的操做,其中所涉及了一些引用 Node 模塊、構造對象等操做,能夠參考 Neon Bindings 文檔:Introduction | Neon。
咱們知道在 Node 中引用模塊須要依賴 require 方法,可是 require 方法並不存在於 Global 對象中,而是存在於模塊代碼執行的做用域之中,咱們須要瞭解 Node CommonJS 的實現機制:
(function (exports, require, module, __filename, __dirname) {
/* 模塊文件代碼 */
});
複製代碼
每一個文件都會被包裹在上面的匿名函數中,咱們能夠看到,module
、require
、exports
、__filename
、__dirname
所有都是以局部變量暴露給模塊的,所以 Global 對象是不會持有這些內容的。
所以咱們沒法直接在 Node Addon 中獲取 require 等方法,因此 JS 側在執行 Node Addon 時,必須將 module 對象透傳至 Node Addon 中,Rust 側才能經過調用 Module 的 require 方法去引用其餘模塊:
require("./loader.node").load({
type: "main",
module // 透傳當前模塊的 Module 對象
})
複製代碼
上面這段代碼會直接替換 main.js 中原來的內容,而在 Rust 中,須要實現這麼一個方法去方便 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) } 複製代碼
咱們在字節碼編譯完成以後,經過 JS 生成了下面的 Rust 代碼,以讓 Rust 可以將編譯出來的字節碼嵌入至動態連接庫中,而且可以直接讀取:
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 內讀取字節碼,只須要根據 JS 對 Node Addon 中的函數調用時傳入的 type 字段,作一個 match pattern 判斷,再調用對應的二進制數據獲取方法便可:
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 是 4 個字節的二進制數據,其實是 v8 Flags Hash,v8 在運行字節碼前會進行校驗,若是不一致會致使 cachedDataRejected。爲了讓字節碼可以在當前環境中正常運行,咱們須要獲取當前環境的 v8 Flags Hash。
咱們經過 Rust 調用 vm 模塊執行一段無心義的代碼,取得 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(())
}
複製代碼
接着將待運行的字節碼的 12~16 字節替換成剛剛獲取的 4 字節 Fix Code:
data[12..16].clone_from_slice(&fix_code[12..16]);
複製代碼
接着須要在 Rust 中解析字節碼的 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))?;
複製代碼
此處之因此直接調用 Eval 去生成二進制數據,是由於 Rust 的字符串轉換爲 JsString 存在不小的開銷,因此仍是直接在 JS 中生成會比較高效。Eval 的實現本質上仍是調用 vm 模塊的 runInThisContext 方法。
在運行字節碼以前,咱們須要經過異或運算去解混淆:
buf.into_iter().enumerate().map(|(_, b)| b ^ 12345).collect()
複製代碼
接着,就要運行字節碼了。
首先,爲了可以正常運行以前生成的字節碼,還須要對 v8 的一些參數進行設置,對齊編譯環境的配置:
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 中調用 vm 模塊去運行字節碼,即便用 Rust 執行下面的一段 JS 邏輯(原 Rust 代碼過長就不貼了):
const vm = require('vm');
const script = vm.Script(dummyCode, {
cachedData, // 這個就是字節碼
filename,
lineOffset: 0,
displayErrors: true
});
script.runInThisContext({
filename,
lineOffset: 0,
columnOffset: 0,
displayErrors: true
});
複製代碼
最後,咱們的構建產物的目錄結構以下:
dist
├─ loader.node - Node Addon,裏面包含了混淆過的全部字節碼數據,基本不可讀。
├─ main.js - 主進程代碼入口,只有一行加載代碼
├─ renderer.js - 渲染進程代碼入口,只有一行加載代碼
└─ index.html - HTML 文件,用於加載 renderer.js
複製代碼
運行應用時,以 main.js
爲入口,完整的運行流程以下:
其中,loader.node 裏存儲了全部的字節碼數據,而且包含了加載字節碼的邏輯。main.js 和 renderer.js 都會直接去引用 loader.node,而且傳入 type 參數去指定須要加載的字節碼。
Function.prototype.toString()
方法沒法正常使用,緣由是源代碼並不跟隨字節碼分發,所以取不到函數的源代碼。咱們是字節跳動的互娛音樂前端團隊,涉獵跨端、中後臺、桌面端等主流前端技術領域,是一個技術氛圍很是濃厚的前端團隊,歡迎各路大佬加入:job.toutiao.com/s/eB5sw3x