Skip to content

ssh -A 是把钥匙交给跳板机吗?SSH Agent 转发原理、滥用与更稳的替代

很多运维第一次在多跳登录里用 ssh -A,图的是一个方便:连上跳板机之后,不用再把私钥拷上去、也不用重新输密码,就能直接 git clonessh 内网机器,私钥还老老实实待在本地笔记本里,听起来又省事又安全。恰恰是这种"私钥没离开本机"的错觉,掩盖了 SSH Agent 转发最危险的本质——它虽然没有把私钥文件交出去,却把"替人用私钥签名"的能力,开放给了跳板机上所有人。本文把这条链路拆开:Agent 转发到底转发的是什么、跳板机上的攻击者能拿它做什么、什么时候才真的需要 -A,以及 ProxyJump 和堡垒机本地凭据两种更稳妥的替代。

Agent 转发转发的是什么:一条能签名、不交钥匙的通道

要理解风险,先得说清 ssh-agent 是怎么工作的。你 ssh-add 之后,解密后的私钥并不落盘,而是被 ssh-agent 进程持有在内存里,对外只暴露一个 Unix socket,环境变量 SSH_AUTH_SOCK 指向它。本机的 sshgitscp 需要认证时,不去读私钥文件,而是把认证挑战(challenge)发给这个 socket,ssh-agent 用内存里的私钥算出一个签名返回。整条链路上,私钥明文从头到尾没有离开过 ssh-agent 的进程——这是它安全性的根基。

ssh -A 做的事情,是在这条已有 SSH 连接之上再加一条"通道(channel)",把你本机的 agent socket 映射到远程主机。连上跳板机后,跳板机上会出现一个形如 /tmp/ssh-XXXX/agent.N 的 socket,远程的 SSH_AUTH_SOCK 指向它。此后跳板机上的任何进程要向这个 socket 发起签名请求,请求会顺着 SSH 隧道传回你的笔记本,由本机 ssh-agent 签名,再把签名结果送回。

关键就在这里:私钥文件确实没出门,但"能签名"这件事出门了。 对跳板机而言,可用的不是一把具体的钥匙,而是一个"随便签"的 oracle——你想要认证到哪里、签什么内容,它都能替你算。签名能力一旦暴露,等于钥匙使用权被暴露,剩下的只是时间问题。

跳板机上的 root 能用它做什么

Agent 转发的 socket 归你的用户所有,权限通常已经收到 600。但这挡不住两拨人:同主机的 root,以及任何能写 /proc、能 ptrace、或者干脆能 sudo 到你 uid 的人。他们不需要偷那个 600 的 socket 文件,只要把自己的 SSH_AUTH_SOCK 环境变量指向它,再跑一条 ssh 就够了:

bash
# 假设转发出来的 socket 在 /tmp/ssh-abc123/agent.456
SSH_AUTH_SOCK=/tmp/ssh-abc123/agent.456 ssh git@github.com
ssh-add -L   # 列出 agent 里此刻挂着的所有公钥指纹

第一条命令会顺着这个 socket 借你的私钥完成对 github.com 的认证——只要你的公钥被授权到那个仓库,攻击者就以你的身份进去了,而你本机甚至不会弹任何提示。第二条能快速盘点你现在"间接"暴露了哪些身份,好决定接下来去试哪些目标。攻击者甚至不用 ssh 客户端,写个小脚本直接对 socket 发签名请求,或者用 socat 把 socket 转发到自己的环境里慢慢用,都行。

这类攻击有个专门的名字叫 Agent Hijacking,它和"偷私钥"的后果几乎等价,却更难察觉:没有文件被复制,没有新登录记录标注"异常",日志里只有一条再正常不过的 SSH 会话和几个签名请求。更隐蔽的是,它不需要攻击者一开始就盯着你——只要你有一次 ssh -A 落了地、ForwardAgent 没关,之后任何拿到该机 root 的后来者都能享用这个仍在转发的 agent,直到你这条会话断开为止。

最后还有一层被很多人忽略的风险:转发的 agent 会"顺着"再传递。 如果你在跳板机上又 ssh -A 进了下一台机器,你的 agent 就在第二台机器上又开了一条转发,暴露面翻倍。多跳转发层层叠起来,任何一跳沦陷,整条链上的签名能力都被拿走。

什么时候才是真的需要 -A

把 Agent 转发当成默认选项,是出问题的主要来源。事实上绝大多数"想省事"的场景,都有更稳的办法。判断要不要开,可以用下面这张表:

场景是否该用 -A更稳的做法
跳板机后还有多台内网机器,需要在跳板机上继续 ssh/git通常不需要ProxyJump-J)把 TCP 打通即可,agent 留在本机
在跳板机上临时 git clone/push 一次不需要跳板机上建独立 deploy key,IdentitiesOnly 限定
一条自动化流水线要在中间主机上用你的身份继续认证高风险用受限 key + from=/restrict,或走堡垒机按会话注入凭据
只有你、单用户、无人能拿 root 的私有环境可以接受至少用 ForwardAgent yes 精确到单个 Host,而非全局
远程主机上必须代表你、做需要你本人 key 的交互式签名罕见换成 FIDO/U2F 硬件密钥,签名必须物理触碰

判断的锚点其实只有一句话:你信不信任跳板机上的 root。 信,-A 才勉强可用;不信——而公网跳板、共享堡垒、外包可登录的机器通常都"不可信"——就该用不改动信任边界的方案。

把 ForwardAgent 默认关掉,需要时再按机器放行

-A 当成全局习惯而不是一次按需决策,是风险的主要来源。更稳的做法是在 ~/.ssh/config 里先写死"默认不转发",再给少数真正信得过的机器单独开口:

