# 如何在启动前验证 SSH 辅助程序

本地网关应把 SSH 辅助程序视为特权代码，即使它没有保险库，也不保存状态。辅助程序会接收命令、继承文件描述符和环境，并可能成为真正与远程主机通信的进程。如果攻击者能替换这个可执行文件，上层做出的所有批准都会失去意义。

安全设计要完成两个不同的任务。预检在请求到达运行器之前发现包损坏或安装错误。启动时代码要求则让 macOS 在实际进程映像的签名身份不符时拒绝执行。混淆这两个任务，就会留下一个常见缺口：应用验证了一个文件，却执行了另一个文件。

## 从正在运行的应用包推导辅助程序路径

辅助程序路径必须来自当前实际运行的应用包，绝不能来自 `PATH`、当前目录、偏好设置或代理提供的路径。预期位置应是构建时常量。网关只要开始搜索 `sp-ssh`，就已经把选择可执行代码的权力交给了环境。

Apple 文档列出了嵌套代码的标准包位置，包括 `Contents/MacOS` 和 `Contents/Helpers`。只选一个位置，只在那里放代码，并在辅助程序落到其他位置时让构建失败。先签名辅助程序，最后签名外层应用。这样外层签名才能封存嵌套代码引用。

Foundation 可以定位辅助可执行文件，但安全判断仍要验证它与主包的精确关系。解析符号链接并标准化两个 URL，然后比较路径组成部分，而不是字符串前缀。像 `candidate.path.hasPrefix(bundle.path)` 这样的测试会接受 `/Applications/Good.app.backup` 一类相邻路径，在大小写或规范化方面也可能出错。候选路径必须与运行中包内唯一的预期 URL 完全相同。

不要把复制到应用旁边的辅助程序作为后备方案。开发时这种后备很诱人，因为它能掩盖打包错误。到了生产环境，它会悄悄把信任边界从已签名的包内容改成附近路径上碰巧存在的文件。开发构建应使用明确的构建配置，打包错误时直接失败。

位置只能说明打包情况，不能证明身份。能替换可写包内文件的攻击者可以保留原路径。所以下一步必须检查已打开对象及其签名。

## 先打开文件，再检查其身份

用 `O_NOFOLLOW` 打开候选文件，保持描述符不关闭，并对该描述符调用 `fstat`。顺序很重要。先调用 `lstat`、检查结果、再调用 `open`，会给另一个进程机会在两次操作之间替换目录项。

Apple 的 Secure Coding Guide 因此建议使用基于描述符的操作。它明确要求在打开后检查类型、UID、GID、模式和链接数。对于可执行辅助程序，我会使用如下预检：

```c
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>

int inspect_helper(const char *path, uid_t expected_uid, struct stat *snapshot) {
    int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
    if (fd < 0) return -1;

    struct stat st;
    if (fstat(fd, &st) != 0 ||
        !S_ISREG(st.st_mode) ||
        st.st_uid != expected_uid ||
        (st.st_mode & (S_IWGRP | S_IWOTH)) != 0 ||
        (st.st_mode & S_IXUSR) == 0 ||
        st.st_nlink != 1) {
        int saved = errno ? errno : EPERM;
        close(fd);
        errno = saved;
        return -1;
    }

    *snapshot = st;
    return fd;
}
```

`expected_uid` 应由安装模式决定，而不是从文件本身读取。系统管理的安装可能要求 root 所有权，按用户安装的应用则可能合理地属于该用户。不要只因 root 看起来可信就强制要求 UID 0。Apple 还提醒，路径可能跨到另一个挂载文件系统，此时仅靠所有权提供的信息比开发者通常以为的更少。

链接数规则必须有明确决策。普通的包内可执行文件通常只有一个硬链接，因此拒绝其他数量很合理。如果打包系统有意创建硬链接，应记录并测试预期数量。仅仅因为某次构建让你意外就删掉检查，会留下另一个名称，别人可以通过它修改同一个 inode。

在启动完成前保留描述符和 `stat` 快照。它们不能让基于路径的启动变成原子操作，但能帮助检测替换，记录所涉及的设备与 inode，并在不重新打开受攻击者控制的名称时解释拒绝原因。

## 检查路径链中的每个可写目录

即使辅助程序本身的模式完美，只要父目录可以被重命名或改写，它仍不安全。攻击者无需修改可执行文件的字节，只要能替换指向这些字节的目录项即可。

使用目录描述符，从辅助程序的父目录一路走回包根目录。对每个组成部分拒绝符号链接，确认它是目录，记录设备与 inode，并应用安装模式规定的所有权和写权限规则。持有父目录描述符后使用 `openat` 和 `fstatat`，比反复解析绝对路径字符串更可靠。遍历应在已验证的包根目录结束，而不是在仅仅带有熟悉后缀的路径结束。

这里必须诚实说明按用户安装的限制。如果运行网关的同一用户拥有一个可写的应用包，以该用户身份运行的其他进程可能替换包文件。模式和所有者检查能发现对其他账户的意外暴露，却无法抵御当前账户完全失陷。代码签名身份检查仍然有用，因为攻击者只应用临时签名，并不能满足你的指定要求。

如果策略要求包位于本地单一卷上，还应比较预期路径链中的设备 ID。Apple 指出，路径名可能跨越挂载边界。拒绝意外边界很有用，但不要声称这就证明卷可信。它只证明对象不在打包契约指定的位置。

目录检查很容易发现常见部署错误，例如更新器留下组可写的暂存目录、辅助程序被恢复到已签名应用之外，或开发脚本引入符号链接。这些故障应在运行器收到命令之前阻止执行。只发警告后继续，会把打包错误变成选择可执行文件的逻辑。

## 验证身份，而不只是签名有效性

有效签名只回答已签名代码是否仍与该签名一致。它不能证明代码由你的团队签名，也不能证明这个可执行文件就是你的辅助程序。在 macOS 上，代码可以使用临时签名，其他开发者也能生成签名完全有效的代码。

使用 Code Signing Services，并提供一个明确要求，指定发布渠道所需的签名标识符和团队身份。Apple TN3127 清楚区分了这些概念：代码签名标识符是签名者选择的名称，签名身份包含证书和私钥，指定要求则定义各版本之间什么才算同一代码。比较界面上显示的 `Authority` 字符串适合诊断，不适合作为产品的安全边界。

为辅助程序的绝对 URL 创建 `SecStaticCode`，编译或加载要求，然后调用 `SecStaticCodeCheckValidityWithErrors`。加入 `kSecCSStrictValidate` 和 `kSecCSCheckAllArchitectures`。Apple 文档说明，默认行为可能只验证通用二进制的本机切片。验证所有切片可防止未经测试的架构携带不同或损坏的签名。

不要在这里使用 `kSecCSBasicValidateOnly`。该标志会跳过主可执行文件和资源验证，使完整性检查失去目的。也不要只信任辅助程序自己的指定要求，而不与网关控制的要求比较。自我描述不等于授权。

外层应用也应验证。Apple 为辅助工具规定标准嵌套代码位置，使签名系统能把它们当作代码处理。正确签名的版本有一条意图链：辅助程序满足自己的预期要求，外层应用的封印又记录该嵌套组件。两者都检查，才能发现辅助程序本身有效却被移植到损坏包中的情况。

内部保留详细的 `CFError`，再向用户映射成少量拒绝类别：缺少签名、要求不匹配、资源无效、架构不受支持或文件已变化。绝不能在验证失败后换一条路径重试。代码签名失败意味着运行器不可用。

## 静态检查无法消除启动竞争

静态验证成立的前提是文件没有继续变化。Apple 在 `SecStaticCodeCheckValidity` 文档中明确说明了这一点，并点名网络、联合和 FUSE 等动态文件系统。这个警告也适用于普通的可替换目录项：验证返回后，另一个进程可能在 `posix_spawn` 解析路径前，把另一个可执行文件重命名到已检查的位置。

故障只需四步：

1. 网关解析 `/Applications/Example.app/Contents/Helpers/runner` 并验证文件 A。
2. 竞争进程把文件 B 重命名到这个路径。
3. 网关要求 `Process` 或 `posix_spawn` 执行该路径。
4. 内核打开文件 B，因为启动调用收到的是名称，而不是为文件 A 保留的描述符。

在验证前后比较 `stat` 能缩小窗口并发现很多尝试，但不能让第二步和第三步不可分割。自行散列文件也有同样限制，还重复了代码签名已经完成的工作。锁文件只能协调愿意遵守锁的进程，攻击者不会配合。

常见建议是在应用启动时验证一次并缓存成功结果。它受欢迎，是因为签名检查有成本，而包内代码在正常运行中看似不会改变。但对网关而言，这个建议是错的。更新、恢复、卷变化和恶意替换都可能发生在菜单栏应用仍运行时。可以按 Apple 的建议缓存已编译要求对象，但应在每个启动边界重新评估可执行文件。

保持打开描述符仍有帮助。在验证期间保留它，在启动前立即再次 `fstat`，如果设备、inode、大小、修改时间或状态更改时间变化就拒绝。进程创建后再取第三份快照用于遥测。这些比较改善诊断并提高攻击成本，但安全声明必须准确：只有操作系统的启动要求才能把身份判断绑定到进程启动。

## 把要求绑定到进程创建

在提供 LightweightCodeRequirements 的系统上，调用 `run` 前设置 `Process.launchRequirement`。Apple 说明，如果待启动的可执行文件不满足 `LaunchCodeRequirement`，操作系统不会运行该进程，并会创建崩溃报告。这样，决定性的身份检查就在进程创建中完成，路径选择不能再从安全判断中漂移出去。

核心 Swift 设置很短：

```swift
import Foundation
import LightweightCodeRequirements

func configuredProcess(helper: URL, team: String, identifier: String) throws -> Process {
    let requirement = try LaunchCodeRequirement.allOf {
        ValidationCategory(.developerID)
        TeamIdentifier(team)
        SigningIdentifier(identifier)
    }

    let process = Process()
    process.executableURL = helper
    process.launchRequirement = requirement
    return process
}
```

团队和标识符必须来自编译进已签名网关的发布配置，不能从辅助程序旁边的偏好设置加载。如果产品通过多个签名渠道发布，应为每个受支持渠道创建并测试明确要求，而不是不断放宽一个表达式，直到所有构建都通过。

应把要求用于 Mach-O 辅助程序，而不是 shell 包装脚本。Apple 指出，对 shebang 脚本应用启动要求时，系统评估的是解释器。证明 `/bin/bash` 是 Apple 代码，并不能证明要信任的脚本字节。把脚本逻辑放入已签名可执行代码，或者把脚本当作数据，由可信代码在内部读取，不要把它作为特权运行器启动。

通过 SDK 可用性检查启用此 API，并保留静态预检用于诊断。对于较旧的部署目标，`POSIX_SPAWN_START_SUSPENDED` 可以让子进程在执行用户空间代码前暂停。随后可按 PID 获取动态 `SecCode`，对要求进行验证，再恢复或杀死进程。这个后备方案更难写对：检查每个返回码，避免 PID 混淆，关闭非预期描述符，并且在验证成功前绝不发送命令或凭据。如果威胁模型无法接受这种复杂度，就应要求支持启动要求的操作系统版本。

## 使用干净且狭窄的进程契约启动

验证二进制文件不会净化你传给它的内容。用结构化字段构造参数向量，设置明确环境，有意识地选择工作目录，并只传递辅助程序需要的描述符。绝不能通过 shell 拼接 SSH 命令。

从空环境或允许列表开始。影响动态加载、配置发现、区域设置、代理或主目录查找的变量，可以在不改代码的情况下改变已签名程序的行为。Hardened Runtime 和 Library Validation 能减少某些加载攻击，但不能把任意继承环境变成安全接口。

把标准输入输出当成协议。规定最大消息大小，拒绝尾随字段，给交换过程设置截止时间，并区分辅助程序协议错误和 SSH 退出状态。无状态辅助程序不应从环境变量、参数、临时文件或代理提供的路径读取密钥。它只应通过受控通道接收最小请求，并返回最小结果。

关闭所有无关描述符。预检时打开文件使用 `O_CLOEXEC` 很有帮助，但也要审计应用其余部分。继承了保险库数据库描述符、IPC 监听器或日志文件的子进程，获得了签名检查从未打算授予的访问权。在辅助程序契约允许时设置资源限制，并在取消或超时时终止整个子进程组。

