本文是对 Cracking Electron apps open 的整理与翻译
起因:draw.io 更新,我的工具又挂了
Amos 用 draw.io 桌面版制作网站的配图,但实际的转换流程跑在 Linux 上------他写了一个 Rust 程序,开启无头 Chromium,加载 draw.io 的 HTML/JS/CSS,然后用 Chromium 的"打印为 PDF"功能保存 PDF,再进一步压缩成 SVG。
(这个思路和官方 draw.io Docker 镜像的做法完全一样。需要 Chromium 是因为 SVG 没有富文本排版能力,draw.io 在图表里嵌入了 HTML 标记,必须用浏览器来布局。)
问题在于:draw.io 一更新,他的转换程序就经常失效。每次失效,他都得去抓最新的 draw.io 桌面安装包,拆开来找他需要的文件。
这篇文章记录的就是:面对 draw.io 发布的所有格式------Windows Installer、Windows 无安装器、macOS Universal、Linux deb、Linux snap、Linux AppImage、Linux rpm、Chrome OS------如何把每种格式都拆开,找到想要的东西。
("crack"是核桃的意思,不是越狱。)
核心概念:什么是 ASAR
在正式开始之前,先理解贯穿全文的一个词:ASAR。
ASAR 是 Electron 的专属归档格式。简单说,就是把应用的 HTML、JS、CSS、图片等所有文件打包成一个单一的 .asar 文件,Electron 运行时能透明地读取里面的内容,就好像它们是普通文件一样。
这个设计的主要原因:
- Windows 的路径长度限制 :旧版 Windows 的某些 API 对路径长度有严格限制,
node_modules目录的深层嵌套很容易超过上限 - Windows 的大量小文件性能问题 :Windows 在处理"大量小文件"时比 Linux/macOS 慢得多(参见 WSL issue #873)
一个 .asar 文件替代了成千上万的小文件,解决了这两个问题。
使用 ASAR CLI:
bash
sudo npm install -g @electron/asar
# 列出内容
asar l resources/app.asar | grep -v node_modules
# 解压全部
asar e resources/app.asar ./app-unpacked
# 提取单个文件
asar ef resources/app.asar main.js
一、Windows Installer(.exe)
bash
$ ls -lhA
total 100M
-rw-rw-r-- 1 amos amos 100M Jul 2 20:49 draw.io-21.4.0-windows-installer.exe
先用 7z l 列出内容:
bash
$ 7z l draw.io-21.4.0-windows-installer.exe
输出非常详细,关键信息:
Type = Nsis
SubType = NSIS-3 Unicode BadCmd=11
...
2023-06-14 23:42:16 ..... 103376259 103376259 $PLUGINSDIR/app-64.7z
这是一个 NSIS(Nullsoft Scriptable Install System)安装包,7-zip 完全支持解压:
bash
$ 7z x draw.io-21.4.0-windows-installer.exe
Everything is Ok
Files: 11
实际的应用数据在嵌套的 .7z 归档里:
bash
$ cd '$PLUGINSDIR'
$ 7z x app-64.7z
Everything is Ok
Folders: 2
Files: 74
Size: 433816630
找 .asar 文件:
bash
$ find . -name '*.asar'
./resources/app.asar
安装 ASAR CLI,查看内容:
bash
$ asar l resources/app.asar | grep -v node_modules | grep -E '[.](html|css ".")$'
/drawio/teams.html
/drawio/src/main/webapp/clear.html
/drawio/src/main/webapp/dropbox.html
/drawio/src/main/webapp/export-fonts.css
/drawio/src/main/webapp/export3.html
/drawio/src/main/webapp/github.html
/drawio/src/main/webapp/gitlab.html
/drawio/src/main/webapp/index.html
/drawio/src/main/webapp/onedrive3.html
/drawio/src/main/webapp/open.html
/drawio/src/main/webapp/teams.html
/drawio/src/main/webapp/vsdxImporter.html
/drawio/src/main/webapp/styles/atlas.css
/drawio/src/main/webapp/styles/dark.css
/drawio/src/main/webapp/styles/grapheditor.css
/drawio/src/main/webapp/mxgraph/css/common.css
/drawio/src/main/webapp/mxgraph/css/explorer.css
这就是想要的!解压全部:
bash
$ asar e ./resources/app.asar ./app-unpacked
$ du -sh ./app-unpacked
204M ./app-unpacked
值得一提的是:我们完全在 Linux 上解压了一个 Windows 应用。类似的技巧也被用在 GOG 游戏的 Wine 安装脚本里(那些游戏只提供 Windows 安装程序)。
二、Windows 无安装器版(.exe)
bash
$ ls -lhA
total 100M
-rw-rw-r-- 1 amos amos 100M Jul 2 21:34 draw.io-21.4.0-windows-no-installer.exe
用 file 命令确认格式:
bash
$ file draw.io-21.4.0-windows-no-installer.exe
draw.io-21.4.0-windows-no-installer.exe: PE32 executable (GUI) Intel 80386,
for MS Windows, Nullsoft Installer self-extracting archive, 5 sections
同样是 NSIS,同样的路径:
bash
$ 7z x draw.io-21.4.0-windows-no-installer.exe
Everything is Ok
Files: 4
$ ls '$PLUGINSDIR/'
app-64.7z nsis7z.dll StdUtils.dll System.dll
然后就是你已经知道的步骤了。
(顺带吐槽:自解压的 NSIS 归档根本不算"无安装器"。真正的无安装器应该是一个普通的归档文件。Windows 11 即将原生支持 7z 格式,到时候这个问题也许会改变。)
三、macOS Universal DMG
bash
$ ls -lhA
total 218M
-rw-rw-r-- 1 amos amos 218M Jul 2 21:40 draw.io-universal-21.4.0.dmg
DMG 是 macOS 的可挂载镜像格式,不在 macOS 上怎么解?
7-zip 也支持:
bash
$ 7z x draw.io-universal-21.4.0.dmg
$ find . -name '*.asar'
./draw.io 21.4.0-universal/draw.io.app/Contents/Resources/app.asar
$ asar list "$(find . -name '*.asar')" | grep -F index.html
/drawio/src/main/webapp/index.html
规律越来越明显了。
四、Linux .deb
.deb 的本质是一个 GNU ar 归档,和 C/C++ 的静态库格式完全一样!
bash
$ ar t drawio-amd64-21.4.0.deb
debian-binary
control.tar.gz
data.tar.xz
可以用 ar(来自 GNU binutils):
bash
$ ar t drawio-amd64-21.4.0.deb
可以用 llvm-ar(来自 LLVM):
bash
$ llvm-ar t drawio-amd64-21.4.0.deb
当然也可以用 7-zip(在 Windows 上解 deb 时特别有用):
bash
$ 7z x drawio-amd64-21.4.0.deb
$ ls -lhA
total 206M
-rw-rw-r-- 1 amos amos 3.3K control.tar.gz
-rw-rw-r-- 1 amos amos 103M data.tar.xz
-rw-rw-r-- 1 amos amos 4 debian-binary
-rw-rw-r-- 1 amos amos 103M drawio-amd64-21.4.0.deb
用 7-zip 列出 data.tar.xz 中的 .asar(会先解成 data.tar,然后再查):
bash
$ 7z l data.tar | grep -E '[.]asar$'
2023-06-14 23:33:19 ..... 194079121 194079232 ./opt/drawio/resources/app.asar
或者直接用 tar(Linux、macOS、Windows 11 现在都支持):
bash
# 列出 tar.xz 内容
$ tar wtf data.tar.xz | grep -E '[.]asar$'
./opt/drawio/resources/app.asar
# 只提取 .asar 文件
$ tar pfx data.tar.xz ./opt/drawio/resources/app.asar
# 验证
$ asar list ./opt/drawio/resources/app.asar | grep -F 'index.html'
/drawio/src/main/webapp/index.html
(tar 两个有用的记忆法:wtf = what the fuck is inside,pfx = please fucking extract this。)
五、Linux .rpm
7-zip 提取出 .cpio 文件:
bash
$ 7z x drawio-x86_64-21.4.0.rpm
$ ls -lhA
total 520M
-rw-rw-r-- 1 amos amos 418M draw.io-21.4.0-1.x86_64.cpio
-rw-rw-r-- 1 amos amos 103M drawio-x86_64-21.4.0.rpm
再解一层:
bash
$ 7z l draw.io-21.4.0-1.x86_64.cpio | grep -E '[.]asar$'
2023-06-14 23:36:02 ..... 194079121 194079121 ./opt/drawio/resources/app.asar
六、Linux AppImage
最简洁的一个:
bash
$ 7z l drawio-x86_64-21.4.0.AppImage | grep -E '[.]asar$'
2023-06-14 23:33:19 ..... 194079121 67062337 resources/app.asar
一行搞定。
七、Linux Snap
Snap 通过 Snap Store 分发。如果不想通过 snap 命令安装,可以直接访问 API:
https://search.apps.ubuntu.com/api/v1/package/drawio
返回的 JSON 里有 anon_download_url 字段:
bash
$ curl --silent --location --remote-name \
https://api.snapcraft.io/api/v1/snaps/download/84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap
$ file 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap
84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap: Squashfs filesystem, little endian,
version 4.0, xz compressed, 144481911 bytes, 133 inodes, blocksize: 131072 bytes
.snap 本质是 squashfs + xz 压缩。7-zip 再次出场:
bash
$ 7z l 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap | grep -E '[.]asar$'
2023-06-27 15:35:17 ..... 194485747 62628240 resources/app.asar
八、Google Chrome OS(.crx)
Chrome OS 版本对应的是 Chrome Web Store 上的 draw.io 扩展,不是一个完整的 Electron 应用。
下载 .crx 文件需要构造特定的 URL。在浏览器打开扩展页面后,在 JS 控制台执行:
javascript
(function() {
let extensionID = new URL(location.href).pathname.split("/")[4];
console.log(`Downloading extension ${extensionID}`);
let x = `id%3D${extensionID}%26uc`;
let url = `https://clients2.google.com/service/update2/crx?response=redirect&prod=chromiumcrx` +
`&prodchannel=unknown&prodversion=9999.0.9999.0&acceptformat=crx2,crx3&x=${x}`;
return console.log(url);
}())
得到下载链接后:
bash
$ ls -lhA
total 39152
-rw-r--r--@ 1 amos wheel 19M Jul 3 14:32 extension_21_2_7_0.crx
比其他任何格式都小得多------因为这个版本不包含完整的 Chromium 和 V8,只是纯 HTML/JS。
.crx 文件的本质是带额外头部字节的 ZIP(类似 JAR、ODF、MSIX 格式):
bash
$ unzip -l extension_21_2_7_0.crx | head
Archive: extension_21_2_7_0.crx
warning [extension_21_2_7_0.crx]: 1320 extra bytes at beginning or within zipfile
(attempting to process anyway)
7-zip 直接提取。这里没有 .asar 文件,应用内容直接可访问:
bash
$ 7z l extension_21_2_7_0.crx | grep .html
2022-06-11 16:07:56 ..... 694 401 index.html
九、扩展实验:Figma
换一个应用试试------Figma,看它是不是也是 Electron 构建的。
bash
$ ls -lhA
total 3608
-rw-r--r--@ 1 amos wheel 1.8M Jul 3 15:18 Figma.dmg
等等,只有 1.8MB?7-zip 提取:
Figma.app/
├── Contents/
│ ├── MacOS/
│ │ └── DynamicUniversalApp (238KB)
│ └── Resources/
│ └── (只有 nib 和 icon)
这只是一个下载器 。直接运行它会提示必须放在 /Applications 目录(App Translocation 机制)。
但如果你不想运行它,直接看 Info.plist:
bash
$ cat ./Figma.app/Contents/Info.plist | grep -i https -B 1
<key>aarch64</key>
<string>https://desktop.figma.com/mac-arm/Figma.zip</string>
<key>x86_64</key>
<string>https://desktop.figma.com/mac/Figma.zip</string>
下载链接就在 plist 里。作者评价:这个 2MB 的下载器只是用来根据架构下载正确版本,其实也节省了分发一个真正的 Universal Binary 的带宽,所以也说得过去。
bash
$ curl -sLO https://desktop.figma.com/mac-arm/Figma.zip
$ unzip -l ./Figma.zip | grep -F '.asar'
1658107 06-27-2023 11:13 Figma.app/Contents/Resources/app.asar
列出 asar 内容:
bash
$ asar list app.asar | grep .js | grep -v node_modules
/build.json
/i18n/ja.json
/js
/js/desktop_shell.js
/main.js
/package-lock.json
/package.json
/shell_app_binding_renderer.js
/tray_binding_renderer.js
/web_app_binding_renderer.js
提取 main.js 并用 js-beautify 格式化:
bash
$ asar ef app.asar main.js
$ js-beautify main.js | grep '[.]node"'
var K = hl.app.isPackaged ? require("./bindings.node") : require("../build/Release/bindings.node"),
Ei = kt(() => hl.app.isPackaged ? require("./desktop_rust.node") : ...
.node 文件是 Node.js 原生模块(C/C++/Rust 编写,编译为动态库)。
确认 Figma 还是 Electron:
bash
$ ls -lhA Figma.app/Contents/Frameworks
Electron Framework.framework
Figma Helper (GPU).app
Figma Helper (Plugin).app
Figma Helper (Renderer).app
Figma Helper.app
Mantle.framework
ReactiveObjC.framework
Squirrel.framework
无需多言。
Figma 真的在用 Rust
用 nm 查看 desktop_rust.node 的符号:
bash
$ nm -C desktop_rust.node | grep ' _ft_' | head -5
0000000000144b42 s _ft_adobe_glyph_list
0000000000029fcc t _ft_alloc
...
$ nm -C desktop_rust.node | grep ' _hb_' | head -5
00000000000e3e4c t _hb_shapers_get()
00000000000dbf7c t _hb_ot_shape_normalize(...)
harfbuzz(文字整形)和 freetype(字体渲染)------这合理,因为 JS 代码调用了 getFontPreview。
更有意思的是 cxxbridge 符号:
bash
$ nm -C desktop_rust.node | grep 'cxxbridge' | head
0000000000029164 t generate_svg(rust::cxxbridge1::String, ...)
0000000000028124 t get_font(rust::cxxbridge1::String)
cxxbridge 是 Rust 与 C++ 互操作的库。所以 desktop_rust.node 是 Rust + C++ 混合编译的产物,而 bindings.node 大概是 macOS 平台特定的 Objective-C/Swift 代码。
十、扩展实验:Discord
bash
$ ls -lhA Discord.dmg
-rw-r--r--@ 1 amos wheel 158M Jul 3 16:11 Discord.dmg
$ 7z l Discord.dmg | grep 'asar'
2023-04-26 23:30:26 ..... 4816906 4820992 Discord/Discord.app/Contents/Resources/app.asar
这个 .asar 只有 4.6MB,远小于 draw.io 的 194MB。
解压后用 ripgrep 搜索:
bash
$ rg 'NEW_UPDATE_ENDPOINT'
index.js
23: NEW_UPDATE_ENDPOINT
26: if (!updater.tryInitUpdater(buildInfo, NEW_UPDATE_ENDPOINT)) {
Constants.js
25:const NEW_UPDATE_ENDPOINT = settings.get('NEW_UPDATE_ENDPOINT') ||
'https://updates.discord.com/';
这只是一个自更新器。继续找真正的下载 URL:
bash
$ js-beautify app/app_bootstrap/splash/index.js | grep 'discord.com' -B 10
te = `https://discord.com/api/download/${DiscordSplash.getReleaseChannel()}?platform=linux&format=`,
$ cat build_info.json
{
"releaseChannel": "stable",
"version": "0.0.275"
}
用这个 URL 模板下载真正的应用:
bash
$ curl -sL -o discord.tar.gz \
"https://discord.com/api/download/stable?platform=linux&format=tar.gz"
$ ls -lhA
total 197880
-rw-r--r--@ 1 amos wheel 88M Jul 3 16:36 discord.tar.gz
里面的 app.asar 就是实际的 Discord 应用代码------和 macOS 版完全一样。
Discord 的第二套更新系统
Discord 还有另一条细粒度的更新路径:
bash
$ cat bootstrap/manifest.json
{
"discord_desktop_core": 0,
"discord_erlpack": 0,
"discord_spellcheck": 0,
"discord_utils": 0,
"discord_voice": 0
}
moduleUpdater.js 里能看到 URL 模板:
javascript
remoteBaseURL = `${endpoint}/modules/${buildInfo.releaseChannel}`;
const url = `${remoteBaseURL}/${encodeURIComponent(getRemoteModuleName(queuedModule.name))}/${encodeURIComponent(queuedModule.version)}`;
访问模块的 manifest 端点:
bash
$ curl "https://updates.discord.com/distributions/app/manifests/latest?channel=stable&platform=win&arch=x86" -o my-manifest.json
用 jq 找到 discord_desktop_core 的完整下载包:
bash
$ jq -C .modules.discord_desktop_core.full my-manifest.json
{
"host_version": [1, 0, 9014],
"module_version": 1,
"package_sha256": "62a8bb668df50930e514b7910ba236e259a0bfe7cd97d8e131541317229faeaa",
"url": "https://dl.discordapp.net/distro/app/stable/win/x86/1.0.9014/discord_desktop_core/1/full.distro"
}
下载这个 .distro 文件:
bash
$ file full.distro
full.distro: OpenPGP Public Key
file 识别错误(这是因为 brotli 文件头的字节碰巧和 PGP 头相似)。在 OpenAsar 项目的源码里找到了线索:
javascript
body = Buffer.concat(body);
body = zlib.brotliDecompressSync(body);
fs.writeFileSync('client.tar', body);
原来是 brotli 压缩的 tar:
bash
$ brotli -d full.distro -o full.distro.tar
$ tar wtf full.distro.tar
delta_manifest.json
files/core.asar
files/index.js
files/package.json
提取出来总共有 2600+ 个文件------这才是真正的完整 Discord 桌面应用。
验证一下里面的内容:
bash
$ rg -i 'devtools' index.js
125: const enableDevtoolsSetting = global.appSettings.get(
'DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING', false);
126: const enableDevtools = buildInfo.releaseChannel === 'stable' ? enableDevtoolsSetting : true;
$ rg -i 'Window' index.js
8:exports.setMainWindowVisible = setMainWindowVisible;
12: BrowserWindow
117: const windowNative = require('./discord_native/browser/window');
这确实是完整的 Discord 主进程代码。
Discord 也在用 Rust
从之前 Reddit 上找到的一条 Discord 更新器错误日志里:
[2023-04-07 17:44:23] ERROR [updater_client]: Failed 7: Other(
Reqwest(
reqwest::Error {
kind: Request,
url: Url { ... "https://updates.discord.com/distributions/app/manifests/latest" ... }
updater_client 和 Reqwest(Rust 的 HTTP 客户端库)------Discord 的更新器也是用 Rust 写的。
格式对照总结
| 格式 | 本质 | 工具 | 嵌套层次 |
|---|---|---|---|
| Windows Installer .exe | NSIS 自解压 → 7z → asar | 7-zip + asar | 3层 |
| Windows 无安装器 .exe | NSIS 自解压 → 7z → asar | 7-zip + asar | 3层 |
| macOS .dmg | HFS+ 镜像 → app → asar | 7-zip + asar | 2层 |
| Linux .deb | GNU ar → data.tar.xz → asar | ar/tar/7z + asar | 2层 |
| Linux .rpm | RPM → .cpio → asar | 7-zip × 2 + asar | 3层 |
| Linux AppImage | squashfs → asar | 7-zip + asar | 1层 |
| Linux .snap | squashfs+xz → asar | 7-zip + asar | 1层 |
| Chrome OS .crx | 带额外头的 ZIP | 7-zip | 0层(直接 HTML) |
结论
对于 Electron 应用来说,获取源代码通常非常容易。大多数应用甚至没有混淆代码,而且 node_modules 里堆了大量空间浪费。
几个有趣的收获:
7-zip 几乎无所不能。 NSIS 安装包、DMG 镜像、.deb/.rpm 包、squashfs、cpio------这些格式 7-zip 都支持,可以用它作为"万能钥匙"的第一道门。
很多"应用"其实只是下载器。 Figma 的 DMG 是 2MB 的架构分发器,Discord 的 DMG 是自更新器,真正的应用代码隐藏在 update API 的下载链接里。Info.plist 和 JS 代码里往往直接写了真实下载 URL。
Discord 的 .distro 格式比想象中简单。 brotli 压缩的 tar 归档,没什么神秘的。OpenAsar 项目的源码直接揭示了这一点。
大型 Electron 应用正在用 Rust 补充 JS 的不足。 Figma 的 desktop_rust.node 包含 harfbuzz 和 freetype(字体处理),Discord 的更新器用 reqwest,这是 Rust 被嵌入 Electron 生态的具体证据。
这些技术不是"黑客行为"------draw.io 本身就是开源的,Figma 和 Discord 也没有对这些文件加密(只是 Discord 的 .distro 格式稍微绕了一点)。能学到的东西很多,有时候甚至能发现安全漏洞(虽然一些公司的漏洞赏金会让人大失所望)。
参考链接
- 原文:https://fasterthanli.me/articles/cracking-electron-apps-open
- Electron ASAR 文档:https://www.electronjs.org/docs/latest/tutorial/asar-archives
- @electron/asar CLI:
npm install -g @electron/asar - 7-zip(p7zip-full):
apt install p7zip-full - js-beautify:
npm install -g js-beautify - ripgrep:https://github.com/BurntSushi/ripgrep
- fx(交互式 JSON 查看器):https://github.com/antonmedv/fx
- NSIS(Nullsoft Scriptable Install System):https://nsis.sourceforge.io
- OpenAsar(Discord 的社区开源替代方案):https://openasar.dev
- cxxbridge(Rust/C++ 互操作):https://github.com/dtolnay/cxx
- Windows 路径长度限制说明:https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation