# 符号链接替换后的代理可执行文件身份

文件名是定位器，不是身份。这个区别看起来有些吹毛求疵，直到代理以 `~/bin/agent` 运行并获得批准，有人把这个路径换成另一个程序，而网关因为标签看起来仍然熟悉，就认定下一个调用方是可信的。

符号链接让这个错误很容易重现，但它不是根本原因。任何可变的间接引用都可能造成同样的问题：shell 包装器、PATH 条目、复制出来的二进制文件、开发目录，或每次请求都重新解析路径的启动器。批准路径的网关，实际上批准了一个普通无特权用户经常可以替换的对象。

在 macOS 上，有用的判断单位是正在运行的进程及其代码身份。Apple Code Signing Services 可以描述并验证与进程关联的代码。记录中可以保留路径用于诊断，但路径不能承载授权决策。

## 符号链接是名称服务，不是程序

符号链接保存一个字符串，内核会在程序打开或执行它时解析这个字符串。替换该字符串会改变之后的 `exec` 调用找到的内容，但不会改写已经启动进程的可执行映像。

这个细节很容易带来虚假的安全感。人们测试符号链接替换，看到原来的进程继续运行，就以为攻击没有危害。这个测试只说明进程行为正常，并没有测试真正重要的决策：网关是否会把之后通过该别名启动的进程识别为之前批准过的进程。

假设一个编程代理通过下面的路径启动：

```text
~/bin/build-agent -> ~/work/approved-agent
```

网关收到第一个请求，看到 `~/bin/build-agent`，然后向操作员请求许可。攻击者随后修改链接：

```text
~/bin/build-agent -> ~/Downloads/replacement-agent
```

如果已批准的进程继续发送请求，网关在该进程退出前继续允许这个会话，可能是正确的。这不是故障。故障出现在另一个进程通过同一个名称启动，而网关说：「我已经批准过 `~/bin/build-agent` 了。」

第二个进程是新的安全主体。它需要重新检查，除非它符合明确规定的更新规则，否则还需要新的审批。

没有符号链接时也会出现同样的错误。如果网关对客户端提供的路径运行 `codesign`，它可能检查了一个文件，却接受了另一个进程发来的请求。客户端可以在检查后修改路径来抢占这段时间，也可以把网关指向一个稳定别名，而这个别名对应的目标会不断变化。审批卡片上一个看起来漂亮的路径，无法修复这种设计。

Apple 把代码要求视为身份约束，而不是文件名。Apple 的文档说明，指定要求是用来判断某段代码是否与之前见过的代码相同的条件。开发者没有声明指定要求时，它通常根据签名机构和嵌入式标识符生成。这是正确的方向，但有一个重要限定：代码要求描述的是代码身份，不是正在运行的进程会话。

## 获得批准的是正在运行的进程

网关应根据从操作系统获取的、指向真实调用方进程的活动引用作出授权决策。随后，这个决策应绑定到本次进程运行，而不是绑定到客户端声称的路径、可执行文件名或可重复使用的环境变量。

在 macOS 上，一次合适的检查有两个任务：

1. 获取真实调用方进程的签名信息。
2. 在把进程身份信息作为授权证据之前，验证该进程。

这是两个独立的任务。`SecCodeCopyDesignatedRequirement` 可以返回已签名代码的指定要求，但 Apple 明确指出，这个调用不会验证签名。签名后被修改的代码，或签名不正确的代码，仍然可能产生不完整或误导性的信息。网关也必须执行有效性检查。

进程记录应包含足够的证据，以便之后解释当时的决策：

- 进程标识符，或者更好的是能抵御 PID 重用的操作系统进程凭据
- 检查时观察到的可执行文件路径，但只作为上下文保留
- 可用时记录签名标识符和团队标识符
- 适用时记录签名机构和证书链
- 指定要求
- cdhash，或受支持的 cdhash 集合
- 签名验证结果和检查时间

不要把这份列表误当成策略语言。网关需要回答的问题很简单：哪个正在运行的进程发出了请求，人是否批准了这次运行？少量身份事实就能回答这个问题。一大堆路径规则做不到。

棘手的部分是更新处理。合法版本之间的指定要求通常保持稳定。这也是 macOS 在用户授权应用访问受保护服务时，用它维持连续性的原因。Apple 技术说明 TN3127 给出了一个常见例子：应用更新后再次使用麦克风时，macOS 会把新版本与记录中的指定要求进行比较。

