# SSH ProxyCommand 的安全性是否比看起来更弱？

SSH ProxyCommand 是在你想运行的远程命令之前发生的本地代码执行。这样说出来似乎很明显，但审查时却经常把它当成传输细节：代理请求执行 `ssh host uptime`，审查人员检查主机和远程命令，而客户端悄悄启动了一个没人纳入审批决定的本地 shell 命令。

当自主编程代理可以调用 SSH 时，这个空白更加危险。远程目标可能受到严格限制，但本地 SSH 配置中可能包含别名、包含文件、条件规则、代理辅助程序和多年来积累的跳板主机链。安全审查应先从 SSH 在客户端执行的操作开始，再向外审查网络路径。

## ProxyCommand 在远程会话创建前运行

`ProxyCommand` 会告诉 SSH 客户端启动一个本地命令，并将该命令的标准输入和标准输出用作连接 SSH 服务器的传输通道。这个命令不会在目标主机上执行，而是在启动 `ssh` 的计算机上，以本地账户身份运行，时间早于 SSH 对目标服务器进行身份验证。

典型配置看起来没有什么问题：

```
Host build-private
    HostName build.internal.example
    ProxyCommand /usr/local/bin/connect-private %h %p
```

有人运行 `ssh build-private` 时，SSH 会展开 `%h` 和 `%p`，然后启动 `/usr/local/bin/connect-private build.internal.example 22`。这个辅助程序可能打开套接字、调用另一个客户端、读取令牌文件、加载库、读取环境变量，或运行 shell 脚本。从那以后，SSH 客户端看到的只有字节流。你的审批提示可能描述的是一次 SSH 连接，但第一个有实际意义的操作其实是启动本地程序。

OpenSSH 的 `ssh_config` 手册明确说明了这个边界：`ProxyCommand` 指定用于连接服务器的命令，并提醒该命令会通过用户的 shell 执行。最后这句话会改变审查方式。命令行并不是参数数组。shell 解析、展开、重定向、命令替换以及 shell 的启动环境，都属于这项操作的一部分。

不要因为辅助程序位于 dotfiles 仓库中，就把这件事轻轻带过。六个月前审查过的配置，可能调用通过 `PATH` 找到的二进制文件，从可写目录中加载文件，或依赖如今解析结果已经不同的别名。审查目标应是实际运行该命令的计算机上的最终执行路径。

## 一个主机别名可能选择远不止一台主机

SSH 配置是匹配系统，不是简单的字典。一个简短的别名可能触发系统配置、用户配置、被包含的片段、通配主机、规范化处理和条件代码块中的设置。最终命令可能来自一个没人会联想到该别名的文件。

先列出 SSH 会读取哪些配置。在常见的 OpenSSH 客户端中，这通常包括 `/etc/ssh/ssh_config`、通过 `Include` 找到的文件，以及 `~/.ssh/config`。发行版打包方式和命令行选项可能改变这份列表，因此也要检查调用方式。调用者如果提供了 `-F /some/file`，就改变了整个审查范围。

使用 `ssh -G` 打印某个具体目标的最终配置。对于受控测试别名，这是一项有用的审查材料：

```
ssh -G build-private | grep -E '^(hostname|user|port|proxycommand|proxyjump|identityfile) '
```

输出大致如下：

```
hostname build.internal.example
user deploy
port 22
proxycommand /usr/local/bin/connect-private %h %p
identityfile ~/.ssh/id_deploy
```

`ssh -G` 会显示 SSH 选择了哪些配置，可以发现数量惊人的错误：范围过宽的 `Host *` 条目、旧的包含文件，或映射到账户与预期不同的别名。它不能证明辅助程序是安全的，也不一定会用审查人员容易理解的方式说明所有导致最终配置的条件，因此要将每条相关指令追溯到其来源文件。

只有当配置始终把主机输入当作数据处理时，才能把主机输入视为数据。如果代理可以提交任意别名，它就能选择本地账户可以读取的任何匹配 `Host` 代码块。如果它可以提交任意 `-o` 选项或配置路径，通常就能完全绕过别名审查。接受自由格式 SSH 命令的包装器并不是有意义的边界。

更好的接口是接受一个来自小型允许列表的目标标识符，并将它映射到固定的账户、主机名、端口和连接方式。标识符可以是 `production-readonly`，但底层 SSH 参数不应来自代理生成的字符串。

## Shell 展开会把配置变成输入边界

由于 SSH 会通过 shell 传递 `ProxyCommand`，配置中的引号不会提供结构化进程参数所能提供的同等安全属性。shell 语法会一直保留到 shell 对其进行处理为止。命令文本中的空格、通配符、变量引用、重定向和命令替换，都可能影响结果。

有风险的模式是辅助程序根据收到的值自行构造 shell 命令。考虑下面的配置：

```
Host *
    ProxyCommand sh -c 'relay --target %h --port %p --token \"$RELAY_TOKEN\"'
```

