商业软件的授权绕过、激活伪造和联网校验欺骗不适合作为可复现教程。更有价值的做法,是把同类现象抽象成 Electron 应用安全问题:一个桌面应用的启动入口在哪里,哪些配置会影响调试能力,为什么本地完整性校验容易被绕过,离线授权应该怎样设计,网络续期结果为什么不能只信任一个布尔值。
这里以一个自有或已获授权的 Electron 应用为对象,重新梳理一套安全分析和加固思路。
Electron 应用的启动链路
Electron 应用通常由三部分组成:
| 组成 | 作用 | 常见风险 |
|---|---|---|
| Electron 可执行文件 | 启动 Chromium 与 Node.js 运行时 | Fuses 配置不当可能暴露调试入口 |
| 主进程代码 | 创建窗口、处理文件、网络、授权等高权限逻辑 | 被本地修改或被提前 Hook 后会影响全局逻辑 |
| 渲染进程代码 | 展示界面,处理用户交互 | 如果权限过大,前端漏洞会扩大为本地代码执行风险 |
典型启动流程如下:
flowchart TD
A[用户启动应用] --> B[Electron 可执行文件]
B --> C[读取 Electron Fuses 配置]
C --> D[加载 resources/package.json]
D --> E[定位 main 入口文件]
E --> F[执行主进程脚本]
F --> G[创建 BrowserWindow]
G --> H[加载渲染进程页面]
H --> I[通过 IPC 调用主进程能力]
F --> J[调用 Node.js / Electron API]
很多 Electron 应用会把代码打进 resources/app.asar。ASAR 是 Electron 使用的一种归档格式,它方便分发和加载资源,但不是加密,也不是强安全边界。
一个简化的 package.json 入口可能长这样:
{
"name": "acme-note",
"version": "1.0.0",
"main": "launch.dist.js"
}
如果 main 指向 launch.dist.js,Electron 会从应用资源目录里寻找这个入口。入口脚本可能直接包含业务逻辑,也可能只做初始化,然后加载压缩后的 JavaScript、字节码文件或其他模块。
需要注意一点:只要核心逻辑仍运行在 Node.js / Electron 环境里,它就会依赖 fs、crypto、net、electron.ipcMain、electron.app 等运行时对象。攻击者如果能在核心逻辑之前执行代码,就可能改变这些对象的行为。
这正是 Electron 本地安全分析里最关键的风险点。
ASAR 不是安全边界
很多人会误以为代码打进 app.asar 就不容易被修改。实际上,ASAR 更接近压缩包:
npm install -g @electron/asar
asar list app.asar
asar extract app.asar app
上面的命令能列出和解包 ASAR 内容。对防护设计来说,ASAR 只能解决“文件组织”和“加载效率”问题,不能解决“代码不可篡改”问题。
Electron 默认加载资源时,历史上存在 app 目录和 app.asar 的优先级问题。为了减少本地目录覆盖风险,可以使用 Electron Fuses 收紧加载策略。
Electron Fuses:启动期安全开关
Electron Fuses 是写入 Electron 可执行文件的一组开关,用来控制运行时能力。它们比 JavaScript 层的判断更早生效,因此适合做基础安全收敛。
常见配置含义如下:
| Fuse | 建议 | 说明 |
|---|---|---|
RunAsNode | 关闭 | 避免可执行文件被当作普通 Node.js 运行 |
EnableNodeCliInspectArguments | 关闭 | 禁止通过 --inspect 等参数打开调试入口 |
EnableNodeOptionsEnvironmentVariable | 关闭 | 避免通过 NODE_OPTIONS 注入运行参数 |
OnlyLoadAppFromAsar | 开启 | 限制应用只从 app.asar 加载 |
EnableEmbeddedAsarIntegrityValidation | 开启 | 配合平台能力校验嵌入的 ASAR 完整性 |
EnableCookieEncryption | 开启 | 降低 Cookie 明文落盘风险 |
可以在构建产物阶段配置 Fuses。示例仅用于自有应用发布流程:
const { flipFuses, FuseV1Options, FuseVersion } = require('@electron/fuses');
flipFuses('dist/AcmeNote.exe', {
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.EnableCookieEncryption]: true
});
Fuses 不能替代代码签名,也不能阻止所有本地篡改。它的作用是减少默认暴露面,让攻击者不能轻易通过命令行参数或资源目录覆盖进入调试状态。
为什么 JavaScript 层完整性校验容易失效
一种常见设计是:主进程启动后读取关键文件,计算 Hash,再和内置 Hash 比较。如果不一致,就退出程序。
流程看起来合理:
flowchart TD
A[主进程启动] --> B[读取 package.json / main.js / 页面资源]
B --> C[计算文件 Hash]
C --> D{Hash 是否匹配}
D -->|匹配| E[继续运行]
D -->|不匹配| F[退出应用]
问题在于,这套逻辑和被保护的代码处在同一个信任边界里。完整性校验依赖的 API 也在同一个 Node.js 运行时中,例如:
fs.readFilefs.promises.readFilecrypto.createHashprocess.exitelectron.app.quit
如果攻击者能在校验逻辑之前执行代码,就可能改变这些 API 的行为,让校验读取到“干净备份”,或者阻止退出函数真正执行。
更稳妥的设计应该把完整性校验拆成多层:
flowchart TD
A[系统代码签名] --> B[确认可执行文件未被替换]
B --> C[Electron Fuses 收紧启动能力]
C --> D[ASAR 完整性校验]
D --> E[签名清单校验关键资源]
E --> F[服务器侧授权与风控校验]
各层负责的问题不同:
| 层级 | 解决的问题 | 局限 |
|---|---|---|
| 代码签名 | 防止安装包和可执行文件被无感替换 | 用户主动忽略签名警告时仍有风险 |
| Fuses | 收紧 Electron 启动期能力 | 不能单独证明业务代码可信 |
| ASAR 完整性 | 检测资源归档被改动 | 平台支持和配置要正确 |
| 签名清单 | 校验关键文件未被替换 | 如果校验逻辑本身被 Hook,仍可能失效 |
| 服务端校验 | 把关键授权判断放到远端 | 离线场景无法完全依赖服务端 |
用签名清单做资源完整性校验
本地 Hash 清单不能只和应用放在一起。攻击者可以同时修改文件和 Hash。更合理的做法是:发布时生成文件 Hash 清单,再使用私钥对清单签名;客户端只内置公钥,用来验证清单签名。
清单结构可以这样设计:
{
"version": "1.0.0",
"files": [
{
"path": "launch.dist.js",
"sha256": "..."
},
{
"path": "page-dist/index.html",
"sha256": "..."
}
],
"signature": "base64-signature"
}
客户端校验逻辑:
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`;
function stableStringify(value) {
return JSON.stringify(value, Object.keys(value).sort());
}
function verifyManifestSignature(manifest) {
const { signature, ...payload } = manifest;
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(stableStringify(payload));
verifier.end();
return verifier.verify(
PUBLIC_KEY,
Buffer.from(signature, 'base64')
);
}
async function sha256(filePath) {
const data = await fs.readFile(filePath);
return crypto.createHash('sha256').update(data).digest('hex');
}
async function verifyFiles(appDir) {
const manifestPath = path.join(appDir, 'integrity.manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
if (!verifyManifestSignature(manifest)) {
throw new Error('Integrity manifest signature is invalid');
}
for (const file of manifest.files) {
const actual = await sha256(path.join(appDir, file.path));
if (actual !== file.sha256) {
throw new Error(`File has been modified: ${file.path}`);
}
}
}
这段逻辑的价值在于:攻击者不能只修改清单里的 Hash,因为清单签名会失败。
但它仍然不是终点。只要攻击者可以控制本地运行时,就可能尝试绕过 verifyFiles 的调用结果。因此,本地完整性校验适合增加篡改成本,不适合承载唯一安全决策。
IPC:渲染进程不能决定安全结果
Electron 有主进程和渲染进程。渲染进程更接近浏览器页面,负责 UI;主进程拥有更高权限,能访问文件系统、网络、系统能力。
授权、文件写入、命令执行等敏感逻辑必须放在主进程。渲染进程只能提交请求,不能自己决定结果。
推荐结构如下:
sequenceDiagram
participant UI as 渲染进程
participant Preload as Preload 白名单
participant Main as 主进程
participant License as 授权服务
UI->>Preload: 调用 activateOffline(code)
Preload->>Main: ipcRenderer.invoke('license.activateOffline', code)
Main->>Main: 校验格式、验签、检查设备绑定
Main->>License: 可选:续期或状态确认
License-->>Main: 返回签名结果
Main-->>UI: 返回有限状态
preload 中只暴露必要接口:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('license', {
activateOffline(code) {
return ipcRenderer.invoke('license.activateOffline', code);
}
});
主进程负责真正校验:
const { ipcMain } = require('electron');
ipcMain.handle('license.activateOffline', async (_event, code) => {
if (typeof code !== 'string' || code.length > 4096) {
return {
ok: false,
message: 'Invalid license code format'
};
}
const result = await verifyOfflineLicense(code);
if (!result.ok) {
return {
ok: false,
message: 'License verification failed'
};
}
return {
ok: true,
expiresAt: result.expiresAt
};
});
同时建议开启这些安全配置:
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, 'preload.js')
}
});
关键原则很简单:渲染进程负责交互,主进程负责裁决。
离线授权应该用“签名”,不是“把秘密藏在客户端”
离线授权的难点在于:客户端无法实时询问服务器,只能靠本地材料判断许可证是否有效。
一个合理模型是:
- 客户端生成设备指纹。
- 授权系统根据设备指纹、产品版本、邮箱、过期时间等生成授权载荷。
- 授权系统用私钥对载荷签名。
- 客户端内置公钥,只负责验签。
- 验签通过后,再检查设备指纹、版本、过期时间、授权类型。
授权载荷示例:
{
"product": "acme-note",
"version": "win|1.0.0",
"fingerprint": "device-fingerprint",
"email": "user@example.com",
"licenseId": "LIC-2026-0001",
"issuedAt": "2026-06-07",
"expiresAt": "2027-06-07",
"type": "pro"
}
授权码可以由两部分组成:
base64url(payload).base64url(signature)
验签逻辑示例:
const crypto = require('node:crypto');
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`;
function base64urlDecode(input) {
input = input.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(input, 'base64');
}
function verifyLicenseToken(token, currentFingerprint) {
const parts = token.split('.');
if (parts.length !== 2) {
return { ok: false, reason: 'Malformed token' };
}
const [payloadPart, signaturePart] = parts;
const payloadBuffer = base64urlDecode(payloadPart);
const signatureBuffer = base64urlDecode(signaturePart);
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(payloadPart);
verifier.end();
const signatureValid = verifier.verify(PUBLIC_KEY, signatureBuffer);
if (!signatureValid) {
return { ok: false, reason: 'Invalid signature' };
}
const payload = JSON.parse(payloadBuffer.toString('utf8'));
if (payload.fingerprint !== currentFingerprint) {
return { ok: false, reason: 'Device mismatch' };
}
if (new Date(payload.expiresAt).getTime() < Date.now()) {
return { ok: false, reason: 'License expired' };
}
return {
ok: true,
license: payload
};
}
这里有一个容易混淆的点:授权校验通常应该使用“私钥签名、公钥验签”,而不是把私钥或可逆秘密放进客户端。客户端只要包含能生成合法授权的秘密,就迟早会被提取。
联网续期不能只相信 { success: true }
很多桌面应用会在启动时做一次联网续期或状态确认。风险在于,如果客户端只看服务端响应里的布尔值,例如:
{
"success": true
}
那么本地网络层、代理层、运行时层只要能改变响应内容,就可能影响授权状态。
更稳妥的续期响应应该包含:
| 字段 | 作用 |
|---|---|
licenseId | 标识许可证 |
status | 当前状态,例如 active、revoked、expired |
expiresAt | 新的过期时间 |
nonce | 客户端发起请求时生成的随机数,防止重放 |
issuedAt | 服务端签发时间 |
signature | 服务端对响应主体的签名 |
响应示例:
{
"licenseId": "LIC-2026-0001",
"status": "active",
"expiresAt": "2027-06-07T00:00:00.000Z",
"nonce": "random-from-client",
"issuedAt": "2026-06-07T12:00:00.000Z",
"signature": "base64-signature"
}
客户端不应该只判断 status,还要验证签名和 nonce:
function verifyRenewResponse(response, expectedNonce) {
const { signature, ...payload } = response;
if (payload.nonce !== expectedNonce) {
return { ok: false, reason: 'Nonce mismatch' };
}
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(JSON.stringify(payload));
verifier.end();
const valid = verifier.verify(
PUBLIC_KEY,
Buffer.from(signature, 'base64')
);
if (!valid) {
return { ok: false, reason: 'Invalid server signature' };
}
return { ok: true, payload };
}
这仍然不能保证本地 UI 永远不可篡改。只要应用完全运行在用户设备上,拥有足够权限的本地攻击者就可能修改运行时逻辑。服务器侧能做的是:把账号权益、云同步、高价值 API、团队功能等关键能力放在服务端强校验,不能让本地状态成为唯一依据。
反调试适合收敛入口,不适合当作核心防护
生产环境关闭调试能力是必要的,但反调试不应该被设计成唯一防线。
| 做法 | 价值 | 问题 |
|---|---|---|
禁用 --inspect | 减少直接调试入口 | 只能防低成本调试 |
禁用 NODE_OPTIONS | 减少环境变量注入 | 不能防止二进制级或本地文件篡改 |
| 检测命令行参数 | 实现简单 | 运行太晚,容易被改 |
| 检测 DevTools | 能发现部分调试行为 | 误报和绕过都比较常见 |
| 代码签名 + Fuses + 服务端校验 | 多层防护 | 实现和运维成本更高 |
更实用的策略是区分构建类型:
| 构建类型 | 调试策略 |
|---|---|
| 开发构建 | 保留 DevTools、日志、Source Map |
| 内测构建 | 保留受控日志,限制敏感信息输出 |
| 生产构建 | 关闭调试入口,移除 Source Map,启用 Fuses 和代码签名 |
Electron 授权与完整性加固清单
可以按这个清单检查自有应用:
| 检查项 | 推荐做法 |
|---|---|
| ASAR | 使用 ASAR 打包,但不要把它当作加密 |
| Fuses | 关闭 Node 调试入口,开启只从 ASAR 加载 |
| 代码签名 | Windows 使用 Authenticode,macOS 使用签名和公证 |
| 完整性清单 | 用私钥签名清单,客户端只保存公钥 |
| 主进程权限 | 敏感逻辑只放主进程,不放渲染进程 |
| IPC | 白名单通道、参数校验、返回最小信息 |
| 渲染进程 | nodeIntegration: false、contextIsolation: true、sandbox: true |
| 离线授权 | 使用私钥签名、公钥验签,绑定设备与过期时间 |
| 联网续期 | 响应带签名、nonce、时间戳,不只返回布尔值 |
| 服务端能力 | 云功能和账号权益必须由服务端强校验 |
| 日志 | 生产环境移除敏感日志,避免泄露授权细节 |
核心结论
Electron 应用的安全边界和 Web 应用不同。桌面端代码、资源、运行时都在用户设备上,本地攻击者拥有更强的观察和修改能力。
安全设计不能依赖某一个点:
- ASAR 负责打包,不负责保密。
- Fuses 负责收紧启动能力,不负责业务可信。
- 本地完整性校验负责增加篡改成本,不负责绝对防护。
- 离线授权应该使用签名体系,不能把生成授权的秘密放进客户端。
- 联网续期结果需要签名和重放保护,关键权益要放到服务端判断。
把这些层组合起来,才能让 Electron 应用在可维护性、离线能力和安全成本之间取得更合理的平衡。