对于持久的操作系统权限，这种行为很合理。但如果你无声地把过去的人为批准复用于新的自主代理运行，它就过于宽松了。网关可以显示签名机构，帮助操作员识别程序，同时在新进程启动时仍然再次请求批准。签名者说明来源，进程边界决定同意的有效期。

## 静态路径检查留下无法辩解的竞争窗口

静态检查回答的是静态问题：「这个文件现在在这个路径上具有什么签名？」它本身无法回答：「是什么代码发出了这个请求？」

即使静态检查在技术上完全正确，这个差距也很重要。假设网关收到一个请求，声称其可执行文件路径是 `/Users/dev/bin/agent`。它运行：

```sh
codesign --verify --strict --verbose=2 /Users/dev/bin/agent
codesign -d -r- /Users/dev/bin/agent
```

两个命令都可能报告有效签名和指定要求。网关随后把路径名保存为已批准身份。在检查和下一次请求之间，攻击者可以让 `agent` 指向另一个文件。下一次请求到来时，网关可能再次运行这些命令，并得到替代文件的信息。但两个命令都无法证明这些信息与发出请求的进程有关。

还有第二个经常被团队忽略的竞争窗口。辅助程序可能在启动代理前检查某个路径，之后才收到子进程的连接。路径检查描述的是启动时的父文件，连接描述的是请求发生时的进程。如果辅助程序不通过操作系统凭据绑定这两个事件，它就只是建立了一个假设，然后把这个假设称为来源证明。

Apple 的代码签名 API 因此暴露了不同类别的信息。`kSecCSSigningInformation` 请求证书和 CMS 数据，`kSecCSRequirementInformation` 请求要求，`kSecCSDynamicInformation` 请求运行中代码的动态有效性信息。API 本身不会把这些部分变成完整的网关设计，但它清楚表明：静态签名检查和运行中代码状态是不同的输入。

文件名仍然应该出现在审批界面中。操作员需要知道请求来自 `~/work/demo` 中的开发目录，而不是 `/Applications` 中的已安装工具。把它当作界面上下文即可。授权记录必须继续绑定到操作系统识别出的调用方。

## 不接触真实代理也能重现替换

你可以在临时目录中使用两个小型二进制文件演示路径问题。这个测试不需要生产令牌、SSH 密钥或修改应用包。

创建工作目录，编译两个打印不同标记的程序。代码故意保持简单，因为要测试的是替换行为，不是程序逻辑。

```sh
work="$(mktemp -d /tmp/agent-identity.XXXXXX)"
cd "$work"

cat > approved.c <<'EOF'
#include <cstdio.h>
#include <unistd.h>
int main(void) {
  printf("approved process pid=%d\n", getpid());
  fflush(stdout);
  sleep(60);
  return 0;
}
EOF

cat > replacement.c <<'EOF'
#include <cstdio.h>
#include <unistd.h>
int main(void) {
  printf("replacement process pid=%d\n", getpid());
  fflush(stdout);
  sleep(60);
  return 0;
}
EOF

clang approved.c -o approved-agent
clang replacement.c -o replacement-agent
codesign --force --sign - --identifier com.example.approved approved-agent
codesign --force --sign - --identifier com.example.replacement replacement-agent
ln -s "$work/approved-agent" agent
```

对于本地机制测试，临时签名已经足够，但它没有证书链。Apple 说明，临时签名代码没有证书，CMS 数据也为空。不要用临时签名模拟发行者签名，也不要据此决定生产网关接受什么。

记录别名指向的目标，检查其签名，然后启动第一个进程：

```sh
printf 'alias before: %s\n' "$(readlink agent)"
codesign -d -r- agent 2>&1 | sed -n '1,8p'
./agent &
first_pid=$!
printf 'first pid: %s\n' "$first_pid"
ps -p "$first_pid" -o pid=,comm=,args=
```

输出形式应类似下面这样：

```text
alias before: /tmp/agent-identity.xxxxxx/approved-agent
Executable=/tmp/agent-identity.xxxxxx/agent
designated => identifier "com.example.approved"
approved process pid=48291
first pid: 48291
48291 ... ./agent
```

现在，在第一个进程休眠时替换链接：

```sh
rm agent
ln -s "$work/replacement-agent" agent
printf 'alias after: %s\n' "$(readlink agent)"
codesign -d -r- agent 2>&1 | sed -n '1,8p'
ps -p "$first_pid" -o pid=,comm=,args=
```

此时检查 `agent` 时应该会看到 `com.example.replacement`，而 `first_pid` 仍然存活。再次通过别名启动进程：

