我们有一个项目使用了 Electron 开发桌面应用,使其能够在 Windows / Mac 两端上跨平台运行,因此核心逻辑都是通过 JavaScript 编写的,黑客非常容易对我们的应用进行解包、修改逻辑破解商业化限制、重新打包,去再分发破解版。
虽然我们已经对应用做了数字签名,但是这还远远不够。要想真正解决问题,除了把所有商业化逻辑做到服务端,我们还需要对代码进行加固,避免解包、篡改、二次打包、二次分发。
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 对象中生成其缓存数据(参考[1])。该缓存数据可以理解为 v8 的字节码,该方案通过分发字节码的形式来达到源代码保护的目的。
特征:容易解包
、难以阅读
、难以篡改
、容易二次打包
优势:生成的字节码,不仅几乎不可读,而且难以篡改。且不保存源代码。
劣势:对构建流程具有较大侵入性,没有便捷的解决方案。字节码里还是可以读到字符串等数据,可以进行篡改。
官方的几句话介绍:https://v8.dev/blog/code-caching
扩展阅读:
我们可以理解,v8 字节码是 v8 引擎在解析和编译 JavaScript 后产物的序列化形式,它通常用于浏览器内的性能优化。所以如果我们通过 v8 字节码运行代码,不仅能够起到代码保护作用,还对性能有一定的提升。
我们在此不对 v8 字节码作为过多的阐述,可以通过阅读上述两篇文章去了解通过 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 进程的代码。
由于我们将构造 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[5]
使用 Rust[6] 语言为单纯的技术选型偏好,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[7])
实现上主要是对构建工具流的改造,具体构建流程可以参考该图示:
在大多数 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[8]。
我们知道在 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 参数去指定需要加载的字节码。
对构建流程的影响,主要是在 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 层对字节码进行了混淆,能够在字节码保护的基础上隐藏代码运行逻辑,不仅增大了解包难度,还增大了代码篡改、二次分发的难度。
[1]参考: https://nodejs.org/api/vm.html#vm_script_createcacheddata
[2]bytenode/bytenode: https://github.com/bytenode/bytenode
[3]理解 V8 的字节码「译」: https://zhuanlan.zhihu.com/p/28590489
[4]通过字节码保护 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
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/nwqnMX1y91AstL1w6R0K7A
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。