# macOS 用户界面自动化测试能否绕过代理审批？

只有在由人而不是其他进程触发审批时，审批卡才算真正的安全控制。这听起来显而易见，但当你把审批卡放进普通的 macOS 桌面，就会发现辅助功能客户端可以检查控件，自动化工具可以激活应用，任何前台窗口都可能争夺下一次点击。

不要只问审批卡看起来是否像模态窗口，或者按钮标签是否让人安心。你应该测试：一个已经拥有现实本地权限的进程，能否在目标用户没有经过深思熟虑并充分了解情况的选择时，触发受保护操作。测试的有用结果不是一段刺激的漏洞演示，而是对一个更具体问题的可重复回答：哪些输入路径能够越过审批边界，在什么权限条件下可以越过，以及什么证据能够证明结果？

这是针对你所拥有软件的红队演练，测试应在你控制的 Mac 和账户上进行。请求的操作必须无害，可以使用虚拟 HTTP 端点、非生产 SSH 主机，或返回固定测试值的操作。如果一项安全测试可能意外花钱、删除数据或改变生产环境权限，那么在任何人写脚本之前，它的范围就已经划定得不合格。

## 辅助功能权限会改变攻击者模型

拥有辅助功能权限的进程不是普通后台进程。Apple 将辅助功能访问描述为允许应用控制 Mac，macOS 也要求用户在“隐私与安全性”设置中授予这项权限。Apple 另外将自动化权限描述为访问和控制其他应用的权限。这两种权限不同，但只要审批卡出现在桌面上，就都应纳入威胁模型。

团队经常混淆的一点是：审批界面可以被辅助功能访问，并不意味着所有来自辅助功能的操作都能证明是人的意图。辅助功能必须公开有意义的标签、角色、状态变化和焦点，让辅助技术用户能够操作应用，这是包容性要求。但这不表示应用必须允许外部进程在没有任何本地用户信号的情况下调用不可逆的确认操作。

先写清楚你到底要模拟哪种攻击者。不要笼统地称其为“恶意软件”，因为这会掩盖决定结果的权限。一个有用的基线攻击者，是同一登录用户下运行、经过签名、已经获得辅助功能权限的本地辅助进程。更强的变体还拥有自动化权限，并能请求激活应用。两种变体都没有管理员凭据、绕过屏幕解锁的能力，也没有被篡改的审批应用副本。

这样的边界能让测试保持公平。如果结论是“完全控制用户账户的攻击者能够控制用户账户”，你什么也没学到。如果结论是“一个拥有用户可能授予窗口管理器、测试运行器、宏工具或辅助工具的相同权限的辅助进程，能够批准凭据使用”，那你就找到了一个设计问题，并且有清晰的修复方向。

在第一次运行前先建立能力清单：

| 能力 | 测试状态 | 重要性 |
| --- | --- | --- |
| 辅助功能权限 | 启用或禁用 | 决定辅助进程能否发现并调用公开的界面元素。 |
| 自动化权限 | 启用或禁用 | 决定它能否请求 macOS 控制另一个应用。 |
| 输入监控 | 启用或禁用 | 帮助你区分观察用户输入和生成界面操作。 |
| 屏幕录制 | 启用或禁用 | 可用于取证，但不要把截图当成授权。 |
| 同一用户会话 | 必须具备 | 让测试集中于现实的桌面干扰。 |

不要因为测试工具提出请求就授予权限。每增加一项权限，都会改变你之后能够作出的结论。把确切组合写进结果标题，例如“仅有辅助功能权限的辅助进程无法确认按次审批”，而不是“审批是安全的”。后一种说法无法经受不同桌面配置的检验。

同时记录辅助进程的 bundle identifier、代码签名主体、进程 ID、父进程和启动路径。好的审批系统应该向用户显示足够的进程身份信息，让人能够判断请求；但测试记录需要比审批卡能合理展示的内容更详细。测试人员需要回答：子进程是否继承了看似可信的父进程，终端包装进程是否启动了辅助进程，以及重新启动是否创建了新的进程身份。

## 可点击的按钮不等于审批设计薄弱

通过 macOS 辅助功能层级公开的按钮，并不自动构成漏洞。AppKit 要求辅助功能元素参与这个层级，让辅助客户端能够找到功能控件。Apple 也说明，控件会公开标签、标题、位置以及是否启用等属性。

薄弱的设计在于：调用这个公开按钮就足以证明敏感操作已经获得授权。这样的设计会让合成的辅助功能操作和有意识的点击无法区分。它还经常悄无声息地失败，因为团队只用鼠标测试，看到了预期回调，却从未问过还有什么方式能够触发同一个回调。