```sh
./agent &
second_pid=$!
printf 'second pid: %s\n' "$second_pid"
wait "$first_pid" "$second_pid"
```

第二个进程会打印 `replacement process`。两个 PID 运行的是不同的程序映像，尽管它们都以 `./agent` 的形式启动。

这个结果应该改变你的网关测试计划。只检查旧 PID 是否继续运行，几乎什么也证明不了。真正有用的测试是：网关是否把第二个 PID 当作新的调用方，并在它使用已批准的操作之前显示观察到的身份。

## 有用的审批测试需要三次不同的运行

不要只做一次正常审批和一次符号链接命令。你需要三次运行，因为每次运行验证的是不同性质。

首先，通过别名启动已批准的目标，并发起操作请求。网关应创建会话记录，并显示一条审批信息，用操作员能够判断的方式标识真实调用方。记录会话标识符、PID、观察到的路径和代码签名事实。

其次，修改别名，但让第一个进程继续运行。让第一个进程再次发出请求。如果你的会话模型允许一次进程运行持续到退出，网关可能会允许这个请求。可执行映像并没有改变。不要把这个预期结果错误地标记为漏洞。

第三，通过替换后的别名启动新进程，并发起同一个请求。网关不能仅因为可执行文件名、命令行、工作目录或请求的操作相同，就继承第一个进程的批准。它应创建独立会话，并要求经过正常的授权流程。

测试时可以使用下面的结果表：

| 运行 | 启动时的别名目标 | 预期结果 |
| --- | --- | --- |
| 第一个进程 | `approved-agent` | 获得新的审批，然后在本次运行中允许 |
| 替换后的第一个进程 | 磁盘上是 `replacement-agent`，但内存中仍是旧映像 | 现有会话行为持续到进程退出 |
| 第二个进程 | `replacement-agent` | 获得新的审批或被拒绝，绝不能继承原审批 |

还有一种情况值得测试。把别名换回原始二进制文件，然后启动第三个进程。网关仍应创建新会话。同一代码身份不等于同一个进程。如果产品确实为已签名发布者提供持久信任关系，就把它作为单独且明确的操作员决策。不要把它悄悄塞进按进程审批功能中。

Sallyport 默认开启按会话授权，审批卡片首先显示进程的代码签名机构，而不是可变的文件名。新代理进程请求执行操作时，操作员因此能看到更有价值的事实。

## 签名者、指定要求和 cdhash 回答的是不同问题

团队经常把三个不同概念压缩成「由同一个应用签名」。这种捷径要么造成不断的审批疲劳，要么让权限持续过久。

签名机构回答的是谁签署了代码。对于分发软件，这可能包括证书链和团队标识符。它能帮助操作员区分已知供应商和随机可执行文件，但不能指出某一个确切构建。

指定要求回答的是 macOS 是否应在更新过程中把代码视为相同身份。Apple 表示，所有已签名代码都有指定要求，可以是显式声明的，也可以是自动生成的。默认要求通常包含签名机构和嵌入式标识符。这让它适合维持连续性，但也可能接受你没有亲自检查过的后续版本。

cdhash 标识特定的已签名 CodeDirectory。就授权而言，它接近构建指纹。它是很好的审计证据，因为调查人员可以借此区分共享签名者和标识符的两个版本。但对于开发工具，它通常不适合作为永久审批规则，因为日常更新会改变 cdhash。

应根据决策使用证据：

- 使用正在运行的进程凭据，把会话绑定到调用方。
- 在审批界面显示签名机构和标识符，帮助操作员识别来源。
- 记录指定要求和 cdhash，让之后的调查能够区分发布者连续性和确切构建。
- 即使签名者和指定要求相同，新代理进程仍需再次审批。

最后一点比 macOS 权限更严格，这是有意为之。代理网关可以使用不会进入代理进程的凭据来发出 HTTP 请求或 SSH 命令。这是操作边界，而不是一次性的麦克风提示。把过去的一次点击复用于未来无关的进程，会削弱最初让网关成立的人为控制。

## 不要把文件监视器变成授权机制

一种看似合理的做法是监视代理路径，在文件变化时撤销权限。它很受欢迎，因为看起来简单：保存原始 inode，订阅文件系统事件，在文件写入或重命名后使审批失效。

但它不应成为基础。

文件系统事件适合作为遥测，不是调用方身份的证明。路径可以有多个名称。二进制文件可以被复制。进程可以从已解除链接的文件启动。事件传递可能延迟或合并。如果你的设计授权的是路径而不是进程，攻击者甚至不需要赢得一场戏剧性的竞争。

