基於 Node.js Addon 和 v8 字節碼的 Electron 代碼保護解決方案

背景

咱們有一個項目使用了 Electron 開發桌面應用,使其可以在 Windows / Mac 兩端上跨平臺運行,所以核心邏輯都是經過 JavaScript 編寫的,黑客很是容易對咱們的應用進行解包、修改邏輯破解商業化限制、從新打包,去再分發破解版。html

雖然咱們已經對應用作了數字簽名,可是這還遠遠不夠。要想真正解決問題,除了把全部商業化邏輯作到服務端,咱們還須要對代碼進行加固,避免解包、篡改、二次打包、二次分發。前端

方案對比

主流方案

  • Uglify / Obfuscator
    • 介紹:經過對 JS 代碼進行醜化和混淆,儘量下降其可讀性。
    • 特徵:容易解包容易閱讀容易篡改容易二次打包
    • 優點:接入簡單。
    • 劣勢:代碼格式化工具和混淆反解工具都能對代碼進行必定程度的復原。醜化經過修改變量名,可能會引發代碼沒法運行。混淆經過調整代碼結構,對代碼性能有較大的影響,也可能引發代碼沒法執行。
  • Native 加解密
    • 介紹:將 Webpack 的構建產物 Bundle 經過 XOR 或者 AES 等方案進行加密,封裝進 Node Addon,而後在運行時經過 JS 進行解密。
    • 特徵:解包有成本容易閱讀容易篡改容易二次打包
    • 優點:有必定的保護做用,能夠阻攔小白。
    • 劣勢:對於熟悉 Node 和 Electron 的黑客來講,解包很是容易。可是若是應用支持 DevTools,則能夠直接經過 DevTools 看到源代碼而後再分發。若是應用不支持 DevTools,只要把 Node Addon 拷貝到一個支持 DevTools 的 Electron 下執行,仍是能看到源代碼。
  • ASAR 加密
    • 介紹:將 Electron ASAR 文件進行加密,並修改 Electron 源代碼,在讀取 ASAR 文件以前對其解密後再運行。
    • 特徵:難以解包容易閱讀容易篡改容易二次打包
    • 優點:有較強的保護做用,能夠阻攔很多黑客。
    • 劣勢:須要從新構建 Electron,初期成本高昂。可是黑客能夠經過強制開啓 Inspect 端口或者應用內 DevTools 讀取到源代碼、或者經過 Dump 內存等方式解析出源代碼,而且將源代碼從新打包分發。
  • v8 字節碼
    • 介紹:經過 Node 標準庫裏的 vm 模塊,能夠從 Script 對象中生成其緩存數據(參考)。該緩存數據能夠理解爲 v8 的字節碼,該方案經過分發字節碼的形式來達到源代碼保護的目的。
    • 特徵:容易解包難以閱讀難以篡改容易二次打包
    • 優點:生成的字節碼,不只幾乎不可讀,並且難以篡改。且不保存源代碼。
    • 劣勢:對構建流程具備較大侵入性,沒有便捷的解決方案。字節碼裏仍是能夠讀到字符串等數據,能夠進行篡改。

方案介紹

關於 v8 字節碼

官方的幾句話介紹:v8.dev/blog/code-c…node

擴展閱讀:git

咱們能夠理解,v8 字節碼是 v8 引擎在解析和編譯 JavaScript 後產物的序列化形式,它一般用於瀏覽器內的性能優化。因此若是咱們經過 v8 字節碼運行代碼,不只可以起到代碼保護做用,還對性能有必定的提高。github

咱們在此不對 v8 字節碼做爲過多的闡述,能夠經過閱讀上述兩篇文章去了解經過 v8 字節碼進行代碼保護的技術背景和實現方案。web

v8 字節碼的侷限性

在代碼保護上的侷限

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

對調試的影響以及支持 Sourcemap

因爲咱們將構造 vm.Script 所使用的代碼都替換成了 dummyCode 進行佔位,因此對 sourcemap 會有影響,而且 filename 也再也不起做用。因此對調試時定位代碼存在必定影響。瀏覽器

對代碼大小的影響

對於只有幾行的 JS 代碼來講,編譯爲字節碼會大大增長文件體積。若是項目中存在大量小體積的 JavaScript 文件,項目體積會有很是大幅度的增加。固然對於幾 M 的 JS Bundle 來講,其體積的增量基本能夠忽略不計。

