这是一个集原理讲解、场景复现和解决方案于一体的完整指南。通过本项目提供的工具,你可以亲手复现 macOS Gatekeeper 的拦截行为,并彻底理解 “应用已损坏” (App Damaged) 背后的技术真相。
1. 现象描述
在 macOS 上安装或启动应用时,可能会遇到以下提示:
“xxx.app 已损坏,无法打开。你应该将它移到废纸篓。” (English: "xxx.app is damaged and can't be opened. You should move it to the Trash.")
核心事实: 大多数情况下,文件并没有物理损坏。这通常是 macOS 的 Gatekeeper 安全机制在发现应用“来路不明”(如带有隔离属性且无有效签名)时,为了安全起见直接拒绝运行的通用错误提示。
2. 核心原理:两道防线
macOS 的应用启动检查可以看作是两道防线:
-
第一道防线:内核 (Kernel) —— 强制代码签名
- 机制:在 M1/M2/M3 (Apple Silicon) 芯片上,所有可执行代码必须拥有签名(哪怕是 Ad-hoc 签名)。
- 拦截表现:如果完全无签名,内核直接拒绝加载,进程崩溃(
Killed)。 - 豁免:本地编译生成的文件会有临时的 AMFI 豁免,但一旦文件离开本机(异地),豁免失效,必须有签名才能活。
-
第二道防线:Gatekeeper —— 信任策略检查
- 触发开关:隔离属性 (Quarantine)。当文件来自非本地来源(如下载、AirDrop)时,系统会自动添加此属性。一旦检测到此属性,Gatekeeper 即会介入。
- 检查内容:
- 是不是正经开发者? (是否有 Apple Developer ID)。
- 有没有恶意软件? (是否经过公证 Notarized)。
- 文件改没改过? (哈希校验)。
- 拦截表现:提示“无法验证开发者”或“应用已损坏”。
总结:
- 无签名 -> 死在第一道防线(内核)。
- Ad-hoc + 隔离 -> 死在第二道防线(Gatekeeper)。
- 文件篡改 -> 死在哈希校验(Gatekeeper)。
3. 验证实验室:亲自复现与验证
为了验证上述原理,我们编写了一个简单的验证工具包。这里包含了 6 个核心实验,覆盖了 macOS 下应用运行的所有关键场景。
实验概览表
| ID | 场景描述 | 状态配置 | 预期结果 (Apple Silicon) | 核心原理 |
|---|---|---|---|---|
| 1 | 本地无签名 | 无签名 + 无隔离 | ✅ 运行成功 | 本地编译豁免 / 内核信任 |
| 2 | 模拟异地无签名 | 无签名 + 无隔离 | ❌ 拒绝 (Killed) | 必死。一旦离机 AMFI 豁免失效,内核强制要求签名 |
| 3 | 模拟异地 Ad-hoc | Ad-hoc + 无隔离 | ✅ 运行成功 | 唯一活路。Ad-hoc 满足内核,无隔离绕过 Gatekeeper |
| 4 | 模拟异地 Ad-hoc | Ad-hoc + 有隔离 | ⚠️ 拦截/损坏 | 签名未被信任 (无 Developer ID) |
| 5 | 自动隔离机制 | 浏览器下载 | ℹ️ 属性自动添加 | Launch Services 自动标记下载文件 |
| 6 | 签名完整性验证 | Linker vs Codesign | ⚠️ 结果迥异 | 资源密封 (Sealed Resources) 决定是硬拦截还是软拦截 |
项目结构
📦 完整项目代码:macos-gatekeeper-guide
macos-gatekeeper-guide/
├── build_test_app.sh # 构建脚本(支持多种签名模式)
├── src/
│ └── main.swift # 测试应用源码(SwiftUI)
└── build/ # 构建产物目录(自动生成)
└── *.app # 生成的测试应用
核心文件说明:
build_test_app.sh:一键构建工具,支持--unsigned、--linker-signed、--name等参数src/main.swift:极简 SwiftUI 应用,用于验证签名和 Gatekeeper 行为
使用前提:确保在项目根目录下执行所有命令。
实验 1:本地无签名(基准对照)
验证 macOS 对“本机生产”文件的信任机制。
- 构建无签名应用:
(说明:此处的./build_test_app.sh --unsigned --name "App_Exp1"--unsigned会显式移除所有签名,包括 Linker-Signed(编辑器自动添加的签名)。) - 运行:双击
build/App_Exp1.app。
结果:✅ 成功打开。
原理:本机编译的文件享有 AMFI 临时豁免,即使完全无签名也能运行。
实验 2:异地无签名 + 移除隔离(模拟尝试绕过)
验证如果用户手动移除了隔离属性,完全无签名的应用在异地是否能运行。
- 构建无签名应用:
./build_test_app.sh --unsigned --name "App_Exp2" - 通过 AirDrop 传输到另一台 Mac。
- 在接收机器上移除隔离属性:
xattr -d com.apple.quarantine /path/to/App_Exp2.app - 运行:双击
App_Exp2.app。
结果:❌ 无法打开 / 闪退。
- 注意:此时在“隐私与安全性”中不会出现“仍要打开”的按钮。
- 原理:因为隔离属性已被移除,Gatekeeper 没有介入,是内核直接查杀了进程。
实验 3:异地完整 Ad-hoc + 无隔离(成功绕过方案)
验证 Ad-hoc 签名在移除隔离属性后的行为。
- 构建完整 Ad-hoc 应用:
./build_test_app.sh --name "App_Exp3" - 通过 AirDrop 传输到另一台 Mac。
- 在接收机器上移除隔离属性:
xattr -d com.apple.quarantine /path/to/App_Exp3.app - 运行:双击
App_Exp3.app。
结果:✅ 成功打开。
结论:完整 Ad-hoc + 移除隔离 = 可行。 内核只要求“有签名”,Gatekeeper 只要求“有隔离才查”。只要移除了隔离,Gatekeeper 不上班,内核看到有签名就放行。
实验 4:异地 Ad-hoc + 有隔离(标准拦截)
这是最真实的默认场景:你写了个 App(未购买证书),直接分发给他人使用。
- 构建:
./build_test_app.sh --name "App_Exp4" - 通过 AirDrop 传输到另一台 Mac(自动添加隔离属性)。
- 运行:双击
build/App_Exp4.app。
结果:⚠️ 被拦截 / 提示无法验证开发者。
- 关键点:由于我们的构建脚本使用了规范的
codesign,签名结构完整。因此,在“隐私与安全性”设置中,会出现“仍要打开”按钮,允许用户手动放行。
点击仍要打开,打开成功
实验 5:自动隔离机制演示
验证到底什么操作会给文件贴上“隔离”标签。
验证步骤
-
使用 curl 下载(纯命令行):
curl -o google.png https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png xattr -l google.png # 结果:(无输出,表示无属性) -
使用 Safari/Chrome 下载同一张图:
xattr -l ~/Downloads/googlelogo_color_272x92dp.png # 输出示例: # com.apple.quarantine: 0081;693a85a3;Chrome;632447E8-28E3-443B-B2D8-FA423C6B2384 -
使用 AirDrop 接收文件。
xattr -l ~/Downloads/googlelogo_color_272x92dp.png # 输出示例: # com.apple.quarantine: 0081;655...;sharingd;... -
微信传输:
xattr -l ~/Downloads/googlelogo_color_272x92dp.png # 输出示例: # com.apple.quarantine:0082;693a8446;WeChat;
原理:
- 隔离属性由 macOS 的 Launch Services 框架自动添加
- 当应用(如浏览器、AirDrop、微信)通过特定 API 下载或接收文件时,会触发 Launch Services 标记
- 命令行工具(curl、wget)直接写入文件系统,不经过这些 API,因此不会触发标记
- 隔离属性包含来源信息(下载工具名称、时间戳、URL 等),用于追溯文件来源
实验 6:签名完整性实验(解密“仍要打开”消失之谜)
能够解释为什么同样的 Ad-hoc 签名,有的能点“仍要打开”,有的直接报“已损坏”。
-
第一阶段:构建“残血版” (硬拦截) 使用
--linker-signed参数,构建一个仅包含 Linker 签名但没有资源密封(Sealed Resources)的应用。./build_test_app.sh --linker-signed --name "App_Exp6_Bad" # 手动添加隔离属性(模拟下载) xattr -w com.apple.quarantine "0081;632447E8;Chrome;" build/App_Exp6_Bad.app结果:双击运行 -> ❌ “应用已损坏” (无按钮)。
-
第二阶段:修复为“满血版” (软拦截) 对同一个 App 进行规范签名。
codesign --code --force --deep --sign - --verbose=4 build/App_Exp6_Bad.app结果:双击运行 -> ⚠️ “无法验证开发者” -> 设置中出现 【仍要打开】 按钮。
点击 OK(确定),弹出 Open Anyway(仍要打开)
点击仍要打开,打开成功
结论:签名结构完整性决定了是“硬拦截(已损坏)”还是“软拦截(无法验证)”。
4. 深度辨析:硬拦截 vs 软拦截
通过实验 6,我们知道了签名结构的重要性。那么如何查看应用的签名状态呢?
查看签名信息
使用 codesign 命令可以查看应用的详细签名信息:
# 查看签名详情
codesign -dvvv /path/to/Your.app
# 关键输出示例:
# Linker-Signed(残缺):
# Sealed Resources=none
#
# 完整 Ad-hoc:
# Sealed Resources=version 2 rule count=13 nested=0
硬拦截 vs 软拦截对比
| 拦截类型 | 签名状态 | 典型特征 (codesign -dvvv) | 用户界面 | 原因说明 |
|---|---|---|---|---|
| 🔴 硬拦截 | Linker-Signed (编译器自动签) | Sealed Resources=none | ❌ 已损坏 (无按钮) | 签名结构残缺,Gatekeeper 认为包损坏 |
| 🟡 软拦截 | 完整 Ad-hoc (手动 codesign) | Sealed Resources=version 2... | ⚠️ 无法验证 (有按钮) | 签名完整但无开发者身份,允许手动放行 |
| 🟢 通过 | 正式证书 | Authority=Apple Developer... | ✅ 直接打开 | 有效的开发者签名,正常流程 |
如何确诊? 使用日志查看确切错误:
log stream --predicate 'process == "syspolicyd"' --info
- 硬拦截 (-67062):签名结构无法满足要求(如 Linker-Signed 加了隔离)。
- 软拦截: 系统尝试获取授权,允许用户手动批准。
5. 用户端解决方案(按推荐程度排序)
✅ 方案 A:终端命令解除隔离
这是根治“已损坏”且适用性最广的方法(前提:App 有起码的签名)。
# 递归删除隔离属性
xattr -r -d com.apple.quarantine /path/to/Your.app
⚠️ 方案 B:“仍要打开”
- 适用:签名完整但无 Developer ID 的应用。
- 操作:系统设置 -> 隐私与安全性 -> 点击【仍要打开】。
🚫 方案 C:开启“任何来源”
sudo spctl --master-disable
(这无法解决内核级的签名缺失问题,不推荐)
6. 开发者根治方案
如果您是开发者,想要分发应用且确保用户100% 不遇到此问题:
🔑 唯一正道:签名 + 公证
- 购买:Apple Developer Program ($99/年)。
- 签名:使用
Developer ID Application证书签名。 - 公证 (Notarize):提交给 Apple 服务器进行恶意软件扫描,并获取 Ticket。
📦 分发建议
- 推荐 DMG:虽然也会带有 Quarantine,但由于是只读镜像,它能确保文件权限和结构绝对完整。只要用户通过上述方案绕过 Quarantine,App 是一定能跑的。
- 慎用 Zip:如果用户解压工具不当,可能会丢失可执行权限或破坏 Bundle,导致“真·损坏”。