符号链接替换后的代理可执行文件身份
在 macOS 上测试代理可执行文件身份对符号链接替换的抵抗能力,把审批绑定到正在运行的代码,并让路径别名远离信任决策。

文件名是定位器,不是身份。这个区别看起来有些吹毛求疵,直到代理以 ~/bin/agent 运行并获得批准,有人把这个路径换成另一个程序,而网关因为标签看起来仍然熟悉,就认定下一个调用方是可信的。
符号链接让这个错误很容易重现,但它不是根本原因。任何可变的间接引用都可能造成同样的问题:shell 包装器、PATH 条目、复制出来的二进制文件、开发目录,或每次请求都重新解析路径的启动器。批准路径的网关,实际上批准了一个普通无特权用户经常可以替换的对象。
在 macOS 上,有用的判断单位是正在运行的进程及其代码身份。Apple Code Signing Services 可以描述并验证与进程关联的代码。记录中可以保留路径用于诊断,但路径不能承载授权决策。
符号链接是名称服务,不是程序
符号链接保存一个字符串,内核会在程序打开或执行它时解析这个字符串。替换该字符串会改变之后的 exec 调用找到的内容,但不会改写已经启动进程的可执行映像。
这个细节很容易带来虚假的安全感。人们测试符号链接替换,看到原来的进程继续运行,就以为攻击没有危害。这个测试只说明进程行为正常,并没有测试真正重要的决策:网关是否会把之后通过该别名启动的进程识别为之前批准过的进程。
假设一个编程代理通过下面的路径启动:
~/bin/build-agent -> ~/work/approved-agent
网关收到第一个请求,看到 ~/bin/build-agent,然后向操作员请求许可。攻击者随后修改链接:
~/bin/build-agent -> ~/Downloads/replacement-agent
如果已批准的进程继续发送请求,网关在该进程退出前继续允许这个会话,可能是正确的。这不是故障。故障出现在另一个进程通过同一个名称启动,而网关说:「我已经批准过 ~/bin/build-agent 了。」
第二个进程是新的安全主体。它需要重新检查,除非它符合明确规定的更新规则,否则还需要新的审批。
没有符号链接时也会出现同样的错误。如果网关对客户端提供的路径运行 codesign,它可能检查了一个文件,却接受了另一个进程发来的请求。客户端可以在检查后修改路径来抢占这段时间,也可以把网关指向一个稳定别名,而这个别名对应的目标会不断变化。审批卡片上一个看起来漂亮的路径,无法修复这种设计。
Apple 把代码要求视为身份约束,而不是文件名。Apple 的文档说明,指定要求是用来判断某段代码是否与之前见过的代码相同的条件。开发者没有声明指定要求时,它通常根据签名机构和嵌入式标识符生成。这是正确的方向,但有一个重要限定:代码要求描述的是代码身份,不是正在运行的进程会话。
获得批准的是正在运行的进程
网关应根据从操作系统获取的、指向真实调用方进程的活动引用作出授权决策。随后,这个决策应绑定到本次进程运行,而不是绑定到客户端声称的路径、可执行文件名或可重复使用的环境变量。
在 macOS 上,一次合适的检查有两个任务:
- 获取真实调用方进程的签名信息。
- 在把进程身份信息作为授权证据之前,验证该进程。
这是两个独立的任务。SecCodeCopyDesignatedRequirement 可以返回已签名代码的指定要求,但 Apple 明确指出,这个调用不会验证签名。签名后被修改的代码,或签名不正确的代码,仍然可能产生不完整或误导性的信息。网关也必须执行有效性检查。
进程记录应包含足够的证据,以便之后解释当时的决策:
- 进程标识符,或者更好的是能抵御 PID 重用的操作系统进程凭据
- 检查时观察到的可执行文件路径,但只作为上下文保留
- 可用时记录签名标识符和团队标识符
- 适用时记录签名机构和证书链
- 指定要求
- cdhash,或受支持的 cdhash 集合
- 签名验证结果和检查时间
不要把这份列表误当成策略语言。网关需要回答的问题很简单:哪个正在运行的进程发出了请求,人是否批准了这次运行?少量身份事实就能回答这个问题。一大堆路径规则做不到。
棘手的部分是更新处理。合法版本之间的指定要求通常保持稳定。这也是 macOS 在用户授权应用访问受保护服务时,用它维持连续性的原因。Apple 技术说明 TN3127 给出了一个常见例子:应用更新后再次使用麦克风时,macOS 会把新版本与记录中的指定要求进行比较。
对于持久的操作系统权限,这种行为很合理。但如果你无声地把过去的人为批准复用于新的自主代理运行,它就过于宽松了。网关可以显示签名机构,帮助操作员识别程序,同时在新进程启动时仍然再次请求批准。签名者说明来源,进程边界决定同意的有效期。
静态路径检查留下无法辩解的竞争窗口
静态检查回答的是静态问题:「这个文件现在在这个路径上具有什么签名?」它本身无法回答:「是什么代码发出了这个请求?」
即使静态检查在技术上完全正确,这个差距也很重要。假设网关收到一个请求,声称其可执行文件路径是 /Users/dev/bin/agent。它运行:
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 密钥或修改应用包。
创建工作目录,编译两个打印不同标记的程序。代码故意保持简单,因为要测试的是替换行为,不是程序逻辑。
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 数据也为空。不要用临时签名模拟发行者签名,也不要据此决定生产网关接受什么。
记录别名指向的目标,检查其签名,然后启动第一个进程:
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=
输出形式应类似下面这样:
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
现在,在第一个进程休眠时替换链接:
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 仍然存活。再次通过别名启动进程:
./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 对象,并使用可信连接上下文提供的进程属性,而不是使用代理输入中复制的值。
安全流程如下:
- 接受连接,并从连接机制中获取调用方的操作系统身份。
- 将该身份解析为正在运行的代码对象。
- 在读取身份数据并用于授权前,执行代码有效性检查。
- 从正在运行的代码对象中读取签名机构、标识符、指定要求和 cdhash。
- 创建绑定到调用方凭据的会话记录。如果没有已批准会话,就要求审批。
- 每次操作前重新检查调用方凭据是否仍指向同一个活动进程,并在进程退出或操作员撤销会话时销毁会话。
第一步的细节取决于传输方式。本地 Unix socket、XPC 连接和子进程管道会提供不同的凭据。但危险的退路始终相同:接受代理在请求负载中发送的 PID、路径、bundle 标识符或签名摘要。代理控制着这个负载,它不能证明任何事情。
需要特别注意 PID 重用。一个裸 PID 只有在进程存在时才是唯一的。进程退出后,操作系统可以把同一个数字分配给另一个进程。如果 API 提供了更完整的凭据,就保存它。如果传输方式无法提供持久的调用方绑定,就把会话有效期缩短到连接生命周期,并在重新连接后重新授权。拒绝处理比接受有歧义的调用方更不方便,但路径替换漏洞正是存在于这种歧义中。
审批提示必须真实。如果网关看到的是临时签名,就明确显示。如果不存在有效签名,也要明确说明。如果程序有熟悉的显示名称,但签名机构发生变化,不要把签名机构藏在折叠区域后面。第一行就应该告诉操作员是谁签署了这个进程,以及它想执行什么操作。
审计轨迹必须保存决策,而不只是请求
一条只记录 POST /deploy 成功的日志,无法解释符号链接替换事件。它告诉你发生了什么,却没有说明网关为什么允许该调用方执行操作。
对于每个已批准会话,保存审批时观察到的身份证据快照。对于每个操作,保存会话引用和结果。操作记录不必反复复制证书数据,但必须能够追溯到包含这些数据的确切审批记录。
精简记录可以这样写:
{
"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 命令无需保险库密钥即可检查链。这样,符号链接替换测试更容易复核,因为审批事件和之后的操作是两个独立事实,而不是一条含糊的成功消息。
在写下一个日后无法解释的审批规则前,先运行符号链接测试。如果新启动的替代进程能够使用第一个进程的批准,就不要再把可执行文件名称当成方便显示的字段。它已经进入了信任决策。
常见问题
代码签名能让可执行文件路径变得可信安全吗?
已签名的可执行文件仍可能通过符号链接、硬链接、复制后的路径或启动脚本被访问。名称只能说明进程从哪里找到,并不能证明当前运行的代码是什么,也不能证明是谁签署了它。
替换符号链接会改变已经运行的进程吗?
不能。运行中的进程会继续执行通过 exec 加载的程序映像,而替换符号链接只会影响之后的路径解析。真正的安全问题在于,网关再次查找这个可变路径,并把查找结果当成已经批准的进程证据。
macOS 应如何识别代理进程?
应把正在运行的进程作为检查对象,而不是客户端提供的路径。在 macOS 上,应通过 Code Signing Services 收集进程签名信息,并在授予会话前验证这些信息。
macOS 上的指定要求是什么?
指定要求用于标识 macOS 在合法更新中视为同一发布者和应用身份的一组代码。它不能唯一标识某一个具体构建,因此应把它用于连续性判断,而不是作为运行内容的唯一记录。
应该按 cdhash 批准代理吗?
cdhash 标识某个特定的已签名 CodeDirectory,可作为具体构建的有用证据。它比签名者或指定要求更严格,但如果永久固定它,正常更新也会失败,直到有人有意批准新构建。
代理批准应在重启后继续有效吗?
不应该。批准应属于一次进程运行,而不是某个文件名,也不是永久的开发者身份。即使新进程来自同一个已签名应用,也应产生新的审批事件。
为什么按路径批准对 AI 代理很危险?
危险模式是一次批准某个辅助程序,之后把从该路径启动的所有进程都视为已批准。攻击者可以在首次决策后替换符号链接、包装器或二进制文件,同时保留熟悉的命令名称,因此这种做法尤其危险。
代理操作审计日志应记录什么?
审计记录应包含进程标识符或审计令牌、作为上下文的可执行文件路径、签名机构、标识符、指定要求、cdhash、验证结果和会话开始时间。路径有助于诊断,但不能成为信任锚点。
替代可执行文件未签名或签名不同,应该怎么办?
如果批准规则要求有效签名,那么未签名的替代程序应被拒绝。由其他机构签名的替代程序需要新的审批。若是由你明确允许的身份签署的合法更新,则应按照你选择的更新策略处理。
在自己的工作 Mac 上测试符号链接替换安全吗?
在临时目录中使用自己编译的二进制文件进行替换,并让别名指向这些文件。不要替换自己未创建的应用包中的文件,不要关闭平台保护,也不要使用生产凭据进行测试。