...
Back

公证通过不等于能打开

一个 Mac 应用可以通过公证、通过 codesign 校验、被 Gatekeeper 接受,然后在你双击的瞬间被内核杀掉。本文讲清原因,并给出一个三分钟就能跑完的判别方法。

公证通过不等于能打开

公证通过不等于能打开

我们发布过一个已公证的 Mac 磁盘映像,它根本启动不了。

不是"启动后崩溃",也不是"弹了个错误"。内核直接拒绝执行它,在我们的代码跑起来之前就把进程杀了,应用自己的日志里什么都没有 —— 因为它从来没活到能写日志的那一刻。

而我们所有的检查都是绿的:

检查项结果
codesign --verify --deep --strict通过
spctl -a -t execaccepted
Apple 公证服务Accepted
stapler validate票据有效

这个包用有效的 Developer ID 证书签名,加固运行时已开,带安全时间戳。它在我们的下载页上挂了六个月,一次也没能打开过。


内核到底在抱怨什么

唯一能看到失败的地方是系统日志:

AMFI: When validating /Volumes/…/Contents/MacOS/YourApp:
mac_vnode_check_signature: code signature validation failed fatally
proc NNNNN: load code signature error 4 for file "YourApp"
AMFI: hook..execve() killing xpcproxy (pid NNNNN):
      Attempt to execute completely unsigned code (must be at least ad-hoc signed).

"完全未签名的代码"这句话是错的,或者说是一句极不友好的概括。二进制确实签了名。真正发生的是:应用申请了一个受限 entitlement,而没有任何东西授权它拥有这个权限。

大多数 entitlement 只是一种声明。少数是受限的:内核只在应用内嵌的描述文件明确授予时才认可它们。如果你申请了却没有背书,AMFI 不会帮你剥掉这个权限,也不会给警告,而是直接拒绝执行整个二进制。

我们这个案例里,权限文件带着:

<key>com.apple.developer.networking.networkextension</key>
<array>
  <string>packet-tunnel-provider</string>
</array>

它是从 App Store 版继承来的 —— 在那边确实有描述文件授予它。而 Developer ID 版会刻意删掉内嵌描述文件,因为直接分发的应用本来不需要,除非它申请了受限权限,而这个包正好申请了。


为什么常规检查一个都拦不住

这部分值得记住,因为它并不直观:

  • codesign --verify 不检查权限授权。 它验证签名完整、封印吻合。至于签名者是否被允许申请某个 entitlement,那是运行时策略问题,而 codesign 不是运行时。
  • 公证不会运行你的应用。 它扫描恶意代码,检查二进制是否用 Developer ID 证书签名、是否开了加固运行时、是否带安全时间戳。这些都是打包属性。Apple 的公证服务不会为了看 AMFI 会不会放行而去启动你的应用。
  • spctl 回答的是另一个问题。 它告诉你 Gatekeeper 是否允许启动。对于一个内核随后会拒绝加载的二进制,它照样会回答 accepted,因为这是两道独立的关卡,而此时只跑了其中一道。

于是整条工具链一致认为产物没问题,而产物根本打不开。流水线里任何一环都不会报警。


一个三分钟就能跑完的判别方法

你不需要真正的应用就能判断某个 entitlement 是不是受限的。用它单独签一个极简二进制,然后跑一下。

cat > probe.c <<'EOF'
#include <stdio.h>
int main(void){ printf("PROBE_RUNNING\n"); return 0; }
EOF
clang -o probe probe.c
 
cat > ent.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.developer.networking.networkextension</key>
  <array><string>packet-tunnel-provider-systemextension</string></array>
</dict>
</plist>
EOF
 
codesign --force --options runtime \
  --sign "Developer ID Application: Your Team (TEAMID)" \
  --entitlements ent.plist probe
 
./probe

两种结果,含义毫不含糊:

  • 打印 PROBE_RUNNING —— 该权限不受限,可以在没有描述文件的情况下申请。
  • 只有 Killed: 9 —— 受限,必须由内嵌的描述文件授予,否则应用起不来。

先用一个不带任何特殊权限的签名跑一次作对照,确认探针本身能启动。然后逐个测你拿不准的权限。

我们正是用这个方法确认了 com.apple.developer.networking.networkextensioncom.apple.developer.system-extension.install 两者都受限,而 com.apple.security.cs.* 那组加固运行时豁免不受限。


签名层面的指纹

还有第二个迹象,适合在手上已有构建、又不想直接运行它的时候用。让 codesign 针对某个具体架构报告:

codesign -dv --arch arm64 /path/to/YourApp.app/Contents/MacOS/YourApp

健康的通用二进制会打印出完整的授权链。而那个坏掉的包,这条命令一行 Authority 都不输出,可同样的命令不带 --arch 时却能打印出一条完好的链。这种不对称就是指纹。如果胖文件看着已签名而没有任何一个切片是,那你面对的就是一个加载器会拒绝的二进制。


我们改了什么

两件事,而只有第二件值得说。

直接的修法是不再申请我们无法背书的权限。这个应用把隧道跑在进程内,压根没有 NetworkExtension target;那条权限是多年前从同级 target 抄来的残留,因为从没有大声失败过,也就从没有人质疑。删掉它,应用就能启动了。

结构性的修法是不再相信流水线的沉默。一个签了名、公证了、装订了票据、却一次都没被执行过的版本,不算测试过的版本。我们的打包脚本现在会挂载成品映像,并从里面把应用启动一次,产物才被允许接近下载页。这一步花十秒钟,而它是整条链路上唯一能拦下这个问题的检查。

如果这篇只记住一句:在 App Store 之外分发的 Mac 应用,只要申请了 com.apple.developer.* 下的任何权限,在探针证明之前都当它是受限的。判断错的代价,是一个通过了全部自动化关卡、却对谁都不能用的产物。