这里有几个彼此独立的问题。每个可能解析出的主机名是否只包含符合预期的主机名语法？`relay` 是否会把 `--target` 解析为一个值？用户控制的环境能否设置 `RELAY_TOKEN` 或修改搜索路径？外层 shell 在启动 `relay` 之前，是否会收到包含改变语义的字符的命令字符串？仅仅说主机名来自 SSH，并不能回答这些问题。

百分号标记不是安全的模板系统。OpenSSH 会在许多选项中展开 `%h`、`%p`、`%r` 和 `%n` 等标记。`%n` 是原始主机参数，而 `%h` 是 SSH 经过配置处理后实际使用的主机名。这一区别很容易被忽略。使用 `%n` 的代理命令可能接收到用户友好的别名或任意请求文本，而作者原本以为得到的是规范 DNS 名称。使用 `%h` 的命令仍可能看到经过 `HostName` 或规范化处理改变的值。

修复方案通常没有原始设置那么复杂。使用一个小型专用辅助程序，固定可执行文件路径，为它规定受限语法，并让它拒绝超出该语法的所有内容。让辅助程序调用连接 API 或使用参数数组的进程启动器，而不是再拼接一条 shell 命令。如果必须使用 shell 脚本，请在插值前验证输入，为该 shell 中的每个展开值加引号，并将允许的输入范围限制到审计人员可以测试的程度。

不要用 `eval` 代替解析。团队会选择它，是因为它让配置驱动的包装器看起来更灵活，但漏掉一处引号，就会变成本地执行缺陷。灵活性应放在经过审查的配置格式中，而不是放在会重新解析文本的 shell 中。

## ProxyJump 少了一个 shell，但没有消除信任问题

`ProxyJump`，通常写作 `-J` 或 `ProxyJump`，会要求 SSH 通过一个或多个 SSH 跳板主机访问目标。对于普通的堡垒机路由，优先使用它，而不是手写一个只负责启动另一个 `ssh -W` 命令的 `ProxyCommand`。它能直接描述预期的网络拓扑，也不会把拓扑放进自定义 shell 命令中。

例如：

```
Host build-private
    HostName build.internal.example
    User deploy
    ProxyJump bastion-admin

Host bastion-admin
    HostName bastion.example
    User relay
```

这比包含嵌套引号的字符串更容易检查，但仍需要仔细审查。客户端会向跳板主机进行身份验证，跳板主机会参与路由，因此它的名称、账户、主机密钥、多因素认证要求、转发规则和网络可达性都会影响这项操作。遭到入侵或配置错误的跳板主机可能暴露流量模式，也可能重定向连接尝试。每一跳 SSH 都必须进行主机密钥验证。

OpenSSH 允许同时设置这两种选项，但两者同时生效时，`ProxyCommand` 的优先级高于 `ProxyJump`。这是一个危险的配置陷阱。审查人员可能在特定主机代码块中看到清晰的 `ProxyJump`，但更早或更晚匹配的宽泛规则可能提供了代理命令。不要根据某一个代码块推断结果，应使用 `ssh -G` 验证最终生效的配置。

多个跳板主机也需要明确理由。不要默认把链路当成额外安全性。每增加一台主机，就增加了一次凭据使用、一次主机密钥决策和一个审计位置。只有网络路径确实需要时才使用链路，并记录每一跳预期使用的账户。

## Include 和 Match exec 可以在选择配置时执行本地逻辑

`Include` 让 SSH 配置可以组合，这很有用，但如果仓库、配置管理工具或安装程序把片段放进范围过宽的目录中，情况就会变得危险。OpenSSH 会在 `Include` 中展开 glob 模式，因此意外出现的文件可能改变某台主机的设置，而无需修改主配置。要审查被包含目录的所有权和写入权限，不要只查看它们当前的内容。

`Match` 增加了条件。`Match host`、`user`、`localnetwork` 以及相关条件会改变哪些选项生效。`Match exec` 尤其需要警惕：SSH 会运行指定的本地命令，并根据其退出状态判断代码块是否匹配。

这个例子清楚地展示了边界：

```
Match exec \"/usr/local/bin/on-corporate-network\"
    ProxyJump corp-bastion
```

如果辅助程序由 root 所有、路径固定，并且只返回简单结果，这个条件可能合理。但如果它调用 shell、读取可写项目文件、连接网络服务，或依赖 `PATH`，就会变得脆弱。不要因为计划运行 `ssh -G`，就直接在充满活动凭据的工作站上测试未知的 `Match exec` 规则。先检查命令，必要时使用隔离账户或隔离机器。

OpenSSH 手册还记录了匹配条件和命令相关选项中的标记展开。这意味着 `Match exec` 条件可能依赖请求中的主机或用户文本。不要让代理选择的值进入这些条件。如果条件需要上下文数据，应从所有权严格受限的可信本地状态文件中获取，而不是使用跨越代理边界的字符串。

## 事故通常始于一个方便的辅助程序

现实中的故障不需要看起来恶意的配置。想象一下，一名开发人员多年前添加了下面的配置，用于访问私有测试网络：