记录判断输入，但不记录秘密：解析后的包内相对位置、签名要求版本、设备与 inode 快照、验证结果、子 PID 和终止原因。记录应说明哪一次可执行文件启动获得授权。只记录文本路径，会丢失一个名称在事件期间可能指向多个文件的事实。

## 把验证放进授权顺序

辅助程序验收必须与动作授权处于同一事务中，并在网关向子进程释放任何能力之前完成。应用打开时检查太早。如果人工批准远程命令后才检查，而失败处理可能泄露命令、打开套接字或触发后备路径，那又太晚。

把一次启动建模为带有明确且不可逆边界的状态机。网关接收并解析动作，此时请求无法接触凭据。它确认保险库或凭据源可用，解析并预检辅助程序，取得所需人工授权，再附带代码要求启动。只有启动成功后，才创建已验证子进程需要的最小凭据或连接通道。任何更早状态失败都要销毁待处理动作。

用户批准放在哪里，取决于批准的含义。如果卡片询问某个代理能否执行某条 SSH 命令，可以在成本较高的启动前显示。批准后的命令必须保持不可变，辅助程序验证失败应消耗或取消该批准，不能把它排队留给之后的二进制文件。如果批准表达的是对执行动作进程的信任，就只能在网关知道候选身份后显示。两种设计都能成立，但审计记录必须把请求摘要、授权决定、签名要求和子进程连接成一次尝试。

不能因为进程创建返回成功就暴露秘密。如果 API 要求，可以在启动前准备管道或套接字，但应保持携带凭据的一端关闭或逻辑上锁定。等待启动强制检查成功，记录子进程身份，然后才发送请求。当子进程需要 SSH 密钥操作时，应提供狭窄的签名或连接接口，而不是把私钥字节复制进它的地址空间。辅助程序获得的权限越少，前面任何检查出错的代价越小。

取消也要同样谨慎。用户可能在辅助程序验证期间撤销会话，代理也可能在批准后、启动前断开。启动前立即重新检查授权代次，释放请求前再检查一次。如果代次改变，终止子进程并关闭通道。这不是文件系统竞争，但它是围绕同一安全决定的另一个检查与使用缺口。

并发启动不能共享可变启动配置。每次尝试都应有自己的不可变辅助 URL、要求对象引用、参数向量、环境、描述符、截止时间和审计标识符。一个线程修改全局 `Process` 模板而另一个线程调用 `run`，即使两个二进制文件都有效，也可能把已批准请求发给错误可执行文件或错误环境。只需同步消耗批准并启动已配置进程的短暂状态转换，无需锁住每条 SSH 连接的整个生命周期。

一个实用不变量是：在身份强制检查成功前，子进程控制的任何字节都不能进入网关的特权状态。辅助程序一运行就可能写管道，因此在验收前不要连接输出解析器，限制管道缓冲区暴露，并把过早输出视为协议违规。同样，不要用未验证子进程的错误文字构造特权路径、选择凭据或决定下一步尝试哪个二进制文件。

这种顺序还能形成更清晰的日志。一次尝试可以依次记录 `request_received`、`candidate_preflight_passed`、`authorization_granted`、`launch_requirement_passed`、`request_released` 和最终结果。缺失的转换会很醒目。若记录从批准直接跳到 SSH 退出码，响应人员便无法判断预期运行器是否处理过命令。

## 验证真正启动的子进程

启动强制检查应是决定性关卡，但启动后的观察仍能发现集成错误，并给事件响应人员一个稳定的进程身份。`run` 成功后，获取 PID，并为运行进程取得动态 `SecCode`。在连接协议解析器或释放敏感输入前，用对应的进程要求检查它。

Apple 对 `SecStaticCode` 和 `SecCode` 的区分在这里很有用。静态对象描述磁盘上的代码，并不天然关联运行中代码。动态代码对象表示进程中已加载的代码。两类检查回答不同问题：静态检查解释已安装候选是否完整，动态检查确认 macOS 分配给当前子进程的身份。