更進一步 - 經過 Node Addon 進行(解)混淆和運行

基於上述的侷限性,咱們將 v8 字節碼嵌入到一個 Node.js 能夠運行的 Node Addon 之中。而且在這個 Node Addon 裏面對嵌入的 v8 字節碼進行解混淆、運行。如此一來,不只保護了 v8 字節碼上的各類常量信息,還將整套字節碼方案隱藏在了一個 Node Addon 以內。

使用 N-API

爲了不 rebuild,咱們須要使用 N-API 做爲 Node Addon 的方案,具體優點能夠查閱:Node-API | Node.js v15.14.0 Documentation

使用 Rust 與 Neon Bindings

使用 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.jsrenderer.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.jsrenderer.js 進行字節碼編譯。產物將會生成在 output 文件夾下。

編譯時會建立一個可見的 BrowserWindow,若是不但願它可見,在建立 BrowserWindow 的參數中設置爲 hide: true 便可。

封裝 Native Addon

咱們使用了 Rust 去開發 Node Addon。

後續存在很多直接在 Rust 中執行 JS 邏輯的操做,其中所涉及了一些引用 Node 模塊、構造對象等操做,能夠參考 Neon Bindings 文檔:Introduction | Neon

引用 Node 模塊

咱們知道在 Node 中引用模塊須要依賴 require 方法,可是 require 方法並不存在於 Global 對象中,而是存在於模塊代碼執行的做用域之中,咱們須要瞭解 Node CommonJS 的實現機制:

(function (exports, require, module, __filename, __dirname) {
  /* 模塊文件代碼 */
});
複製代碼

每一個文件都會被包裹在上面的匿名函數中,咱們能夠看到,modulerequireexports__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。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 參數去指定須要加載的字節碼。

常見疑問

  • 對構建流程有何影響?
    • 對構建流程的影響,主要是在 Bundle 構建以後、Electron Builder 打包以前,插入了一層字節碼編譯和 Node Addon 編譯。
  • 對構建性能的影響?
    • 啓動 Electron 進程和 BrowserWindow 用於字節碼的編譯,須要消耗 2s 左右。編譯字節碼時,對於 10M 左右的 Bundle,得益於 v8 超高的 JavaScript 解析效率,字節碼生成的時間在 150ms 左右。最後將字節碼封裝進 Node Addon,因爲 Rust 的構建比較慢,可能須要 5s~10s。
    • 總體來講,這套方案對構建時間會有 10s~20s 的延長。若是是在 CI/CD 上進行構建,因爲失去了 cargo 緩存,額外算上 cargo 下載依賴的額外耗時,時間可能會延長到 1 分鐘左右。
  • 對代碼組織和編寫的影響?
    • 目前發現字節碼方案對代碼的惟一影響,是 Function.prototype.toString() 方法沒法正常使用,緣由是源代碼並不跟隨字節碼分發,所以取不到函數的源代碼。
  • 對程序性能是否有影響?
    • 對於代碼的執行性能沒有影響。對於初始化耗時,有 30% 左右的提高(在咱們的應用中,Bundle 大小爲 10M 左右,初始化時間從 550ms 左右下降到了 370ms)。
  • 對程序體積的影響?
    • 對於只有幾百 KB 的 Bundle 來講,字節碼體積會有比較明顯的膨脹,可是對於 2M+ 的 Bundle 來講,字節碼體積沒有太大的區別。
  • 代碼保護強度如何?
    • 目前來講,尚未現成的工具可以對 v8 字節碼進行反編譯,所以該方案仍是仍是比較可靠且安全的。可是受限於字節碼自己的原理,開發反編譯工具的難度並不高,在未知的未來,字節碼加固的方案普及以後,v8 字節碼應該會像 Java/C# 那樣可以被工具反編譯,到時候咱們就應該繼續探索其餘代碼保護方法。
    • 所以,咱們額外地經過 Node Addon 層對字節碼進行了混淆,可以在字節碼保護的基礎上隱藏代碼運行邏輯,不只增大瞭解包難度,還增大了代碼篡改、二次分發的難度。

招聘硬廣

咱們是字節跳動的互娛音樂前端團隊,涉獵跨端、中後臺、桌面端等主流前端技術領域,是一個技術氛圍很是濃厚的前端團隊,歡迎各路大佬加入:job.toutiao.com/s/eB5sw3x

相關文章
相關標籤/搜索