让审批决定依赖本地授权事件，而不只是按钮的操作处理程序。界面控件可以发起请求，但受保护操作应该等待一个授权结果，并记录确认是如何发生的。对于普通会话审批，这可能是用户在应用确认自己的审批界面处于活动且有效状态后进行的明确点击。对于高风险操作，则应要求系统介导的本地认证事件，例如在可用时使用 Touch ID。

不要犯一个常见错误：把“批准”按钮从辅助功能树中隐藏或改名。这会惩罚需要辅助技术的用户，却很难阻挡有能力的本地攻击者，因为它仍可能操纵焦点或调用其他路径。相反，应测试确认流程是否拒绝合成路径、过期卡片、非活动卡片，以及显示的详情已经不再匹配即将执行的操作的请求。

有用的内部决定记录可以是这样：

```json
{
  "request_id": "test-4d8f",
  "decision": "approved",
  "decision_method": "touch_id",
  "card_generation": 7,
  "request_generation": 7,
  "app_active": true,
  "card_frontmost": true,
  "requester_pid": 48102,
  "requester_signing_authority": "Example Development Team",
  "action_started_after_decision": true
}
```

字段名称没有分离本身重要。`decision` 表示发生了什么，`decision_method` 表示它是如何发生的。generation 值可以防止重绘、重试或替换操作后，旧卡片批准新的请求。激活字段记录决定发生时用户是否正在查看你的卡片。最后一个字段可以让测试验证操作顺序。

这种分离还能发现一个隐蔽的实现错误：先接受审批，之后才把卡片显示出来作为提示。在这个漏洞中，所有视觉测试都会通过。受保护操作已经开始，卡片只是在演戏，而不是提供控制。让操作执行器要求一个决定令牌，只有审批子系统完成相关检查后才能创建这个令牌。

## 测试装置应该产生无害且可比较的运行结果

建立一个能够创建可预测审批请求的测试夹具，以及一个收集证据的观察器。不要一开始就把通用桌面自动化框架指向真实凭据。测试装置应该减少歧义，而不是增加歧义。

夹具需要一个具有稳定标识符的请求、每次运行都会变化的可见详情、较短的过期时间，以及无害的完成效果。例如，让受保护操作调用本地端点，只有在审批系统放行后才返回 `approved:test-4d8f`。如果操作在没有有效审批记录时成功，你就得到了明确的失败；如果它超时或记录拒绝，你就得到了明确的未批准结果。

在卡片和受保护请求中都使用短期 nonce。只写“允许代理访问？”的审批卡无法暴露过期窗口漏洞；写成“允许测试请求 4d8f 调用 staging echo service？”则可以。每次运行都更换 nonce，然后断言操作结果包含相同 nonce。

在证据中保留三个相互独立的时间：

1. 请求创建时间。
2. 决定时间。
3. 受保护操作开始时间。

尽可能在应用内部使用单调时间。墙上时钟适合人阅读时间线，但可能因网络同步、睡眠或手动修改而跳变。排序规则很简单：操作必须在同一请求 generation 获得有效决定之后开始。截图不能证明这一顺序。

观察器应该记录活动应用、焦点窗口、审批卡上可见的请求 ID，以及最终的操作结果。屏幕录制有助于诊断意外行为，但不能代替结构化事件。屏幕录制会丢失进程标识符，压缩快速变化，而且常常把关键事件隐藏在指针移动后面。

每次尝试都可以使用这样的测试记录：

```text
case=AX-invoke-approve
request=test-4d8f
helper=TestHelper pid=48102
permissions=accessibility
approval_surface=active
attempt=accessibility_action
decision=denied
protected_action=not_started
artifact=activity-event-0192
```

`attempt` 应描述机制，而不是期望结果。使用 `activate-then-click`、`stale-window`、`keyboard-focus-shift` 或 `voiceover-navigation`，不要写 `attack-1`。六周后，这种直白的描述能避免有人重新运行错误的用例。

先在没有授予辅助进程权限的情况下运行同一个夹具，这就是控制组。然后一次只启用一种能力。没有控制组的红队结果很难解释每次失败。也许你的卡片根本无法通过键盘访问，也许端点失败了，也许测试辅助进程缺少你以为它拥有的授权。控制组会把这些推测变成可见差异。

## 测试合成审批，但不要把测试变成漏洞工具包

这个用例的目的，是判断拥有辅助功能权限的客户端能否调用审批控件并让受保护操作运行。你不需要面向公众发布的点击器，也不需要一堆脆弱的坐标脚本。

使用自己的测试辅助进程，并把任务限制在狭窄范围内：根据应用身份和仅用于测试的请求 nonce 找到测试应用的审批窗口，请求辅助功能客户端会请求的同一个语义操作，然后报告受保护操作是否完成。不要搜索桌面上的所有窗口，不要针对第三方对话框，不要保存凭据，也不要操作真实服务账户。

分别测试以下路径，因为它们证明的是不同问题：