比较 inode 也不能修复这个模型。它可能帮助你发现某个目录中的一次替换，但无法说明从另一个硬链接或复制目标启动的进程，也会让正常的开发流程变得脆弱，因为工具经常重新构建、重命名和替换文件。

把文件观察当作补充审计上下文的理由。如果当前路径指向的目标与审批时不同，就记录这一事实。如果网关看到新的调用方进程，就检查该调用方并执行正常的授权流程。进程边界会直接处理安全决策，不必信任文件监视器充当参考监视器。

这条规则也能避免另一个更隐蔽的错误：因为包装器的签名看起来熟悉，就批准包装器，却忽略它启动了什么。已签名包装器可以执行未签名子进程，加载本地脚本，或根据环境选择目标。网关必须识别实际连接并请求特权操作的进程。如果调用方是包装器，就批准包装器。如果调用方是它的子进程，就检查子进程。

## 将批准绑定到操作系统凭据，并在存在歧义时拒绝

实际实现需要操作系统提供的请求方句柄。在 macOS 上，通常意味着通过 Code Signing Services 获取调用进程的 guest code 对象，并使用可信连接上下文提供的进程属性，而不是使用代理输入中复制的值。

安全流程如下：

1. 接受连接，并从连接机制中获取调用方的操作系统身份。
2. 将该身份解析为正在运行的代码对象。
3. 在读取身份数据并用于授权前，执行代码有效性检查。
4. 从正在运行的代码对象中读取签名机构、标识符、指定要求和 cdhash。
5. 创建绑定到调用方凭据的会话记录。如果没有已批准会话，就要求审批。
6. 每次操作前重新检查调用方凭据是否仍指向同一个活动进程，并在进程退出或操作员撤销会话时销毁会话。

第一步的细节取决于传输方式。本地 Unix socket、XPC 连接和子进程管道会提供不同的凭据。但危险的退路始终相同：接受代理在请求负载中发送的 PID、路径、bundle 标识符或签名摘要。代理控制着这个负载，它不能证明任何事情。

需要特别注意 PID 重用。一个裸 PID 只有在进程存在时才是唯一的。进程退出后，操作系统可以把同一个数字分配给另一个进程。如果 API 提供了更完整的凭据，就保存它。如果传输方式无法提供持久的调用方绑定，就把会话有效期缩短到连接生命周期，并在重新连接后重新授权。拒绝处理比接受有歧义的调用方更不方便，但路径替换漏洞正是存在于这种歧义中。

审批提示必须真实。如果网关看到的是临时签名，就明确显示。如果不存在有效签名，也要明确说明。如果程序有熟悉的显示名称，但签名机构发生变化，不要把签名机构藏在折叠区域后面。第一行就应该告诉操作员是谁签署了这个进程，以及它想执行什么操作。

## 审计轨迹必须保存决策，而不只是请求

一条只记录 `POST /deploy` 成功的日志，无法解释符号链接替换事件。它告诉你发生了什么，却没有说明网关为什么允许该调用方执行操作。

对于每个已批准会话，保存审批时观察到的身份证据快照。对于每个操作，保存会话引用和结果。操作记录不必反复复制证书数据，但必须能够追溯到包含这些数据的确切审批记录。

精简记录可以这样写：

```json
{
  "session": "6F2A...",
  "caller": {
    "process": "OS-issued caller credential",
    "path_observed": "/private/tmp/demo/agent",
    "signing_identifier": "com.example.approved",
    "designated_requirement": "identifier com.example.approved",
    "cdhash": "<observed digest>",
    "validity": "valid"
  },
  "approval": "granted",
  "action": "HTTP POST /deploy",
  "result": "success"
}
```

路径仍然有用。它可以告诉你进程是从临时目录还是 shell 别名启动的，但不能仅凭路径把后来的请求加入这个会话。

Sallyport 同时保留代理运行的 Sessions 日志和单个调用的 Activity 日志，两者都从一份加密、哈希链式审计日志中生成；离线的 `sp audit verify` 命令无需保险库密钥即可检查链。这样，符号链接替换测试更容易复核，因为审批事件和之后的操作是两个独立事实，而不是一条含糊的成功消息。

在写下一个日后无法解释的审批规则前，先运行符号链接测试。如果新启动的替代进程能够使用第一个进程的批准，就不要再把可执行文件名称当成方便显示的字段。它已经进入了信任决策。