只靠 PID 查找有明显风险。PID 会复用，短命子进程也可能在 `run`、查找和验证之间退出。无法获取或验证代码对象时，应按启动失败处理。如果 IPC API 提供审计令牌，应使用这个更强的进程引用，而不是接受消息中提供的 PID。绝不能让子进程自行报告其 PID、签名标识符或可执行路径。

在子进程先获得 CPU 的平台上，启动后检查不能成为唯一检查。替换进程可以在启动与检查之间行动。在较旧系统上，让进程在执行用户空间代码前暂停可缩小这个窗口，但实现必须只恢复已验证 PID，并在每个错误路径杀死它。启动要求更简单，因为操作系统会在进程运行前拒绝不匹配项。

验收后保留进程句柄，把每条协议消息绑定到这个实例。不要重新打开命名套接字，也不要重连到可能被其他进程占据的辅助端点。子进程退出时关闭所有通道，使动作失效，并要求后续进程重新完成验证启动。第一个有效子进程不会授权后来碰巧复用其 PID 或端点名称的进程。

同时记录安装对象和运行对象的证据。安装对象记录包内相对路径、设备、inode、大小、时间戳和静态验证结果。进程记录 PID、签名要求修订版、启动强制结果、动态验证结果、开始时间和终止状态。这些值不是秘密，能帮助区分损坏版本、更新冲突、要求配置错误和替换尝试，而无需输出命令或凭据。

进程身份也需要生命周期规则。如果辅助程序再执行另一个程序，已验证身份不会自动转移到新映像。不要设计成先正确验证一个已签名包装器，再让它通过 `PATH` 调用任意 `ssh`。如果设计包含 exec 转换，最终可执行文件需要自己的强制要求和固定路径，或者可信辅助程序必须自己完成所需协议。启动器上的签名不能证明它之后选择的程序。

还要注意库和配置。运行中的主可执行文件可能满足要求，但不安全的环境变量或可写搜索位置仍会改变其行为。签名依赖代码，按辅助程序需要启用 Hardened Runtime 选项，在兼容时使用 Library Validation，并从环境中移除搜索路径。配置应作为网关验证过的输入，而不是让子进程在主目录中自行发现 dotfile。

最后，测试日志声称的事实。在替换竞争测试中，静态快照应识别文件 A，启动强制检查应启动 A 或拒绝 B，动态检查应与启动结果一致。如果日志能报告 A 已通过，而未记录的 B 实际运行，证据模型仍追随路径名，而不是进程。

## 失败时关闭通道，同时保留更新能力

任何验证失败都应停止该命令运行器，并让应用其他部分保持可理解状态。不要改用系统 `ssh`，不要搜索其他目录，不要删除启动要求，也不要要求用户批准身份不明的辅助程序。批准无法修复可执行文件身份。

更新需要独立的状态转换。停止接收新的 SSH 工作，让活动子进程结束或终止它们，用原子替换机制安装完整已签名包，然后重新执行所有包、文件和签名检查。来自不同版本的辅助程序与应用可能各自拥有有效签名，却违反两者间的协议契约。启动后加入协议版本握手，在发送动作前拒绝不匹配。

决定哪些变化需要用户可见的恢复。部分更新后缺失辅助程序，通常需要重新安装应用。团队或标识符不匹配可能表示篡改，应给出更强警告。模式不匹配可能来自备份工具。内部保留准确错误，对外给出简短操作建议，但不能训练用户绕过检查。

测试替换行为，而不只测试正常签名。发布测试应覆盖辅助路径上的符号链接、第二个硬链接、组可写父目录、临时签名替代品、由错误标识符正确签名的二进制文件、非本机切片损坏的通用文件、预检与启动之间的替换，以及应用持续运行时更新。静态验证后暂停的测试钩子能稳定复现竞争，不必靠运气。

Sallyport 的 SSH 通道使用内置的无状态 Go 辅助程序，保险库核心则留在已签名的菜单栏应用内。这种分离让秘密远离辅助程序，却不意味着可以省略辅助程序验证：网关仍必须先证明启动的是哪个命令运行器，再交付已批准动作。

设计评审中的验收规则应能写成一句话：运行中应用包里的预期文件通过描述符和目录检查，满足发布签名要求，并且进程创建强制执行同一个身份。如果平台无法强制执行最后一项，就必须记录较弱保证，并减少子进程能接收的内容。
