芥末
发布于 2026-01-04 / 0 阅读
0
0

Electron 应用的反调试、完整性校验与授权链路安全设计

商业软件的授权绕过、激活伪造和联网校验欺骗不适合作为可复现教程。更有价值的做法,是把同类现象抽象成 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.asarASAR 是 Electron 使用的一种归档格式,它方便分发和加载资源,但不是加密,也不是强安全边界。

一个简化的 package.json 入口可能长这样:

{
  "name": "acme-note",
  "version": "1.0.0",
  "main": "launch.dist.js"
}

如果 main 指向 launch.dist.js,Electron 会从应用资源目录里寻找这个入口。入口脚本可能直接包含业务逻辑,也可能只做初始化,然后加载压缩后的 JavaScript、字节码文件或其他模块。

需要注意一点:只要核心逻辑仍运行在 Node.js / Electron 环境里,它就会依赖 fscryptonetelectron.ipcMainelectron.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.readFile
  • fs.promises.readFile
  • crypto.createHash
  • process.exit
  • electron.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')
  }
});

关键原则很简单:渲染进程负责交互,主进程负责裁决。

离线授权应该用“签名”,不是“把秘密藏在客户端”

离线授权的难点在于:客户端无法实时询问服务器,只能靠本地材料判断许可证是否有效。

一个合理模型是:

  1. 客户端生成设备指纹。
  2. 授权系统根据设备指纹、产品版本、邮箱、过期时间等生成授权载荷。
  3. 授权系统用私钥对载荷签名。
  4. 客户端内置公钥,只负责验签。
  5. 验签通过后,再检查设备指纹、版本、过期时间、授权类型。

授权载荷示例:

{
  "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当前状态,例如 activerevokedexpired
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: falsecontextIsolation: truesandbox: true
离线授权使用私钥签名、公钥验签,绑定设备与过期时间
联网续期响应带签名、nonce、时间戳,不只返回布尔值
服务端能力云功能和账号权益必须由服务端强校验
日志生产环境移除敏感日志,避免泄露授权细节

核心结论

Electron 应用的安全边界和 Web 应用不同。桌面端代码、资源、运行时都在用户设备上,本地攻击者拥有更强的观察和修改能力。

安全设计不能依赖某一个点:

  • ASAR 负责打包,不负责保密。
  • Fuses 负责收紧启动能力,不负责业务可信。
  • 本地完整性校验负责增加篡改成本,不负责绝对防护。
  • 离线授权应该使用签名体系,不能把生成授权的秘密放进客户端。
  • 联网续期结果需要签名和重放保护,关键权益要放到服务端判断。

把这些层组合起来,才能让 Electron 应用在可维护性、离线能力和安全成本之间取得更合理的平衡。


评论