SSH 环境转发会泄露本地上下文吗?
SSH 环境转发可能把本地上下文暴露给远程命令。请审查 SendEnv 与 AcceptEnv,并用测试验证精确的变量白名单。

SSH 环境转发可能泄露远程任务根本不需要的大量本地上下文。通道虽然经过加密,但加密只保护传输过程,并不会让 AWS_PROFILE、GIT_AUTHOR_EMAIL、租户名称或内部功能开关自动成为另一台机器上进程的合理输入。
我把每个转发变量都视为远程命令的参数。因此,每个变量都应有明确名称、用途、负责人和测试。从适合个人笔记本的 SSH 配置中复制通配符不符合这个标准,因为只要有人新增一个匹配的本地变量,它的含义就会改变。
安全目标不是空白的远程环境。sshd、登录账户、shell 和包装脚本都会建立自己的基础环境。更准确的目标是:客户端只提供书面约定中的工作流变量,服务器不接受更宽的集合,并且测试同时证明必需值能够到达、诱饵值会被拒绝。
只有 SSH 两端都同意,转发才会发生
OpenSSH 环境转发有两道门。客户端的 SendEnv 从本地进程环境中选择变量名,客户端 SetEnv 则直接提供 NAME=VALUE。服务器的 AcceptEnv 决定哪些客户端变量进入会话环境。通常只有客户端发送且服务器接受的名称才会通过。
RFC 4254 描述了底层机制。shell 或命令启动前,客户端可以发送类型为 env 的 SSH 通道请求,其中包含一个变量名和一个值。该 RFC 明确警告,不受控制地设置特权进程环境会带来安全风险,并建议维护允许列表,或等服务器进程降低权限后再设置变量。这不是后来附加的加固建议,而是协议规范本身的提醒。
OpenSSH 手册还有一个常让审计人员困惑的例外:客户端请求伪终端时,协议需要 TERM,所以它始终会被发送和接受。自动化不需要终端时应使用 ssh -T。这样可从这条路径中去掉终端行为、交互式启动意外和 TERM。
这套握手机制带来四个结论。
- 宽泛的
SendEnv面对拒绝所有变量的服务器时暂时不起作用,但管理员一旦扩大AcceptEnv,它就会生效。 - 宽泛的
AcceptEnv面对谨慎客户端时暂时不起作用,但任何获准用户都能换一份客户端配置发送匹配值。 - 当不同人员控制两端配置时,任何一端都无法单独落实完整约定。
- 远程命令成功,并不能说明服务器拒绝过哪些环境请求。
最后一点尤其重要。OpenSSH 可以忽略变量后继续会话。如果部署之所以成功,是因为远程 shell 本来就定义了同名变量,只检查最终值的测试可能把一个从未转发的值误认为转发结果。
通配符会把未来的本地状态变成远程输入
SendEnv WORKFLOW_* 并不表示“今天审查过的变量”,而是未来每个 SSH 客户端进程环境中所有匹配名称。几周后,开发者可能在 shell 配置中加入 WORKFLOW_DEBUG_DUMP、WORKFLOW_CUSTOMER 或 WORKFLOW_TOKEN_FILE,旧规则会悄悄获得新行为。
区域设置最能说明问题。许多工作站会发送 LANG 和 LC_*,以便交互式会话正确显示文本。这对人工登录可能合理,却不应自动进入非交互构建或部署账户。区域设置会改变排序、字符分类、日期格式和诊断文本。解析输出的任务可能失败,即使没有任何秘密泄露。
风险较高的模式通常看起来像方便的命名空间:
AWS_*可能包含配置文件名、区域、凭据相关值和配置路径。GIT_*可能包含身份、跟踪设置、备用对象目录或 askpass 行为。CI_*常把无害的构建标签、平台上下文和临时路径混在一起。LC_*看似只是显示设置,直到脚本依赖固定排序或消息。APP_*会随应用增长,通常没有单一安全含义。
不要把整个命名空间归类为安全,应逐个判断名称。工作流可能需要 DEPLOY_REGION,但 AWS_PROFILE 只是工作站上下文;可能需要 BUILD_REF,而 GIT_CONFIG_COUNT 会改变 Git 的运行配置。共同前缀便于整理,却不是信任边界。
即使不是凭据,值也会暴露上下文。路径会泄露用户名和仓库布局,配置文件名会指明账户或环境,跟踪开关可能让远程工具把命令数据写入共享日志。代理稍后还可能利用这些信息。泄露包括未经计划的影响和披露,不只包括秘密字符串。
看似安全的例外可能在几个月后突然扩大
这类泄露往往由配置漂移造成,而不是一次明显冒险的修改。开发者先为需要 APP_COLOR=0 的测试加入 SendEnv APP_*,服务器当时拒绝它。后来管理员为同一共享主机上的另一支团队加入 AcceptEnv APP_*。两次评审都只看到握手的一半,因此各自看起来无害。
下一次连接会把两条沉睡规则接起来。开发者环境中已有 APP_CUSTOMER=acme-lab、APP_TRACE=1 和 APP_CONFIG=/Users/lee/work/private/config。客户端发送全部变量,守护进程全部接受。发布失败后,诊断包装脚本执行 env,并把输出写入组内可读日志。没有令牌被复制,但本地身份、客户上下文、路径和跟踪开关都未经批准越过边界。
删除服务器通配符只能修复后续会话,不能删除旧日志,也不能确定哪些命令看过这些值。应把发现当作一个小型事件处理:
- 停止接受宽泛模式,并验证守护进程配置。
- 找出两端模式同时存在期间涉及的账户、客户端和时间范围。
- 在获准的远程日志中搜索变量名,不要搜索敏感值。
- 判断每个值是否改变了命令行为,或向其他用户披露了上下文。
- 用精确名称替换通配符,并为被拒绝类别增加诱饵。
因此,“服务器目前会拒绝它”不能为宽泛客户端规则辩护。沉睡配置在生效时通常没有明确负责人。即使服务器很严格,也要删掉客户端无用的发送规则;即使今天的客户端很谨慎,也要删掉服务器无用的接受规则。
反向漂移也会发生。服务器长期为交互用户接受 LC_*,后来自动化镜像更新加入发送 LC_* 的系统配置。部署任务继承开发者选择的 LC_COLLATE,文件顺序随之改变。这可能不是披露事件,却是同一握手导致的完整性故障。应把保密性和命令行为放在同一次审查中。
检查 SSH 实际采用的配置
编辑配置文件前,先读取客户端的最终配置。OpenSSH 的 ssh -G 会应用 Host、Match、include、用户配置和系统配置,然后打印指定目标的结果。看起来最权威的文件可能被更早的值覆盖,也可能从 include 中获得额外规则。
请在与工作流相同的账户和执行环境中运行:
ssh -G deploy-prod |
awk '$1 == "sendenv" || $1 == "setenv" { print }'
一种典型的不安全结果如下:
sendenv LANG
sendenv LC_*
sendenv AWS_*
setenv WORKFLOW_KIND=deploy
ssh -G 打印的是有效选择规则,而不是 SendEnv 选中的当前值,因此不会在审查输出中倾倒凭据。但只有把这些名称或模式与真正启动 SSH 的进程环境对照,审查才算完整。
只收集名称,不收集值:
env | sed 's/=.*//' | LC_ALL=C sort > local-env.names
grep -E '^(LANG|LC_|AWS_|WORKFLOW_)' local-env.names
上面的正则只是检查工具,不是可复用策略。应按 ssh -G 中的所有模式调整它。如果 SSH 由监督进程、IDE、代理或计划任务启动,请检查那个进程的环境,而不是交互式 shell。环境沿进程树继承,从错误的父进程测试会得到干净却无关的答案。
服务器必须单独检查。OpenSSH 服务器可用 sshd -T 验证并打印有效设置;如果 Match 块影响该账户,还要提供连接条件:
sudo sshd -T \
-C user=deploybot,host=deploy.example,addr=192.0.2.44 |
grep '^acceptenv'
审查时换成真实用户、主机和客户端地址,示例中的文档地址只应留在示例里。重新加载前还要运行 sudo sshd -t。语法检查代价很低,收紧环境规则不值得以远程访问中断为代价。
专用客户端文件让允许列表一目了然
自动化最可靠的做法,是用 -F 指定一份小型专用 SSH 配置。ssh 手册说明,显式文件会替代常规用户配置,并使系统级客户端文件被忽略。这样,工作站软件或集中配置日后新增的区域通配符不会进入工作流。
Host deploy-prod
HostName deploy.example
User deploybot
IdentityFile ~/.ssh/deploy_ed25519
IdentitiesOnly yes
RequestTTY no
SendEnv DEPLOY_REGION
SendEnv BUILD_REF
SetEnv WORKFLOW_KIND=deploy
明确指定它:
ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
SendEnv 从本地 ssh 进程环境读取值,适合每次运行都会合理变化的输入。客户端 SetEnv 在配置中写死一个值,适合非秘密常量标签,不适合凭据,也不适合容易忘记更新的值。
OpenSSH 也允许用带 - 前缀的 SendEnv 模式清除之前选择的名称,可用于修复已有的分层配置:
Host deploy-prod
SendEnv -*
SendEnv DEPLOY_REGION BUILD_REF
清理只影响当时已累积的选择,后续 include 或系统规则仍可能重新加入名称。因此我不会把减法当作最终边界。通过 -F 使用的专用文件组成更少,其他工程师一屏就能理解。
凭据不应进入这份约定。转发的令牌会变成普通远程进程状态,可能进入子进程、调试输出、崩溃报告、权限允许时的 /proc 检查,或意外的 env 输出。SSH 保护传输,不保护值到达后的整个生命周期。
服务器应只为有限账户接受精确名称
服务器是会话启动前拒绝客户端变量的最后位置。除伪终端下的 TERM 外,OpenSSH 默认不接受变量。应保留这个全局默认值,只为工作流专用账户增加精确名称。
Match User deploybot
AcceptEnv DEPLOY_REGION
AcceptEnv BUILD_REF
AcceptEnv WORKFLOW_KIND
不要使用 AcceptEnv APP_*、AcceptEnv AWS_* 或裸通配符。OpenSSH 的 sshd_config 手册明确警告,某些环境变量可能绕过受限用户环境。今天的名称看起来无害并不能消除风险,因为通配符会在没有服务器变更的情况下批准未来名称。
宽泛的全局 AcceptEnv 会削弱所有匹配账户。新增更窄的 Match User 块不会删除全局已接受名称,因为这些条目可以累积。如果交互用户需要区域设置而部署账户不需要,不要从全局区域规则开始;应只为目标用户或组开放,并用 sshd -T -C 检查每类连接。
服务器 SetEnv 是另一项控制。它为子会话设置值,并覆盖默认值以及来自 AcceptEnv 或 PermitUserEnvironment 的值。服务器拥有某个常量时应使用它。如果每次运行都必须采用同一个服务器批准值,就不要再从客户端接受该值。
PermitUserEnvironment 又是另一条来源。它控制 ~/.ssh/environment 和 authorized_keys 中的 environment=,OpenSSH 默认关闭它。只检查 AcceptEnv 的审计会漏掉这条路径。除非账户设计明确需要,否则保持关闭;若启用,则把相关模式纳入同一份约定。
精确名称允许列表只控制名称,不验证值。允许的 DEPLOY_REGION 仍可能包含空格、shell 元字符、换行或禁止的区域。SSH 协议把它作为数据传输,但接收脚本可能通过 eval、未加引号的展开、生成配置或命令字符串把它变成语法。
应在远程入口验证每个允许值:
case ${DEPLOY_REGION-} in
us-east-1|us-west-2) ;;
*)
printf 'invalid DEPLOY_REGION\n' >&2
exit 64
;;
esac
case ${BUILD_REF-} in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
;;
*)
printf 'invalid BUILD_REF\n' >&2
exit 64
;;
esac
验证必须符合真实约定。这个短 shell 模式只演示形状检查;如果发布要求完整提交标识,就应检查精确长度并确认仓库确实包含它。不要让验证器宽松到只会拒绝空字符串。
修改后应检查有效结果,而不是相信文件缩进:
sudo sshd -t
sudo sshd -T \
-C user=deploybot,host=deploy.example,addr=192.0.2.44 |
grep -E '^(acceptenv|permituserenvironment|setenv)'
把输出随配置评审保存。它记录守护进程对特定连接真正解析出的设置,比 include 目录中某个片段的截图更有用。
在远程证明必需值到达且诱饵被拒绝
有效测试应在远程进程内同时检查正例和反例。为每个必需值设置不会混淆的标记,再设置能匹配旧宽泛模式的诱饵,通过专用文件启动非交互会话,并让远程 shell 在约定不符时失败。
下面只允许客户端提供 DEPLOY_REGION、BUILD_REF 和固定的 WORKFLOW_KIND:
export DEPLOY_REGION='env-audit-region'
export BUILD_REF='env-audit-ref'
export AWS_PROFILE='must-not-cross'
export LC_AUDIT_CANARY='must-not-cross'
export APP_PRIVATE_PATH='must-not-cross'
ssh -F ./deploy-ssh.conf -T deploy-prod 'sh -s' <<'REMOTE'
set -eu
test "$(printenv DEPLOY_REGION)" = 'env-audit-region'
test "$(printenv BUILD_REF)" = 'env-audit-ref'
test "$(printenv WORKFLOW_KIND)" = 'deploy'
for name in AWS_PROFILE LC_AUDIT_CANARY APP_PRIVATE_PATH; do
if printenv "$name" >/dev/null 2>&1; then
printf 'unexpected forwarded variable: %s\n' "$name" >&2
exit 1
fi
done
printf '%s\n' 'SSH environment contract passed'
REMOTE
预期输出只有一行:
SSH environment contract passed
再取消一个必需的本地值进行反向测试。SendEnv 无法发送本地不存在的名称,因此远程断言应该失败:
unset BUILD_REF
if ssh -F ./deploy-ssh.conf -T deploy-prod 'test -n "$BUILD_REF"'; then
printf '%s\n' 'test failed: BUILD_REF appeared unexpectedly' >&2
exit 1
fi
这能区分可选转发值与必需输入。如果没有 BUILD_REF 就必须停止,还应在 SSH 前用 ${BUILD_REF:?BUILD_REF is required} 让启动器失败。远程检查仍不能省,因为它验证传输约定并能发现服务器拒绝。
诱饵应覆盖审计发现的每个通配符或可疑类别,并使用显然是假的值。不要把真实凭据放进泄露测试,因为失败可能一边证明问题,一边把凭据写进日志。
一次成功不能证明所有想象得到的变量都无法通过。它只证明在某份配置和某个进程环境下,列出的反例被拒绝。还要结合两项静态检查:ssh -G 中只能有精确 SendEnv 名称,sshd -T -C 中只能有预期 AcceptEnv 名称。只要出现 * 或 ?,测试矩阵就无法列举未来环境新增的名称。
应从每种不同启动器运行测试。开发者 shell、CI 工作器、编辑器任务和自治代理即使调用同一二进制,也可能使用不同环境与配置路径。把专用文件路径写进启动器,不要依赖只存在于交互 shell 的别名。
观察最终环境和证明 SSH 的贡献也不同。假设 /etc/profile 已设置 DEPLOY_REGION=us-east-1,客户端又发送同样值,即使服务器拒绝请求,正向断言也会通过。应使用远程基础环境不可能已有的唯一标记,并执行缺失输入测试,才能同时发现被拒绝的必需值与意外的远程默认值。
这项测试并不声称远程环境只有三个变量。正常 OpenSSH 会话还包含服务器建立的 HOME、USER、SHELL、PATH 和 SSH 连接元数据,启动脚本或包装层也可能增加其他值。断言证明的是客户端贡献部分符合约定。若命令还需要最小化整个环境,应另行审计服务器基础环境。
命令构造可以完全绕过 SendEnv
清空 SendEnv 并不能阻止本地 shell 把变量展开到远程命令字符串中。这是另一条数据路径:
ssh deploy-prod "release '$TENANT' '$TOKEN'"
本地 shell 在 ssh 启动前替换两个值。它们位于加密的命令请求中,而不是 env 请求中,因此 AcceptEnv 无法拒绝。根据启动器如何构造和记录命令,它们还可能出现在进程检查、shell 历史、CI 日志或错误消息中。
下面的前缀会改变本地 SSH 进程环境,但只有配置选择该名称时才会跨越:
DEPLOY_REGION=west ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
团队经常混淆这两种情况。“SSH 运行时它在环境里”不能证明环境转发发送了它;“我们关闭了 SendEnv”也不能证明包装脚本没有把它插入参数或标准输入。必须追踪真实通道。
远程启动代码还会形成其他来源。登录 shell、/etc/environment、PAM、强制命令、sudo、服务管理器或容器启动器,都可能在 SSH 接受变量后删除、替换或新增值。标记缺失时,可用 ssh -vvv 查看客户端发送决定,在获准范围查看服务器日志,并运行位于应用包装层之前的微型命令。不要立刻扩大 AcceptEnv,那通常会掩盖真正修改值的层。
sudo 需要单独检查。sudoers 可以重置大多数环境、保留指定名称或允许调用者请求保留,但它只影响特权子进程收到的内容。值此前已经进入非特权 SSH 会话,shell、审计钩子和包装脚本都可能看到它。应按特权命令需要配置 sudoers,但在这一层之前保持 SSH 允许列表最窄。
远程主机也不是被动管道。控制远程账户的人通常可以打印进程环境,管理员更控制整台机器。转发本地值从设计上就是把它披露给远程信任域。如果安全要求是远程主机绝不能知道某个值,就不要以任何形式通过 SSH 发送。
不要把完整 env 倾倒到日常日志中。获准审计时先记录名称,只有假标记才显示值。完整输出会把无关变量变成审计材料,甚至制造本来要避免的事件。
代理运行的 SSH 需要更小的进程边界
自治代理常继承启动它的终端、编辑器或编排器环境。这个环境为跨仓库、跨账户工作的人准备,并非为一项远程动作准备。把其中的命名空间发送到远程主机,会给代理更多上下文,也会给远程工具提供无人审查的输入。
启动工作流时既要指定明确的本地环境,也要指定明确的 SSH 配置。一个最小包装脚本可以要求两个变化值,丢弃无关继承状态,只恢复 SSH 客户端确实需要的内容:
#!/bin/sh
set -eu
: "${DEPLOY_REGION:?DEPLOY_REGION is required}"
: "${BUILD_REF:?BUILD_REF is required}"
exec env -i \
HOME="$HOME" \
PATH='/usr/bin:/bin:/usr/sbin:/sbin' \
DEPLOY_REGION="$DEPLOY_REGION" \
BUILD_REF="$BUILD_REF" \
ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
应按操作系统调整 PATH 和所需输入,并保持列表显式。示例保留 HOME,因为 OpenSSH 可能需要 known_hosts 和身份文件路径。如果托管运行器通过其他选项提供这些文件,也应移除 HOME。
在 Mac 上让代理操作 SSH 时,Sallyport 可以把 SSH 密钥保存在加密保管库中,并通过附带的 sp-ssh 执行,因此代理不会拿到凭据。这改变的是凭据保管方式,并不能替代明确的远程环境约定;仍应围绕代理获准运行的命令保留精确名称测试。
不要为方便而转发本地云凭据。如果远程动作需要访问其他服务,应给远程工作负载自己的窄权限身份,或让保存凭据的网关执行动作。把操作者的环境凭据复制进远程进程会捆绑两个信任边界,也让撤销难以解释。
配置变化后也要让约定保持可执行
环境规则会漂移,因为客户端包加入默认值、管理员合并配置片段、工作流获得新输入。文字规范发现不了这类变化。专用客户端文件、服务器账户配置和诱饵断言应一起进入评审。
以下任一方面变化后都要运行断言:
- SSH 客户端或操作系统软件包
- 用户与系统 SSH 配置 include
sshd_config、PAM、shell 启动文件或强制命令- 代理启动器、CI 运行器、服务管理器或容器镜像
- 工作流必需输入列表
把新增变量当作接口变更。记录远程命令为什么需要它、客户端还是服务器拥有它、是否包含敏感上下文,以及哪项测试证明它不会出现在其他地方。如果没人能回答,应把值作为经过验证的命令输入,或重新设计远程动作,而不是扩大通配符。
失败必须清楚。转发出错后悄悄使用远程默认值的部署,比因缺少标记直接停止更难诊断,也更容易误用。必需输入应同时在本地启动器和远程入口处失败。
最后还要从远程侧审查账户。完美的 SendEnv 列表也保护不了能运行任意 shell、读取其他用户环境或重写启动文件的部署账户。环境转发只是一条输入通道。让它精确、用测试证明行为,并把远程命令权限限制到即使出现意外值也无法转化为无关动作。
常见问题
SSH 默认会发送全部本地环境变量吗?
不会。OpenSSH 只发送 SendEnv 选择的名称和客户端 SetEnv 设置的值,服务器通常还必须用 AcceptEnv 接受。伪终端会对 TERM 特殊处理。
SendEnv 与 SetEnv 有什么区别?
SendEnv 从本地 SSH 进程读取变量当前值。客户端 SetEnv 在配置中定义固定 NAME=VALUE,适合非秘密常量标签,不适合每次变化的输入。
AcceptEnv 能收到客户端没有发送的变量吗?
不能。AcceptEnv 只是允许客户端请求,不会创建值。同名变量仍可能来自服务器配置、PAM、启动脚本或包装层,因此修改 SSH 前要确认来源。
转发 LANG 和 LC_* 安全吗?
交互账户可能需要,但自动化通常不需要这么宽。区域值会改变排序、解析和诊断,应在服务器固定行为,或只接受确实必需的精确名称。
能否删除继承的 SendEnv 规则?
可以用带 - 前缀的 SendEnv 模式清除之前选择。后续规则仍可能重新加入名称,因此自动化使用 ssh -F 指定专用文件更容易审计。
怎样查看某主机实际采用的 SendEnv?
从工作流账户运行 ssh -G host,筛选 sendenv 和 setenv。它显示匹配和 include 后的有效规则,但不会显示当前值。
为什么远程主机缺少转发变量?
本地可能未设置,客户端可能未选择,服务器可能拒绝,后续启动层也可能删除。按顺序检查每道门,并先用假标记测试,不要盲目扩大 AcceptEnv。
ssh -T 会停止所有环境转发吗?
不会。-T 只禁用伪终端,因此去掉 TERM 的特殊路径和交互行为。其他被客户端选择且服务器接受的名称仍可通过。
环境变量适合传递 SSH 凭据吗?
不适合。凭据一旦被接受,就成为远程进程状态,可能进入子进程、日志、诊断和检查界面。应使用远程身份或负责保管凭据的网关。
多久应测试一次 SSH 环境转发?
客户端包、SSH 配置、守护进程、启动器、镜像或工作流输入变化时都应运行诱饵断言。把它放进常规评审,让宽泛规则在生产前失败。