SSH 协议

本页目录

SSH 把“不可信网络上的远程操作”拆成三个问题:连接的是哪台主机,远端接受哪个用户,已认证用户能打开哪些功能。理解这三个边界,比背诵一条密钥交换流程更有助于定位 Host key verification failed、Permission denied 和转发被拒绝之间的区别。

层次完成的工作成功后仍未保证什么
Transport算法协商、主机认证、加密和完整性、换钥用户可以登录
User Authentication证明用户身份并满足认证策略所有会话功能都被授权
Connection多路复用 channel、流控、会话和转发请求应用操作一定完成或持久化
SSH 的三道边界:可信主机、获准用户、获准功能 Transport:协商算法、交换密钥、验证主机 签名证明持有私钥;known_hosts / 主机 CA 决定是否信任 User Authentication:证明用户身份 认证方法由双方策略选择;可能需要多种方法共同完成 Connection:打开 channel 并申请具体功能 登录成功不保证 shell、SFTP、端口转发全部获准 初次交换的 H 固定为 session ID;后续 rekey 更新传输密钥,不重置身份绑定。

密钥交换:共享秘密、交换摘要与主机密钥

SSH 通常先建立 TCP,再发送 SSH-2.0-软件标识 与 CRLF 结束符。版本行不是加密内容;随后使用二进制包交换 KEXINIT,分别协商密钥交换、主机密钥及两个方向的传输算法。算法列表的交集和顺序会影响结果,不能把某一个 OpenSSH 版本的默认值当作 SSH 协议的唯一算法。

以 ECDH 类交换说明:客户端、服务端各生成临时密钥,共同计算共享秘密 K;交换摘要 H 则覆盖版本信息、双方 KEXINIT、服务端主机公钥、临时交换值及 K 等规定字段。服务端用主机私钥签署 H,客户端验签,以此把协商过程与这把主机密钥绑定。H 不是简单给共享秘密改名,也不是直接用作流量加密密钥。

只有第一次密钥交换的 H 被固定为 session ID。 后续 rekey 产生新的 K、H 和传输密钥,session ID 保持不变。双方分别发送 NEWKEYS,在对应发送和接收方向切换密钥,不能把它理解为一条全局开关同步改变两个方向。RFC 4253 §7–9

现代实现还支持混合后量子密钥交换。例如 OpenSSH 10.0 将 mlkem768x25519-sha256 设为默认首选;这是密钥交换的变化,不意味着主机认证也自动变成同一种算法。另一方面,禁用 ssh-rsa 签名通常指禁用 RSA/SHA-1,并非所有 RSA 公钥失效;同一 RSA 密钥可以使用 RSA/SHA-2 签名,前提是双方支持并允许。OpenSSH 发布说明

验签通过,为什么还要 known_hosts

攻击者也可以生成自己的密钥,并正确签署自己的交换。验签只能证明对方持有所提供公钥的私钥;要确认“这是我要连接的那台主机”,还需要将主机名与可信密钥或可信签发者绑定。

信任来源如何建立绑定需要维护的内容
预置主机公钥经可信渠道把公钥写入 known_hosts主机变化、换钥、撤销
首次使用信任 TOFU首次核对或接受后保存,后续比较第一次接受的可信程度
OpenSSH 主机证书对匹配主机范围信任 CA,验证证书约束CA、principal、有效期及撤销
DNS SSHFP在符合配置与验证条件时利用 DNS 中的指纹DNSSEC 验证链与记录维护

因此首次信任可以由配置分发或主机 CA 自动完成,不是“SSH 唯一不能自动化的一步”。ssh-keyscan 收集的只是网络上得到的公钥,单独使用不构成可信验证;清空旧记录也不能解释一次主机密钥变化。ssh 主机认证、VerifyHostKeyDNS

OpenSSH 证书是 SSH 自己的证书格式,不是 TLS 的 X.509 证书。主机证书回答服务器身份问题,用户证书回答登录者身份问题;信任某个主机 CA 不意味着这个 CA 签发的任意用户都获得登录权限。

用户认证:签名请求与“部分成功”

