Electron 应用逆向修改指南:解包、修改、重打包与签名
详解 macOS 上 Electron 应用 app.asar 的完整修改流程,涵盖解包、代码修改、重打包、文件替换与代码签名。
背景 Electron 应用将业务代码打包在 app.asar 归档文件中。这是一种带 pickle 编码头部的二进制格式,内部包含 JSON 元数据(文件树、大小、偏移量)和文件数据区。修改 Electron 应用的行为,本质上就是解包 asar → 修改 JS → 重打包 → 重新签名。
本文以一个通用 Electron 桌面应用为例,完整演示这一流程。
前置准备 1 2 3 4 5 6 node -v npx -v codesign -dv "$(which codesign) " 2>/dev/null || xcode-select --install
应用结构分析 macOS Electron 应用目录结构 1 2 3 4 5 6 7 8 9 10 11 /Applications/YourApp.app/ └── Contents/ ├── Info.plist ├── MacOS/ │ └── YourApp # 主可执行文件 ├── Frameworks/ │ └── Electron Framework.framework/ └── Resources/ ├── app.asar # ← 业务代码归档(核心修改目标) ├── app.asar.unpacked/ # ← 未打包的原生模块 └── icon.icns
asar 内部结构 1 2 3 4 5 npx --yes asar list /path/to/app.asar npx --yes asar list /path/to/app.asar --is-pack
使用 @electron/asar Node.js API 可以更精确地查看 unpack 配置:
1 2 3 4 5 6 7 const asar = require ('@electron/asar' );const files = asar.listPackage ('/path/to/app.asar' , { isPack : true });files .filter (f => f.startsWith ('unpack' )) .forEach (f => console .log (f));
输出示例:
1 2 3 unpack : /dist/node-addon/arm64/native-module.node unpack : /dist/node-addon/arm64/helper-lib.dylib unpack : /dist/node-addon/arm64/another-module.node
为什么要关注 unpack? .node 和 .dylib 是原生模块,Electron 通过 process.dlopen() 加载它们。这些文件不能放在 asar 归档内部 ,否则 dlopen 无法读取。重打包时必须将这些文件标记为 unpacked,否则应用启动即崩溃。
完整修改流程 第一步:关闭应用 1 2 3 4 5 6 pkill -f "YourApp" sleep 2pgrep -fl YourApp || echo "已关闭"
第二步:备份原始 asar 1 2 3 ASAR_PATH="/Applications/YourApp.app/Contents/Resources/app.asar" cp "$ASAR_PATH " "${ASAR_PATH} .bak" echo "备份完成: ${ASAR_PATH} .bak"
第三步:解包 asar 1 2 3 4 5 WORK_DIR=$(mktemp -d /tmp/asar_work.XXXXXX) EXTRACT_DIR="$WORK_DIR /extracted" npx --yes asar extract "$ASAR_PATH " "$EXTRACT_DIR " echo "解包完成: $EXTRACT_DIR "
解包后就是普通的文件目录,可以直接用任何文本编辑器或脚本修改。
第四步:定位并修改目标代码 Electron 的业务代码通常被 webpack/esbuild 压缩成单行 JS。先搜索目标代码:
1 2 grep -rlF --include='*.js' "搜索关键词" "$EXTRACT_DIR "
找到目标文件后,进行字面量替换。强烈推荐用 perl 的 index()+substr() 做替换 ,避免正则表达式和 shell 插值问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 patch_file () { local file="$1 " old="$2 " new="$3 " perl -e ' use strict; use warnings; my ($file, $old, $new) = @ARGV; local $/; open(my $fh, "<:encoding(UTF-8)", $file) or die "读取失败: $!\n"; my $content = <$fh>; close $fh; my $pos = index($content, $old); if ($pos < 0) { die "目标字符串未找到,可能版本不兼容\n"; } substr($content, $pos, length($old)) = $new; open(my $out, ">:encoding(UTF-8)", $file) or die "写入失败: $!\n"; print $out $content; close $out; ' "$file " "$old " "$new " } patch_file "$EXTRACT_DIR /dist/main-process/main.js" \ 'if(someCondition){' \ 'if(!1){' patch_file "$EXTRACT_DIR /dist/renderer-process/index.js" \ 'someVar=e),' \ 'someVar=void 0),'
为什么不用 sed? 压缩代码中常包含反引号 `、${}、! 等特殊字符,sed 的正则引擎和 shell 的字符串插值会干扰这些字符。perl 的 index()+substr() 是纯字面量操作,100% 安全。
第五步:重打包 asar(最关键的一步) 1 2 3 4 5 6 7 8 NEW_ASAR="$WORK_DIR /app.asar.new" npx --yes asar pack "$EXTRACT_DIR " "$NEW_ASAR " \ --unpack-dir "dist/node-addon" echo "重打包完成" ls -la "$NEW_ASAR "
验证重打包结果 1 2 3 4 5 6 7 8 ls -la "$ASAR_PATH " "$NEW_ASAR " npx --yes asar list "$NEW_ASAR " --is-pack | grep unpack ls -la "${NEW_ASAR} .unpacked/dist/node-addon/"
如果 asar 大小明显大于原始文件(比如膨胀 50% 以上),说明所有文件都被打包进了 asar 内部,原生模块没有被 unpack。应用会启动失败。
重打包的常见坑
问题
原因
解决方案
asar 体积膨胀,所有文件都在内部
--unpack glob 模式不匹配
改用 --unpack-dir 指定目录
多个 --unpack 标志只生效一个
commander.js 只保留最后一个值
合并为单个 glob 模式
--unpack-dir "dir1,dir2" 不生效
逗号被当作字符串的一部分
用公共父目录,或分两次打包
*.{node,dylib} 不生效
minimatch v9+ 默认不开启花括号展开
改用 --unpack-dir
第六步:替换原文件 1 2 3 4 5 cp "$NEW_ASAR " "$ASAR_PATH "
macOS App Management 保护 :在 /Applications/ 下,cp -p 可能报 “Operation not permitted”。用 cat 重定向可以绕过:
1 cat "$NEW_ASAR " > "$ASAR_PATH "
第七步:重新签名(macOS 专属) 修改 .app 内的任何文件都会使代码签名失效。在 Apple Silicon (M1/M2/M3) 上,未签名的应用无法启动 ,会报 Launchd job spawn failed(POSIX error 163)。
1 2 3 4 5 6 7 8 9 10 APP_PATH="/Applications/YourApp.app" xattr -cr "$APP_PATH " codesign --force --deep --sign - "$APP_PATH " codesign -dv "$APP_PATH " 2>&1
输出应包含 Signature=adhoc,表示临时签名成功。
第八步:启动验证 1 2 3 4 5 open "$APP_PATH " sleep 3pgrep -fl YourApp && echo "启动成功" || echo "启动失败"
完整自动化脚本 将上述步骤封装为一个可复用的 shell 脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 #!/usr/bin/env bash set -euo pipefailAPP_PATH="${1:-/Applications/YourApp.app} " ASAR_PATH="$APP_PATH /Contents/Resources/app.asar" UNPACK_DIR="dist/node-addon" log () { echo -e "\033[0;36m[$1 ]\033[0m $2 " ; }ok () { log "OK" "$1 " ; }err () { log "ERROR" "$1 " ; exit 1; }log "INFO" "关闭应用..." pkill -f "$(basename "$APP_PATH " ) " 2>/dev/null || true sleep 2log "INFO" "备份 asar..." [[ -f "${ASAR_PATH} .bak" ]] || cp "$ASAR_PATH " "${ASAR_PATH} .bak" ok "备份完成" log "INFO" "解包 asar..." WORK_DIR=$(mktemp -d /tmp/asar_work.XXXXXX) EXTRACT_DIR="$WORK_DIR /extracted" npx --yes asar extract "$ASAR_PATH " "$EXTRACT_DIR " 2>&1 ok "解包完成" log "INFO" "重打包 asar (unpack-dir: $UNPACK_DIR )..." NEW_ASAR="$WORK_DIR /app.asar.new" npx --yes asar pack "$EXTRACT_DIR " "$NEW_ASAR " --unpack-dir "$UNPACK_DIR " 2>&1 ok "重打包完成" ORIG_SIZE=$(stat -f%z "$ASAR_PATH " ) NEW_SIZE=$(stat -f%z "$NEW_ASAR " ) log "INFO" "原始: ${ORIG_SIZE} bytes, 新: ${NEW_SIZE} bytes" [[ $NEW_SIZE -gt $((ORIG_SIZE * 3 / 2 )) ]] && err "新 asar 过大,可能 unpack 失败" log "INFO" "替换 asar..." cat "$NEW_ASAR " > "$ASAR_PATH " ok "替换完成" log "INFO" "清除扩展属性并签名..." xattr -cr "$APP_PATH " codesign --force --deep --sign - "$APP_PATH " 2>&1 ok "签名完成" log "INFO" "启动应用..." open "$APP_PATH " sleep 3pgrep -fl "$(basename "$APP_PATH " ) " && ok "应用启动成功" || err "应用启动失败" rm -rf "$WORK_DIR "
还原方法 如果修改后应用无法正常工作,可以从备份还原:
1 2 3 4 5 6 7 8 9 cat "${ASAR_PATH} .bak" > "$ASAR_PATH " xattr -cr "$APP_PATH " codesign --force --deep --sign - "$APP_PATH " open "$APP_PATH "
asar 格式深入理解 文件结构 1 2 3 4 5 6 7 ┌──────────────────────────────────────────┐ │ 4 bytes │ pickle header (size) │ │ 4 bytes │ JSON header size │ │ N bytes │ JSON header (文件树元数据) │ ├──────────────────────────────────────────┤ │ 文件数据区(按 header 中的 offset 定位) │ └──────────────────────────────────────────┘
JSON header 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 { "files" : { "dist" : { "files" : { "node-addon" : { "files" : { "arm64" : { "files" : { "native-module.node" : { "size" : 234832 , "unpacked" : true , "integrity" : { "algorithm" : "SHA256" , "hash" : "..." } } , "main.js" : { "size" : 1024 , "offset" : "0" , "integrity" : { ... } } } } } } } } } }
"unpacked": true 的文件:从 app.asar.unpacked/ 目录读取
有 "offset" 的文件:从 asar 归档内部读取
shouldUnpack 判断逻辑 @electron/asar 源码中的 unpack 判断逻辑(asar.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const shouldUnpackPath = function (filename, relativePath, unpack, unpackDir ) { let shouldUnpack = false ; if (unpack) { shouldUnpack = minimatch (filename, unpack, { matchBase : true }); } if (!shouldUnpack && unpackDir) { shouldUnpack = isUnpackedDir (relativePath, unpackDir, unpackDirs); } return shouldUnpack; }; function isUnpackedDir (dirPath, pattern, unpackDirs ) { if (dirPath.startsWith (pattern) || minimatch (dirPath, pattern)) { unpackDirs.add (dirPath); return true ; } for (const unpackDir of unpackDirs) { if (dirPath.startsWith (unpackDir) && !path.relative (unpackDir, dirPath).startsWith ('..' )) { return true ; } } return false ; }
关键点:
--unpack 用 minimatch 匹配完整文件路径 (matchBase: true 允许只匹配文件名)
--unpack-dir 用 startsWith 匹配相对目录路径 ,子目录自动继承
如果父目录被标记为 unpacked,子文件也会自动 unpack
如何确定 unpack 目录 修改前,先查看原始 asar 中哪些文件被标记为 unpacked:
1 2 3 4 5 6 7 8 9 10 npx --yes asar list /path/to/app.asar --is-pack | grep unpack cd /tmp && npm init -y && npm install @electron/asarnode -e " const asar = require('@electron/asar'); const files = asar.listPackage('/path/to/app.asar', { isPack: true }); files.filter(f => f.startsWith('unpack')).forEach(f => console.log(f)); "
找到 unpacked 文件后,取它们的公共父目录作为 --unpack-dir 参数。例如,如果 unpacked 文件都在 dist/node-addon/arm64/ 下,那么 --unpack-dir "dist/node-addon" 即可。
总结
步骤
命令
注意事项
关闭应用
pkill -f AppName
确保进程完全退出
备份
cp app.asar app.asar.bak
每次只备份一次
解包
npx asar extract app.asar ./extracted
生成普通文件目录
修改
perl -e '...' file old new
用 index+substr,不用正则
重打包
npx asar pack ./extracted app.asar --unpack-dir "dir"
原生模块必须 unpack
替换
cat new.asar > app.asar
macOS 用 cat 绕过保护
签名
xattr -cr && codesign --force --deep --sign -
Apple Silicon 必须
验证
open App.app && pgrep -fl App
检查是否正常启动
核心原则:只替换 asar 文件,不触碰 app.asar.unpacked/ 目录 。原生模块内容未变,保持原样即可。
本文基于 macOS 14 + Apple Silicon 环境实测,适用于所有使用 asar 打包的 Electron 应用。