```
Host test-*
    ProxyCommand connect-testnet %h %p
```

`connect-testnet` 最初由启动脚本安装在个人 bin 目录中。脚本会调用公司 VPN 客户端，然后启动通过 `PATH` 找到的中继命令。后来，一个构建代理以该开发人员的账户运行，并可以请求 `ssh test-cache`。预期操作是在测试机器上执行一条只读命令。

第一步，SSH 匹配 `test-*`。第二步，本地 shell 启动 `connect-testnet`。第三步，脚本启动 VPN 客户端，以及当前通过 `PATH` 查找到的可执行文件。只有完成这些本地步骤后，SSH 才会开始与指定主机进行协议通信。只记录远程命令的审计，会遗漏改变本地机器状态和选择网络路由的行为。

缺陷不在于禁止 shell 脚本，而在于把本地便利链当成远程服务的一部分。代理能够选择 `test-cache`，就等于选择了本地代码。恶意代理可能会寻找匹配更宽泛模式的别名，但普通代理只要猜到一个恰好存在的名称，也能触发同样的问题。

修复真正的执行路径。实际可行时，用明确列出的目标替换通配符。将中继辅助程序放在绝对路径下。删除其中的 `PATH` 查找。让辅助程序只接受预期的主机和端口值。如果 VPN 连接管理不需要针对每次 SSH 操作执行，就将它从按请求运行的代理命令中移出。然后，以代理实际使用的账户和环境测试最终配置。

## 审批连接方式，而不只是目标主机

审批流程必须展示足够的信息，让人能够识别这项操作。当别名会启动代理辅助程序或经过堡垒机时，`ssh deploy@build-private` 提供的上下文并不完整。审查人员需要看到最终目标、账户、端口、代理方式和跳转路径。否则，他们审批的只是一个标签，只能希望本地配置仍然和上周表达相同的含义。

要把团队经常混在一起的三个决定分开。第一，这个代理进程是否可以请求任何 SSH 操作？第二，它是否可以使用这个特定的 SSH 身份？第三，这个目标是否可以使用这种特定的本地连接方式？前两个问题的答案为是，并不自动意味着第三个问题的答案也是是。代理辅助程序可能会进入不同的信任区域，或产生直接连接不会产生的本地副作用。

Sallyport 将 SSH 凭据隔离在代理之外，并使用内置的 `sp-ssh` 辅助程序执行 SSH 操作，但这并不会把本地 SSH 配置变成安全的策略边界。限制代理的目标接口，并在凭据操作开始前检查所有参与其中的客户端配置和辅助进程。

对于敏感路径，要求每次使用 SSH 身份都进行审批，并让审批说明包含目标和路由。这样可以发现别名发生变化、出现意外的堡垒机，或有人试图在常规维护路径之外使用它。这不会让含糊的审批卡片变得有用，信息必须明确展示出来。

## 使用一次性凭据测试最终路径

配置审查需要执行测试，但要使用无法破坏生产环境的凭据。使用测试主机、临时账户和受控环境。如果操作系统允许，记录客户端进程树和网络目标。目的是确认启动了哪个可执行文件、它收到哪些参数，以及它是否在预期路径之外建立了连接。

对于大多数主机别名，下面这套简短的审查流程已经足够：

1. 运行 `ssh -G alias`，保存最终的 `hostname`、`user`、`port`、`proxycommand`、`proxyjump` 和身份设置。
2. 追踪每个 `Include`，以及提供这些值的每个匹配 `Host` 或 `Match` 代码块。
3. 阅读代理或匹配路径中的每个本地可执行文件，包括脚本及其配置文件。
4. 使用一次性凭据运行连接，验证实际的跳板主机、主机密钥和本地进程。
5. 测试后撤销测试凭据，并在别名旁记录预期路由。

不要只依赖连接成功。成功只能证明字节到达了某个 SSH 服务器，不能证明承载这些字节的是正确程序、接收它们的是预期主机，也不能证明之前没有发生本地副作用。

这里有一点阻力是合理的。如果没人能解释 ProxyCommand 背后的可执行文件，就不应该授权代理调用这个别名。替换它、删除它，或在有人负责之前，将这条路径排除在代理可访问范围之外。

## 小型 SSH 操作面胜过复杂的客户端配置

面向代理的最安全 SSH 设置应包含少量明确命名的目标、固定身份、明确的跳板主机，以及不允许任意配置标志。实现这些目标不需要通用策略语言，只需要受限接口和一份在审查时始终保持简单的配置。

出现以下变化时应重新审查：SSH 客户端更新、新的启动脚本、配置管理部署、新增包含目录、新的代理运行环境，或新的跳板主机。这些变化经常分散在不同仓库中，正因如此，连接路径才会在无人察觉的情况下逐渐失控。

最终标准保持简单：在远程命令运行前，你应该能够说清楚 SSH 启动的每个本地可执行文件、它联系的每台主机、它使用的每个身份，以及批准这条路径的人。如果一个别名无法做到这一点，就还没有准备好用于自主操作。