publickey 认证签署的数据包括 session ID 和规定的用户认证请求字段,把证明绑定到当前连接和请求身份。客户端可以先询问服务端是否接受某把公钥,再发送签名;收到 SSH_MSG_USERAUTH_PK_OK 只是认可该候选,不是登录已经完成。完整成功由 SSH_MSG_USERAUTH_SUCCESS 表示。RFC 4252 §5、7

方法或机制实际作用
publickey验证对对应私钥的控制,同时检查该用户的授权规则
password在已保护的 SSH 传输中提交密码;服务端仍接触密码
keyboard-interactive通用挑战/应答,可承载 OTP、PAM 等交互
用户证书为公钥增加 CA 签名、principal、有效期和约束,并仍需私钥证明

TOTP 并非基础 SSH 用户认证协议中一个直接同名的方法。认证尝试顺序也不是协议规定的“公钥、OTP、密码”;客户端偏好、服务端允许的方法和多因素策略共同决定过程。服务端可报告某个方法已经部分成功但还需另一种方法;这与私钥错误导致的完全失败不同。

OpenSSH 的 AuthenticationMethods publickey,keyboard-interactive 可以表达需要两步的策略,但第二步到底是否为 TOTP 还取决于服务端交互认证配置。authorized_keys 中的密钥也可以附带命令或转发限制;拥有一把被认可的密钥不等于获得不受限的 shell。sshd_config

二进制包:先有边界,才有消息

基础传输包包括 4 B packet_length、1 B padding_length、payload、随机 padding,以及按算法定义处理的完整性数据。packet_length 不包含自己的 4 B,也不包含传统格式尾部的 MAC;padding 至少 4 B。不要将这个基础描述机械套到所有 AEAD 或 Encrypt-then-MAC 算法的加密范围上。

下面构造初始未加密阶段的 SSH_MSG_IGNORE 教学包:消息类型 1 B,字符串长度 4 B,hello 5 B,共 10 B payload。按 8 B 对齐,5 B 包头加 10 B payload 需要 9 B padding,完整包为 24 B,packet_length 为 20。

import struct
payload = bytes([2]) + struct.pack('>I', 5) + b'hello'
padding = 4
while (5 + len(payload) + padding) % 8:
    padding += 1
# 固定字节仅用于可重复的编码演示;真实发送使用所要求的随机 padding。
packet = struct.pack('>IB', 1 + len(payload) + padding, padding)
packet += payload + bytes(range(padding))
assert len(payload) == 10 and padding == 9
assert len(packet) == 24
assert struct.unpack('>I', packet[:4])[0] == 20
assert packet[5] == 2 and packet[10:15] == b'hello'
print('基础 SSH 包:payload 10 B,padding 9 B,总长 24 B')

TCP 的一次读取可能只拿到部分包,也可能包含多个包。接收端必须按长度积累并验证,而不是以 socket 读取次数判断 SSH 消息数。RFC 4253 §6

Channel:独立编号和窗口,共享 TCP

session channel 可以申请 shell、exec 或 subsystem;SFTP 是 subsystem 中的一种应用协议。端口转发则常用 direct-tcpip 或 forwarded-tcpip。这不是“channel 0 永远是 shell、channel 1 永远是转发”的固定编号表。

双方分别分配本地 channel ID。若客户端打开时使用 7,服务端确认并分配 42,则客户端后续给服务端的数据用 recipient 42,服务端发回的数据用 recipient 7。编号只在相应连接和端点内有意义。

每个方向都有接收窗口和最大 channel 数据包大小,发送量同时受两者限制。接收方处理并释放缓冲后,使用 WINDOW_ADJUST 增加对方可发送额度;stdout 和扩展数据如 stderr 也要遵守 channel 窗口,不能无限堆积。

window, max_packet, queued = 65536, 32768, 90000
sent = []
while window and queued:
    n = min(window, max_packet, queued)
    sent.append(n)
    window -= n
    queued -= n
assert sent == [32768, 32768] and queued == 24464
assert window == 0  # 此时必须等待该方向的窗口补充。
window += 16384
n = min(window, max_packet, queued)
assert n == 16384 and queued - n == 8080
print('窗口耗尽暂停,WINDOW_ADJUST 只恢复对应额度')

