拆开任意 Electron 应用:从 Windows 安装包到 Discord 的私有更新协议

本文是对 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 运行时能透明地读取里面的内容,就好像它们是普通文件一样。

这个设计的主要原因:

  1. Windows 的路径长度限制 :旧版 Windows 的某些 API 对路径长度有严格限制,node_modules 目录的深层嵌套很容易超过上限
  2. 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_clientReqwest(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 格式稍微绕了一点)。能学到的东西很多,有时候甚至能发现安全漏洞(虽然一些公司的漏洞赏金会让人大失所望)。


参考链接

相关推荐
丷丩1 小时前
MapLibre GL JS第30课:添加视频
javascript·音视频·gis·mapbox·maplibre gl js
ZengLiangYi2 小时前
多格式文件解析:JSONL / SQLite / Event Stream
前端·javascript·后端
万少2 小时前
湖南卫视的秘密武器曝光!芒果灵创,专业AI影视创作平台
前端·javascript·后端
槑有老呆2 小时前
解密 JS 变量提升:告别玄学,读懂 V8 编译与代码执行逻辑
javascript
东风破_2 小时前
一文搞懂 JavaScript 变量声明:var、let、const 到底有什么区别?
前端·javascript
无糖可可果2 小时前
拆穿 JavaScript 变量提升的"魔术"——从一段反直觉代码说起
javascript
月光刺眼2 小时前
🎶二分 · 双指针 · 滑动窗口 · 螺旋矩阵:数组算法四题拆解
javascript·算法
PascalMing2 小时前
从零实现一款 Windows 下的 SSH 批量运维工具:LinuxSshTools 技术详解
运维·windows·ssh
光影少年3 小时前
Redux Toolkit 用法、解决原生Redux 冗余问题
开发语言·前端·javascript·react.js·中间件·前端框架·ecmascript