- 对“批准”控件进行语义辅助功能调用。
- 通过键盘导航停在“批准”上，再进行合成激活。
- 辅助进程将审批应用置于前台后执行指针操作。
- 辅助进程没有将审批应用置于前台后执行指针操作。
- 卡片过期或请求变化后执行延迟操作。

只有在测试视觉重叠的回归问题时，坐标点击才值得保留。它能告诉你布局变化后，屏幕某个位置的点击是否会落到错误控件上，却不能告诉你外部进程是否能够识别并激活目标元素。语义界面自动化更适合测试授权逻辑；坐标操作更适合测试布局和焦点事故。

预计不同审批模式会得到不同结果。会话级审批通常用于在某个代理进程的整个生命周期内授权已识别的进程。如果会话审批接受普通点击，那么拥有辅助功能权限的辅助进程可能会模仿它，除非应用区分输入路径，或将审批绑定到更强的本地事件。这对低风险操作可能是可以接受的取舍，但应该公开说明。

按次审批应经受更严格的测试。如果操作使用的凭据能够改变数据、发布版本或访问生产主机，单独按下按钮是不够的。应要求 Touch ID 或其他系统介导的本地认证信号。此时测试问题就变成：辅助进程能否让提示出现，调用它周围所有可访问的控件，却仍无法在用户认证前完成受保护操作？这是更清晰的安全属性。

不要因为按钮没有响应就简单报告“已阻止”。应完整报告整个链条：辅助进程是否找到了卡片，是否调用了控件，应用是否记录了决定，授权子系统是否发出了令牌，以及操作执行器是否开始运行。辅助进程今天可能无法按下按钮，明天却可能通过另一条键盘路径成功。决定令牌才是关键边界。

## 焦点窃取可能把一次真实点击变成错误决定

直接脚本点击之所以受到关注，是因为它看起来恶意。焦点窃取往往是更现实的失败方式，因为它可能把一次真实的用户操作变成对另一项请求的批准。

Apple 的 AppKit 文档明确说明，应用激活是一种请求而非保证，而且当 macOS 管理桌面时，应用的激活状态可能发生变化。Apple 还提供了带有激活行为的启动或打开应用 API。这意味着审批代码必须把前台状态视为需要观察和验证的事件，而不是绘制卡片时假定的永久事实。

有意识地测试这个竞态。显示一张带有唯一 nonce 的无害审批卡。把指针移到卡片的确认控件上但不要点击。触发你控制的辅助进程去激活第二个测试应用或显示一个无害的测试窗口。然后在重复运行中执行预定点击。记录鼠标按下和抬起时哪个应用处于活动状态、哪个窗口拥有焦点元素，以及受保护操作是否开始。

不要把“卡片仍然可见”当作通过。可见卡片可能并不处于活动状态，可能位于另一个窗口后面，控件也可能仍在绘制但键盘焦点已经移到别处。审批路径应重新检查当前请求是否仍拥有显示中的卡片，并在应用实际消费审批时确认应用处于活动状态。

可靠的焦点窃取测试集应包括：

| 用例 | 干扰方式 | 预期结果 |
| --- | --- | --- |
| 点击前激活 | 辅助进程在鼠标按下前激活另一个应用 | 不得批准；卡片必须要求新的明确交互。 |
| 按下与抬起之间激活 | 点击手势期间焦点发生变化 | 不得批准；记录焦点转换。 |
| 覆盖窗口 | 受控窗口遮住卡片的一部分 | 除非用户重新与审批界面交互，否则不得执行操作。 |
| 请求替换 | 第一张卡片仍显示时收到新请求 | 旧卡片不能批准新请求。 |
| 应用失活 | 用户在确认前切换应用 | 卡片过期、撤回，或要求新的确认。 |

“要求新的交互”很重要。短暂中断后自动恢复焦点并接受原来的点击很危险。用户可能以为点击只是关闭了中断，而应用却把它当成批准。撤回待定决定，或在界面上明确重置。等应用重新处于明确活动状态后，让用户发起新的确认。

不要用强行把应用推到前台来解决问题。这样会产生另一种问题：意外的前台行为会训练用户直接点击过去。请求到达时卡片应该清楚地出现，但不应和桌面争夺焦点。如果在错误时机失去激活状态，可以安全失败并再次请求。

## 过期卡片和请求替换需要单独的攻击用例

卡片可能抵抗直接的合成点击，却仍然批准错误的操作。这通常发生在界面代表一个请求，而后端已经转向另一个请求时。

常见情况是重试漏洞。代理请求一个 HTTP 调用，卡片出现。代理断开后重新连接，并提交带有略微不同请求头的第二个请求。界面因为看起来相似而复用已有卡片。用户以为自己批准的是请求 A，点击却释放了请求 B。身份文本可以完全正确，审批仍然可能错误。

