Electron 应用逆向分析实战:ASAR、preload 与 IPC(以 Joplin 为例)
本页目录
Electron 应用的逻辑是 resources/app.asar 里的 JavaScript,分成主进程、preload 脚本和渲染页面三层。逆向的顺序是:把归档复制出来解开,读 package.json 找到主入口,再顺着 BrowserWindow 的 webPreferences 找到 preload 脚本,以及把页面和 Node.js 连起来的 IPC 通道。原生 .node 扩展和 Electron fuses 是最后两块。
本文的实际目标是 Joplin 3.7.22:github.com/laurent22/joplin 上的开源笔记应用(AGPL-3.0-or-later),用的是 macOS arm64 的发布版 DMG。静态分析用 REA 6.7.0(第三方开源工具),环境是 macOS 27.0.1 arm64、Node.js 22.22.3。请只分析开源应用、自己的应用或已获授权的应用。
第 1 步:把归档从应用里复制出来
hdiutil attach -nobrowse -readonly -mountpoint ./joplin-mnt Joplin-3.7.22-arm64.dmg
cp joplin-mnt/Joplin.app/Contents/Resources/app.asar work/
cp -R joplin-mnt/Joplin.app/Contents/Resources/app.asar.unpacked work/
ditto joplin-mnt/Joplin.app work/Joplin.app
hdiutil detach ./joplin-mnt
Joplin 的 app.asar 有 233,595,359 字节。旁边的 app.asar.unpacked/node_modules/ 里有四个必须以真实文件存在的包:keytar、onnxruntime-node、sqlite-vec-darwin-arm64 和 sqlite3。两者要放在一起。Windows、Linux 上的路径,以及浏览器和 @electron/asar 解包方式,见 ASAR 解包教程。
第 2 步:建清单,先迎接一次完整性失败
rea inspect-artifact "$PWD/work/app.asar" --json > inspection.json
在 Joplin 上它当场失败:
{"code":"artifact_integrity_mismatch","category":"integrity_mismatch","message":"Declared ASAR integrity for unpacked entry node_modules/keytar/build/Release/keytar.node contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. …","details":{"logical_path":"node_modules/keytar/build/Release/keytar.node","declared_sha256":"6c32c41c…","calculated_sha256":"f5112161…","unpacked":true}}
签过名的 macOS 发布版大多如此:ASAR 头记下哈希之后,原生二进制又被代码签名改过。把这类矛盾改成“记录但不中断”再跑一次:
rea inspect-artifact "$PWD/work/app.asar" --integrity-policy record-and-continue --json > inspection.json
耗时 4.3 秒,清单里有 7,032 个节点,记录了 5 处矛盾,全是 app.asar.unpacked 里的原生文件:keytar.node、onnxruntime_binding.node、libonnxruntime.1.24.3.dylib、vec0.dylib 和 node_sqlite3.node。每处都保留了声明的和实际的 SHA-256,矛盾一直看得见,没有被吞掉。
第 3 步:解开归档,看看发布了什么
rea extract-artifact "$PWD/work/app.asar" --integrity-policy record-and-continue --json > extract.json
jq -r '.normalized_result.output_root' extract.json
REA 用 43 秒把 6,324 个文件写进系统临时目录下一个新建的 rea-extracted-<uuid>。先看 package.json:
{"name": "@joplin/app-desktop", "version": "3.7.22", "main": "main.bundle.js", "license": "AGPL-3.0-or-later"}
主入口是一个 13,140,998 字节的 esbuild bundle,渲染进程还有一个 19,993,163 字节的 main-html.bundle.js。先别急着找拆包工具,四处看看:Joplin 的归档里还带着 TypeScript 源码(node_modules 之外有 391 个非声明的 .ts 文件)和 547 个 source map。main.bundle.js.map 列了 2,723 个源,包括 ElectronAppWrapper.ts,但没有 sourcesContent,只给得出文件名和位置。在这里,能直接读的是 .ts 文件本身。
ElectronAppWrapper.ts 直接回答了第一个安全问题。主窗口是这样创建的:
webPreferences: {
session: this.joplinSession_,
nodeIntegration: true,
contextIsolation: false,
spellcheck: true,
enableRemoteModule: true,
}
而从 about:blank 打开的窗口用 nodeIntegration: false,加上 preload: path.resolve(__dirname, './utils/window/secondaryWindowPreload.js')。这个 preload 只有四行:
const { ipcRenderer } = require('electron');
window.electronWindow = {
onSetWindowId: windowId => ipcRenderer.send('secondary-window-added', windowId),
};
ElectronAppWrapper.ts 第 502 行用 ipcMain.on('secondary-window-added', …) 处理它。大多数应用不会附带源码,所以下一步从 bundle 入手。
第 4 步:限定范围,构建应用图
解析一个 233 MB 归档里全部 5,379 个相关文件,又慢又会产出巨大的记录。知道该看哪些文件时,用 --source-path 选中它们(必须是归档里的精确路径,不支持通配符):
rea analyze-javascript-application "$PWD/work/app.asar" --integrity-policy record-and-continue \
--source-path package.json --source-path main.bundle.js \
--source-path utils/window/secondaryWindowPreload.js --source-path gui/note-viewer/preload.js \
--json > app-evidence.json
REA 仍会为整个归档建清单并校验,其余 5,375 个文件标为不在分析范围内。第一次跑了 38 秒,但结果里 skipped_javascript_files: 1,还有这条 limitation:
main.bundle.js: Resource constraint during analyze_javascript_application: Owned JavaScript worker exhausted its heap while analyzing main.bundle.js during static …
每个文件默认的预算是 1024 MiB V8 堆和 300000 ms。调高之后就不再跳过:
rea analyze-javascript-application "$PWD/work/app.asar" --integrity-policy record-and-continue \
--max-heap-mb 6144 --analysis-timeout-ms 900000 \
--source-path package.json --source-path main.bundle.js \
--source-path utils/window/secondaryWindowPreload.js --source-path gui/note-viewer/preload.js \
--json > app-evidence.json
71 秒,遍历 1,915,101 个 AST 节点,Evidence 文件 132,610,252 字节。千万别整份读,先投影摘要:
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
rea inspect-analysis-view app-view.json
Electron 部分(节选):
browser_windows: 0
explicit_web_preferences: 0
preload_entrypoints: 0
ipc:
operations: 8
literal_channels: 5
dynamic_channel_operations: 3
renderer_transmissions: 4
renderer_listeners: 4
main_handlers: 0
paired_renderer_transmissions: 0
unpaired_literal_renderer_transmissions: 1
第 5 步:拿已知事实对照结果
和第 3 步读源码得到的结论对照一下。
REA 找到了什么。 main.bundle.js 的 electron-main 节点、utils/window/secondaryWindowPreload.js 的 electron-preload 节点,两者之间是一条 "mechanism":"property:preload" 的 loads 边,也就是说它从压缩过的主 bundle 里解析出了 path.resolve(__dirname, …) 这个 preload 路径。四个渲染侧监听器是笔记查看器的 webview preload(gui/note-viewer/preload.js)里 setHtml、scrollToHash、setPercentScroll、setMarkers 的 ipcRenderer.on。三个动态操作是 sendToHost(callName, …),通道是变量,永远不会被配对。唯一的字面量发送是 secondary-window-added。图里还有三个 native-addon 节点(keytar、sqlite3、onnxruntime 绑定),以及 sqlite 和 localStorage 存储节点。
它漏了什么。 browser_windows: 0、explicit_web_preferences: 0、main_handlers: 0,可源码里明明有主窗口和 secondary-window-added 的处理函数。bundle 里访问 Electron 走的是 esbuild 的模块互操作,写出来是 c3.ipcMain.on("secondary-window-added",…) 和 cy.BrowserWindow。REA 只有在能证明模块别名时才把调用认作 Electron API,这层包装很可能就是这次没认出来的原因。于是那次发送一直没配上对,主窗口的 nodeIntegration:!0,contextIsolation:!1 也没被报告。REA 自己也写明了:“missing findings do not establish absence”。
实用的原则是:Electron 计数里的 0,是去 grep bundle 的理由,不是结论。grep -o '.\{60\}contextIsolation.\{30\}' main.bundle.js 一行就找到了压缩后的 webPreferences。
并不是所有 bundle 都这样。在我们自己打包的三文件应用上(main.js、preload.js、renderer.js,一个 notes:export 通道;@electron/packager 20.3.0、Electron 44.9.1,app.asar 8,434 字节),同一条命令配对成功:
{"operations":2,"literal_channels":1,"dynamic_channel_operations":0,
"renderer_transmissions":1,"renderer_listeners":0,"main_handlers":1,
"paired_renderer_transmissions":1,"ambiguous_renderer_transmissions":0,
"unpaired_literal_renderer_transmissions":0}
差别就在于:未打包文件里是朴素的 require("electron"),而 Joplin 是 esbuild 的互操作包装。
endpoint 噪声很大。 摘要在 Joplin 主 bundle 里数出 25 个 endpoint 节点,大部分不是 URL:\df@tag、\current@color、Debugger.enable、.sync/readme.txt、content-type。需要手工筛。
第 6 步:追踪一个通道
node --input-type=module -e '
import { readFileSync, writeFileSync } from "node:fs";
const application = JSON.parse(readFileSync("app-evidence.json", "utf8"));
writeFileSync("trace-input.json", JSON.stringify({
application, seed: { kind: "channel", value: "secondary-window-added" }, direction: "incoming"
}));'
rea trace-application-feature ./trace-input.json --json > trace.json
jq -c '.normalized_result.summary' trace.json
direction 可取 incoming、outgoing 或 both。在 Joplin 上:
| direction | 追踪节点 | 追踪边 | 终端路径 |
|---|---|---|---|
incoming |
10 | 11 | 0 |
outgoing |
1 | 0 | 0 |
both |
15,812 | 16,214 | 5,410 |
incoming 给出了有用的链条:package → electron-main(main.bundle.js)→ loads → electron-preload(secondaryWindowPreload.js)→ sends → ipc-channel。outgoing 停在通道本身,又一次印证了第 5 步缺失的主进程处理函数。both 一路走到归档根节点,再沿着所有包含关系走下来,终点是 5,372 个 unknown(未选中的文件)、25 个 endpoint、5 个存储节点和 3 个原生 addon,对这个通道来说没有信息量。先只选一个方向。
其他种子类型有 node-id、route、string、api、module、native-export。匹配是字面量的:string 按子串匹配,其余按精确值。匹配上只证明存在静态链接,不证明某次运行一定走到了那里。
第 7 步:原生 addon
REA 会记录 JavaScript 加载的 .node 文件,以及语法里请求的成员名,但不解析它们。Joplin 的三个都作为原生交接项返回,状态是 requires-provider-analysis,并给出 addon 的摘要。要继续深入,用原生 provider 分析 app.asar.unpacked/ 里的文件:
rea analyze "$PWD/work/app.asar.unpacked/node_modules/keytar/build/Release/keytar.node" --provider ghidra --json
Ghidra 的流程见 REA + Ghidra。本文没有在 Joplin 的 addon 上实际跑这一步。
第 8 步:检查 fuses
代码本身说明不了它所在的 Electron 运行时有没有加固,那要看 Electron 二进制里的 fuse 字节:
rea inspect-electron-fuses "$PWD/work/Joplin.app" --json > fuses.json
jq -c '.normalized_result.runtime_implications[] | {subject, outcome}' fuses.json
REA 把应用包解析到它的 Electron Framework(191,759,520 字节,Electron 42.11.18,一个 arm64 slice,sentinel 在偏移 150027024)。Joplin 3.7.22 用的是默认值:RunAsNode、EnableNodeOptionsEnvironmentVariable、EnableNodeCliInspectArguments 开启,EnableEmbeddedAsarIntegrityValidation 和 OnlyLoadAppFromAsar 关闭:
{"subject":"node_cli_inspect_arguments","outcome":"honored"}
{"subject":"electron_run_as_node","outcome":"honored"}
{"subject":"embedded_asar_integrity_validation","outcome":"not_enforced"}
{"subject":"only_load_app_from_asar","outcome":"not_restricted_to_app_asar"}
它的 Info.plist 里仍有 Resources/app.asar 的 ElectronAsarIntegrity 哈希,但校验 fuse 关着,没人去核对。每个 fuse 管什么、传错文件的坑、怎样用 @electron/fuses 交叉验证,见英文文章 Electron fuses 详解。
第 9 步(可选):和运行中的应用对照
静态分析说明代码能做什么。想看页面实际加载了什么,REA 可以被动连接本机回环地址上的 CDP 端点。用 Electron 自带的 --remote-debugging-port=9223 开关启动应用(有些应用会忽略它),然后:
rea list-electron-targets http://127.0.0.1:9223 --json
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID --observation-ms 100 --json
在我们自己的测试应用上,它列出一个标题为 Notes 的 page 目标,页面 user agent 里是 Electron/44.9.1。检查过程不执行 JavaScript、不点击、不调用 IPC。本文没有在 Joplin 上跑这一步。rea capture-electron-scenario 会以你的普通用户权限启动应用,记录 IPC 通道和参数结构等元数据,见 electron-observation.md。
如果主进程或渲染进程代码是没有可用 map 的压缩 bundle,先把它拆成模块:还原 webpack 打包后的源码。
排错
用 --source-path 时报 invalid_request。 我们把位于 main.js 的文件写成了 src/main.js,得到 "Select an exact relevant file path from the artifact inventory; no globs or path aliases."。路径请从清单或解开的目录树里复制。
某个文件被算进 skipped JavaScript。 找一条点名它的 Resource constraint limitation,然后像第 4 步那样调高 --max-heap-mb 和 --analysis-timeout-ms。
IPC 配对数为 0。 要么通道是运行时拼出来的(变量或包装函数),要么像 Joplin 这样,bundle 通过 REA 无法证明的互操作包装访问 Electron。在 bundle 里 grep 通道字符串,读它附近的代码。
通过 MCP 交给 agent 时输出太大。 用 summary 和 page 视图、--source-path,或者 --format json --filter-output evidence_id,normalized_result.statistics。