独立窗口使一个慢 channel 不必吃光其他 channel 的应用缓冲,却不能消除底层 TCP 丢包造成的队头阻塞。CHANNEL_EOF 表示该方向不再发送数据,反向仍可继续;CHANNEL_CLOSE 才参与关闭整个 channel。命令的 exit-status 是另一类 channel 请求,不能用“TCP 正常关闭”替代业务退出码。RFC 4254 §5–6

发送给42,为什么回包却写7?

方向箭头改变时,recipient取对端保存的本地编号。把窗口调大不会突破32768的单包上限;把待发数据调小也不需要凑满窗口。这三个量有不同的管理者和生命周期。

正在呈现知识画面
发送给42,为什么回包却写7?

channel编号由两端各自分配,发送使用对端编号。单次数据量同时受方向窗口、最大包与待发量限制。

转发:谁监听,谁解析目标,哪里终止加密

端口转发:L / R 指监听位置,目标由另一端连接 -L 127.0.0.1:8080:internal.example:80 SSH 客户端 客户端监听 :8080 SSH 服务端 服务端连接 internal:80 SSH 加密段 -R 127.0.0.1:3000:localhost:22 SSH 客户端 客户端连接 localhost:22 SSH 服务端 服务端监听 :3000 SSH 加密段 箭头表示被转发连接的发起方向;SSH 底层连接仍由客户端连向服务端。

下面两条是按需使用的命令示例;gateway.example、internal.example 需要替换为自己的主机。

# 本机 8080 接入,经 gateway 连接 internal.example:80。
ssh -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:8080:internal.example:80 user@gateway.example

# gateway 的回环地址 3000 接入,经 SSH 客户端连接本机 22。
ssh -N -o ExitOnForwardFailure=yes \
  -R 127.0.0.1:3000:localhost:22 user@gateway.example

-L 中目标名字通常由 SSH 服务端解析并连接;上述 -R 中的 localhost 则是 SSH 客户端那一侧。监听地址限制谁能接入,不决定目标在哪一侧。远程监听能否绑定非回环地址还受服务端 GatewayPorts 等策略影响。

SSH 加密终止在 SSH 的两端;服务端再连接 internal.example:80 的一段不自动获得 SSH 加密。如果目标运行 HTTPS,应用 TLS 可继续保护到目标服务。-N 不请求远程命令,ExitOnForwardFailure 可检测监听等转发建立失败,却不保证未来每次连接目标都成功。ssh 转发选项

需求机制边界
固定 TCP 服务-L / -R映射连接,不是完整 IP VPN
应用选择多个 TCP 目标-D 动态 SOCKS应用需使用代理;不是通用 UDP 转发
经跳板登录目标主机-J ProxyJump仍需验证目标主机身份
远端调用本地签名代理agent forwarding私钥不直接复制,但远端可在授权范围内请求签名

ProxyJump 通常不需要把 agent 转发给跳板:客户端可经跳板转发的 TCP 通道,与最终目标建立自己的 SSH 连接。agent forwarding 的作用不同,不能把它当作“私钥文件没离开本机,所以远端不能使用身份”。ssh agent 与跳板

按阶段定位,而不是统一归因“密钥不对”

现象优先核对
连不上 TCP地址、端口、路由、服务监听及连接策略
无共同算法报错所属 KEX、主机签名或传输算法类别
主机验证失败目标名称、端口映射、可信记录与计划内换钥
用户认证失败登录用户名、候选身份、服务端方法和授权约束
登录后功能被拒绝subsystem、命令限制、转发策略与目标连接
传输卡住或连接中断channel 背压、TCP 丢包、空闲状态及超时

只想检查命令解析时,可用 ssh -G -F /dev/null 输出不读取个人配置的有效选项;-G 不建立 SSH 会话。实际连接调试时再用 -v 或 -vvv 对照失败阶段。保活用于发现不响应的连接,不是业务处理完成证明;重连后也不能假定之前启动的命令没有执行。