把审批绑定到不可变的请求摘要。摘要应包含操作类型、目标地址、凭据身份或别名、相关目标详情、代理进程身份和 nonce。任何受保护字段发生变化时，都应使当前卡片失效。不要只更新标签并保留一个已经准备好的“批准”控件。更新请求 generation，并让上一个界面实例无法完成任何操作。

测试夹具可以快速连续提交两个请求：

```text
A: POST staging.example.invalid/echo
   header X-Test-Nonce: alpha-4d8f

B: POST staging.example.invalid/echo
   header X-Test-Nonce: bravo-7a21
```

让请求 A 的卡片保持可见足够长的时间，使测试人员能够识别它。然后通过同一个代理进程提交 B。分别尝试鼠标、键盘、辅助功能操作，以及焦点变化后的确认。唯一可接受的结果是：A 使用 `alpha-4d8f` 完成，B 在经过自己新的审批后使用 `bravo-7a21` 完成，或两者都不完成。如果 A 的可见卡片释放了 B，就应将其视为阻止发布的授权缺陷。

对看似无害、但可能被匆忙用户忽略的目标变化也做同样测试。SSH 操作可能保留命令却更换主机；HTTP 操作可能保留主机却更换路径或注入的凭据。卡片不必打印请求的每个字节，但必须显示足以区分安全决定的详情。后端必须绑定完整的规范化请求，而不能只绑定你选择展示的部分。

这里也需要测试过期。不要只为视觉效果倒计时。请求过期后，应撤销该请求 generation 的后端授权路径。然后测试恰好在过期边界触发的辅助功能操作，以及过期后才到达的鼠标抬起。预期结果是拒绝，并说明究竟是过期、失去焦点、generation 不匹配，还是授权失败阻止了操作。

## 屏幕录制结束后，日志必须能够解决争议

当唯一证据是“我点击了，而且成功了”，审批测试就会变成争论。你需要让另一名工程师无需相信测试人员的解释，也能重建因果链的记录。

记录两条相关流：代理运行和单次受保护调用。运行记录回答是谁发起了工作，以及这次运行后来是否被撤销。调用记录回答具体请求了什么操作、审批如何解决，以及执行是否开始。让审批事件和请求创建、焦点转换、授权结果、执行事件处于同一条有序审计记录中。

Sallyport 为代理运行保留 Sessions 日志，为单次调用保留 Activity 日志；两者都从加密、哈希链式审计日志投影而来。其 `sp audit verify` 命令可以在离线状态下对密文验证哈希链，无需保险库密钥。这样，测试团队就能检查证据是否完整，而不是依赖某个测试结果出来后可以被悄悄修改的可变导出文件。

对于任何红队运行，都应一起保存或导出这些事实：

- 测试用例名称和权限矩阵。
- 请求进程身份及其代码签名主体。
- 请求摘要、可见 nonce 和请求 generation。
- 决定方式、决定结果，以及被拒绝时的原因。
- 第一个执行事件，或明确证明不存在执行事件。

哈希链不能证明审批策略正确。它证明的是一个更窄但仍然有用的事实：日志写入后，调查人员可以检查记录是否被修改或重新排序。策略测试则通过展示应用允许哪些事件到达执行阶段，补上另一半证据。

把日志审查纳入测试通过标准。一个正确阻止操作、却只记录“已取消”的运行，还没有完成。你需要区分合成辅助功能调用和用户取消，区分过期 generation 和过期请求，也要区分因失去焦点而拒绝和应用崩溃。这些区别能把一份缺陷报告转化为一组回归测试。

## 通过的标准是操作始终留在人为决定之后

不要因为第一次自动化脚本失败，就声称审批界面安全。一次好的红队测试结果应有明确且有限的范围：在受控 Mac 上，按照指定权限运行时，每一种经过测试的合成激活和焦点干扰路径，都未能生成有效授权令牌，也未能启动受保护操作。

最有价值的测试会留下一个令人不安、却很有用的例外清单。也许会话审批允许普通点击，因为风险较低，而且用户已经批准了代理进程。也许按次审批对选定密钥要求 Touch ID。也许应用一旦失去激活就取消卡片，这会给少数用户带来不便，却能阻止焦点竞态。把这些决定落实到产品行为中，并对它们进行测试。只存在于设计会议中的安全性，无法经受充满辅助进程的桌面。

每当你修改审批渲染、增加新的操作渠道、改变进程身份处理，或重构从界面决定过渡到执行的代码时，都要运行这套测试。真正重要的回归不会以“辅助功能绕过”的名字出现，它可能只是一次无害的清理，移动了回调、复用了窗口，或把旧请求当成了新请求。

最后的断言应该直截了当：只有当前请求获得所需的本地授权后，受保护操作才能开始。如果任何界面自动化路径都能让这句话失效，那么在修复之前，审批卡就只是装饰。