text
# 默认:所有主机都不转发 agent
Host *
    ForwardAgent no

# 只有这台受信的私有机器放行
Host trusty.internal
    ForwardAgent yes

这样即便哪天手滑打了 ssh -A user@某台机,配置里的 ForwardAgent no 也会把它按下去——转发是否生效,最终以 host 匹配到的配置为准。反过来,只在命令行加 -A 而不动配置,每台新机器、每个新同事都可能成为漏口。

怎么确认一条正在进行的会话到底有没有开转发?-A 本身不留下显眼的提示,最可靠的是登录后看环境变量和 socket:

bash
echo $SSH_AUTH_SOCK                      # 形如 /tmp/ssh-*/agent.* 即说明 agent 被转发过来了
SSH_AUTH_SOCK=$SSH_AUTH_SOCK ssh-add -L  # 列出这条会话能间接使用的公钥指纹

看到 /tmp/ssh-.../agent.N 这类路径,基本可以断定 -A 已生效,本机的钥匙正在为这台远端机器待命。转发的 socket 绑定在这一次 SSH 连接上,会话断开就随之消失;但只要你还挂着连接(比如 tmux 里的长会话),这个签名能力就一直对跳板机开放。与其事后补救,不如在会话建立前把规则写死。

为什么没法再用「每次签名都弹确认」来兜底

早年间有过一个折中:给 ssh-agent 挂上钥匙确认,ssh-add -c 之后每次签名都要本机手动同意,理论上跳板机上的攻击者就没法静默借用你的钥匙。但这层保护在 OpenSSH 8.9 之后被移除了——单次确认可以被 agent 侧的转发轻易绕过,形同虚设。真正能补上"每次签名都要人肉确认"这一格的是 FIDO/U2F 硬件密钥:私钥锁在硬件里,签名动作必须物理触碰设备,跳板机的 root 能转发请求,却怎么也无法替他完成那一下触碰。

这背后是一条更根本的原则:把身份的可信度建立在"钥匙在哪、谁能动它"上,而不是建立在"我这一下操作小不小心"上。 依赖确认弹窗、依赖记得关转发,都不如从机制上让它根本转不过去来得可靠。

服务端也能一刀切:AllowAgentForwarding

客户端之外,SSH 服务器这一侧同样有开关。sshd_config 里的 AllowAgentForwarding 设为 no 之后,无论客户端怎么敲 -A,服务器都不会把 agent socket 映射过来——转发在握手阶段就被拒绝了:

text
# /etc/ssh/sshd_config
AllowAgentForwarding no

这对"有不受信用户登录的机器"尤其有用,比如公共跳板机:管理员可以彻底封掉 agent 转发这条路,逼着用户改走 ProxyJump 或集中入口。它和服务端端口转发的 AllowTcpForwarding(详见 SSH 端口转发 一文)是同一套思路——把危险能力在服务端默认关掉,而不是指望每个客户端自觉。区别在于,AllowAgentForwarding 拦的是"签名能力的暴露",AllowTcpForwarding 拦的是"隧道的搭建";一台机器即便为了支持 -J 放开了代理跳转,也未必要开放 agent 转发,两者应分开权衡。

ProxyJump:只借道,不借身份

大多数"多跳登录"用 ProxyJump 就能替代,这也是 OpenSSH 官方推荐的默认姿势。写法很简单:

bash
ssh -J jump.example.com 10.0.0.5

或者在 ~/.ssh/config 里:

text
Host jump
    HostName jump.example.com
    User admin

Host db.internal
    HostName 10.0.0.5
    ProxyJump jump

-J-A 的本质区别在于——它转发的是 TCP 连接,不是认证能力。 数据包照样经跳板机中转,但你的 ssh-agent 从不暴露到跳板机;最终目标主机上做的认证,由你本机的客户端直接完成,跳板机全程只是搬运字节,碰不到签名。即便跳板机上的 root 完全沦陷,他能做的也只是记录流经的加密流量,无法借用你的私钥去认证到别处。

代价也在这里:-J 要求你本机持有能认证到最终目标的方式。如果你的私钥只放在跳板机上、本机没有,那 -J 就帮不上忙,得回到"远程身份放在哪儿"这个更根本的问题上,而不是用 -A 去妥协。

把凭据收在服务端,而不是转发进隧道

-A 之所以危险,根子在于一个错误的前提:你的私钥必须待在本地,于是为了跨越多跳,不得不把它"签名能力"沿途转发。换个角度,如果远程主机的凭据本身就放在服务端、由统一的入口按会话注入,你既不需要在跳板机上继续认证,也不需要开任何 agent 转发——登录入口替你完成到目标资产的认证,你只在本机保留通往入口这一步的身份。

这正是堡垒机这类集中接入点的思路:Next Terminal 这类开源的接入入口,把跳板、准入认证和资产凭据管理收敛在一处,连接内网资产时凭据由服务端持有、按需注入,整个过程不依赖客户端侧的 ssh -A。同时配合会话录像与审计(见 合规与审计),谁、在什么时候、认证进入了哪台资产都能追到记录;集中代理与准入的细节见 SSH 代理服务器资产访问,跨网络统一入口见 安全网关SSH 网关

退一步讲,即便你坚持单机自管,真正该做的也是把"远程身份"切片:每台目标机器发一把独立的、带 restrictfrom=IP 限制的 key,而不是一把万能 key 走天下再靠 -A 到处转发。Agent 转发的使用频率,应当和你在它的暴露面上失去的东西成正比——多数场景下,这个账算不过 ProxyJump 和集中入口。

换个角度看,agent 转发被滥用往往不是某个人疏忽,而是默认的"能用就转"替他做了错误决定。把默认值从"能用"改回"安全",比教育每一个人记得关转发更管用。