SSH 原理与报文 — 三层架构、二进制包格式与握手流程
SSH 传输层/认证/连接三层协议、Binary Packet 格式、密钥交换与通道机制 / 应用层 / TCP 22 / RFC 4253
先建立直觉
SSH 做的事只有一句话:在一条谁都能偷听的网络上,安全地操作另一台电脑。
打个比方:你要在人来人往的广场上跟远处的朋友说悄悄话。SSH 的做法分三步——先当着所有人的面商量出一套只有你俩懂的暗号(这个过程巧妙到即使被全程围观,旁人也推不出暗号);接着你核对对方的笔迹确认他是本人,他也确认你是本人;从此你们说的每句话,别人只能听见"有人在说话",听不懂内容。
而且这条悄悄话通道一旦建立,就不只能聊天——还能顺手传文件,甚至让朋友帮你把消息转交给他身边的其他人。
它解决什么问题
早年管理远程服务器用的是 Telnet:你敲的用户名、密码、每一条命令、服务器返回的每一行输出,全都以明文在网线上跑。同一个网段里任何一个人打开抓包工具,root 密码就到手了。
更糟的是,你根本无法确认"对面那台机器是不是你要连的那台"。有人在中途冒充服务器,你会毫无察觉地把密码送给攻击者。
还有一个现实痛点:内网里的数据库、管理后台通常不对外开放,你人在外面就够不着。总不能为了访问一个数据库,就把它直接暴露到公网上。
SSH 一次性把这三件事解决了:先加密再传任何东西、用主机密钥确认对方身份、在同一条加密连接里顺带打隧道。
工作流程(简化版)
- 互报家门:TCP 连上之后,双方各发一行明文版本串,说明"我是谁、什么版本"。
- 商量用什么加密:双方各自列出支持的算法清单,按规则挑出共同支持的一套。
- 换密钥并验服务器:通过密钥交换算出只有双方知道的共享密钥;同时服务器用自己的主机私钥签名,客户端拿
known_hosts里的记录验签,确认"还是上次那台机器"。从这一刻起全程加密。 - 验你是谁:在已加密的通道里做用户认证——公钥、密码、2FA 等方式任选。
- 开通道干活:认证通过后按需开通道,跑 shell、跑 sftp、跑端口转发,多个通道可以并行,最后逐个关闭并断开。
以上每一步涉及的消息编号、字段、算法名称,都在下面各节展开。
1. 工作原理
SSH 由三个相互叠加的子协议构成,这是理解 SSH 的第一把钥匙:
| 层 | RFC | 职责 |
|---|---|---|
| 传输层协议(Transport Layer Protocol) | RFC 4253 | 建立加密通道:版本交换 → 算法协商 → 密钥交换 → 服务端主机认证 → 加密与完整性保护 → 定期 rekey |
| 用户认证协议(Authentication Protocol) | RFC 4252 | 在已加密通道内认证客户端用户:publickey / password / keyboard-interactive / gssapi / hostbased |
| 连接协议(Connection Protocol) | RFC 4254 | 在认证后的连接上开多个通道(channel):session(shell/exec/subsystem)、direct-tcpip(本地转发)、forwarded-tcpip(远程转发)、x11 |
关键设计要点:
- 先加密,后认证。与 Telnet 相反,SSH 在任何凭据传输之前就已完成密钥交换,因此口令认证也不会明文暴露(但仍不如公钥安全,因为口令会到达服务端)。
- 服务端认证靠主机密钥。密钥交换过程中服务端用主机私钥对交换哈希
H签名,客户端用known_hosts里记录的公钥验签,从而确认"对面确实是上次那台机器"。 - 每方向独立密钥。同一次 KEX 派生出 6 个值:两个 IV、两个加密密钥、两个 MAC 密钥(C→S 与 S→C 各一套)。
- 首次会话哈希 = session_id。
H在首次 KEX 后固定为session_id,后续认证签名、rekey 派生都绑定它,防止跨会话重放。
报文 / 头部长什么样
2.1 版本交换(唯一的明文行)
看这段前先记住:这是整个 SSH 会话里唯一"人眼可读"的一行——之后所有内容都会被加密。也正因为它明文,扫描器才能用它判断你跑的是哪个版本、有没有已知漏洞。
连接建立后双方各发一行 ASCII,以 CR LF 结尾:
格式:SSH-protoversion-softwareversion SP comments CRLF。这一行在抓包中完全可见,是指纹识别与版本探测的依据。
2.2 二进制包协议(Binary Packet Protocol,RFC 4253 §6)
看这张表前先记住:SSH 把每条消息都装进一个统一格式的"信封"——先说多长、再塞内容、然后补一段随机垃圾把长度凑整(免得别人从包长猜出你敲了什么),最后盖个防篡改的封条。
| 字段 | 长度 | 含义 |
|---|---|---|
packet_length(包长度) | 4 字节 | 后续内容长度(不含本字段、不含 MAC)。AEAD 模式下此字段单独加密或作为 AAD |
padding_length(填充长度) | 1 字节 | 随机填充长度,至少 4 字节 |
payload(有效载荷) | 可变 | 消息内容(可能已被 zlib 压缩) |
random padding(随机填充) | padding_length 字节 | 随机字节,使总长为 8 或加密块大小的整数倍(隐藏真实长度) |
MAC(Message Authentication Code,消息认证码) | 0 / 16 / 32 字节 | 消息认证码,覆盖 sequence_number ‖ 未加密包(EtM 模式则覆盖密文) |
packet_length + padding_length + payload + padding的总长必须 ≥ 16 字节且是块大小的整数倍。默认最大包长 35000 字节。
2.3 关键消息类型(SSH_MSG_*,RFC 4250)
看这张表前先记住:编号是有分段规律的——20 出头是"谈加密",50 开头是"验身份",90 开头是"管通道"。记住这三段,看抓包时就能一眼判断会话进行到哪一步了。
| 编号 | 名称 | 用途 |
|---|---|---|
| 1 | SSH_MSG_DISCONNECT(断开) | 断开并带原因码 |
| 2 | SSH_MSG_IGNORE(忽略) | 流量填充/保活 |
| 5 / 6 | SSH_MSG_SERVICE_REQUEST / ACCEPT(服务请求/接受) | 请求 ssh-userauth 或 ssh-connection 服务 |
| 20 | SSH_MSG_KEXINIT(密钥交换初始化) | 算法协商(双方各发一次,列出支持的算法) |
| 21 | SSH_MSG_NEWKEYS(启用新密钥) | 切换到新密钥,之后的包全部用新密钥 |
| 30 / 31 | SSH_MSG_KEX_ECDH_INIT / REPLY(ECDH 交换初始/回复) | ECDH 密钥交换(曲线/DH 各有对应编号,30~49 为 KEX 专用) |
| 50 | SSH_MSG_USERAUTH_REQUEST(用户认证请求) | 认证请求(携带方法名与凭据) |
| 51 | SSH_MSG_USERAUTH_FAILURE(认证失败) | 认证失败,返回仍可继续尝试的方法列表 |
| 52 | SSH_MSG_USERAUTH_SUCCESS(认证成功) | 认证成功 |
| 60 | SSH_MSG_USERAUTH_PK_OK(公钥可用) | 公钥可用(探测阶段响应) |
| 80~82 | GLOBAL_REQUEST / REQUEST_SUCCESS / FAILURE(全局请求及结果) | 全局请求,如 tcpip-forward(远程转发) |
| 90 | SSH_MSG_CHANNEL_OPEN(打开通道) | 打开通道(session / direct-tcpip / x11) |
| 91 / 92 | CHANNEL_OPEN_CONFIRMATION / FAILURE(通道打开确认/失败) | 通道打开结果 |
| 93 | SSH_MSG_CHANNEL_WINDOW_ADJUST(通道窗口调整) | 通道级流控窗口调整 |
| 94 / 95 | CHANNEL_DATA / CHANNEL_EXTENDED_DATA(通道数据/扩展数据) | 通道数据 / 扩展数据(stderr,type=1) |
| 96~98 | CHANNEL_EOF / CLOSE / REQUEST(通道结束/关闭/请求) | 通道结束/关闭/请求(pty-req、shell、exec、subsystem、env、window-change、exit-status) |
2.4 SSH_MSG_KEXINIT 的算法名单(10 个 name-list)
看这张表前先记住:这就是双方交换的"我会哪些加密手艺"清单。抓包时它是明文的,所以你能直接看出对面支持什么、不支持什么——排查"算法不匹配"类报错全靠它。
| 字段 | 示例值 |
|---|---|
cookie(随机数) | 16 字节随机数(防止一方单独决定交换哈希) |
kex_algorithms(密钥交换算法) | curve25519-sha256, ecdh-sha2-nistp256, diffie-hellman-group16-sha512, sntrup761x25519-sha512@openssh.com(后量子混合) |
server_host_key_algorithms(服务端主机密钥算法) | ssh-ed25519, rsa-sha2-512, rsa-sha2-256, ecdsa-sha2-nistp256 |
encryption_algorithms_client_to_server / ..._server_to_client(双向加密算法) | chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-ctr |
mac_algorithms_c2s / s2c(双向 MAC 算法) | hmac-sha2-256-etm@openssh.com, hmac-sha2-512(AEAD 时该项被忽略) |
compression_algorithms_c2s / s2c(双向压缩算法) | none, zlib@openssh.com |
languages_c2s / s2c(语言) | 通常为空 |
first_kex_packet_follows(是否已乐观发送首包) | 是否已乐观发送 KEX 首包 |
协商规则:取客户端列表中第一个、服务端也支持的算法(客户端偏好优先)。
2.5 会话密钥派生(RFC 4253 §7.2)
看这段前先记住:双方并不是共用一把钥匙,而是一次生成六把——收发各一套(IV、加密密钥、MAC 密钥)。这样即使某个方向出问题,也不会波及另一个方向。字母 A~F 就是六把钥匙的编号。
共享密钥 K 与交换哈希 H 得出后:
密钥不够长时用 HASH(K ‖ H ‖ 已生成部分) 迭代扩展。
交互时序
一句话看懂这张图:只有前两步(版本串、算法清单)是明文的,
NEWKEYS之后连你的用户名都看不见了;剩下的全部动作——验身份、开通道、跑命令——都在加密壳子里进行。
| |
关键机制 / 变体
4.1 主机密钥与 TOFU 信任模型
这是干什么的:解决"我怎么知道对面这台服务器是真的"——SSH 不用 CA 证书链,而是"第一次见面就把对方长相记下来,以后对不上就报警"。
- 服务端主机密钥存于
/etc/ssh/ssh_host_{ed25519,rsa,ecdsa}_key(.pub)。 - 客户端首次连接显示指纹并写入
~/.ssh/known_hosts(可HashKnownHosts yes哈希主机名)。 - 之后主机密钥变化 →
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!,可能是重装系统,也可能是中间人攻击。 - 规避 TOFU 的两种工程化方案:SSHFP DNS 记录(需 DNSSEC)与 SSH 证书(用 CA 签发主机证书与用户证书,
TrustedUserCAKeys/@cert-authority)。
4.2 公钥认证的签名内容
这是干什么的:说明"为什么私钥不用上网、签名也不怕被人捡去重用"——因为签的内容里绑死了本次会话的标识。
客户端签名的不是随机 challenge,而是一段确定性拼接:
因为绑定了 session_id(含本次 KEX 的临时密钥),签名无法跨会话重放,服务端也无法拿它去冒充你登录第三方(这正是 ForwardAgent 需要谨慎的原因——转发的是 agent 的签名能力,不是签名本身)。
4.3 三种端口转发的通道类型
这是干什么的:把 SSH 从"远程登录工具"变成"轻量 VPN"——三种方向分别解决"够不着内网"、“内网服务要暴露出去”、“想让浏览器整体走内网"三类需求。
| 形态 | 命令 | 通道类型 | 数据流向 |
|---|---|---|---|
| 本地转发 | ssh -L 3306:db:3306 gw | direct-tcpip | 本地监听 → 经 SSH → 服务端向 db:3306 发起连接 |
| 远程转发 | ssh -R 8080:localhost:80 gw | tcpip-forward(全局请求)+ forwarded-tcpip | 服务端监听 → 经 SSH → 客户端向本地 80 发起连接 |
| 动态转发 | ssh -D 1080 gw | direct-tcpip(按 SOCKS 请求动态创建) | 本地 SOCKS5 代理 → 目标由应用指定 |
4.4 通道级流控(Window)
这是干什么的:防止"一条通道堵车,整条连接瘫痪”——多个通道挤在一条 TCP 上,必须给每个通道单独发通行配额。
TCP 已有流控,SSH 为何还要?因为一条 TCP 上复用多个通道,若某通道读取缓慢会阻塞整条连接。SSH 给每个通道一个初始窗口(OpenSSH 默认 2 MB)与最大包长(32 KB),接收方消费数据后发 CHANNEL_WINDOW_ADJUST 补充配额。窗口过小是高带宽时延积(长肥管道)链路上 SSH 吞吐上不去的常见原因,HPN-SSH 补丁即针对此优化。
4.5 密钥重协商(Rekey)
这是干什么的:给密钥设"保质期"——一把钥匙用太久、加密太多数据都会增加被破解的风险,所以中途换一把。
OpenSSH 默认在传输 1 GB 数据或经过 1 小时后触发 rekey(RekeyLimit),重新执行 KEX 产生新会话密钥,限制单密钥的数据量、增强前向保密。
4.6 常见安全加固点
这是干什么的:把协议能力落到实际配置上——每一行都是在堵一个真实存在过的攻击路径。
| 风险 | 加固 |
|---|---|
| 口令爆破 | PasswordAuthentication no,仅公钥;配 fail2ban |
| root 直登 | PermitRootLogin no 或 prohibit-password |
| 弱算法降级 | 显式配置 KexAlgorithms / Ciphers / MACs 白名单,禁用 ssh-rsa(SHA-1)、CBC、hmac-sha1 |
| Agent 转发被滥用 | 避免 ForwardAgent yes,改用 ProxyJump(ssh -J) |
| 端口转发被当跳板 | AllowTcpForwarding no、PermitOpen host:port、GatewayPorts no |
| 中间人 | 分发 known_hosts 或部署 SSH CA;核对 SHA256 指纹 |
| Terrapin 攻击(CVE-2023-48795) | 升级 OpenSSH ≥ 9.6(支持 strict KEX),或禁用 chacha20-poly1305 与 -etm CBC 组合 |
常见误区
误以为"SSH 加密了,所以抓包什么都看不到"。版本串(
SSH-2.0-OpenSSH_9.6p1...)和SSH_MSG_KEXINIT里的完整算法清单都是明文的,只有SSH_MSG_NEWKEYS(21)之后才全部加密。误以为公钥认证时私钥会传给服务器。私钥始终不出本机:客户端只是用私钥对一段包含
session_id的数据签名,服务端用authorized_keys里的公钥验签。误以为主机密钥变更告警一定是被攻击了。服务器重装、迁移、更换负载均衡后端、容器重建都会导致变更。正确做法是带外核对指纹,而不是直接关掉校验。
误以为
ForwardAgent(-A)和ProxyJump(-J)差不多。-A把签名能力暴露给跳板机,跳板机被攻陷就等于你的身份被借用;-J只把跳板机当 TCP 隧道,认证仍在本地完成。优先用-J。误以为 TCP 有流控了,SSH 的通道窗口是多余的。一条 TCP 上复用多个通道,只靠 TCP 流控会让某个慢通道拖垮全部通道,所以需要
CHANNEL_WINDOW_ADJUST做通道级隔离。误以为
-R远程转发默认就能被外网访问。默认只绑127.0.0.1,要对外开放需服务端配置GatewayPorts yes。
速记口诀
- 三层口诀:「先建管、再验人、后开道」——4253 传输层建加密管道,4252 认证层验用户,4254 连接层开通道。
- 握手口诀:「报版本、亮清单、换密钥、验主机、切新钥」——对应版本交换、KEXINIT、ECDH、主机签名、NEWKEYS 五步。
- 六密钥口诀:「A B 向量、C D 加密、E F 校验,收发各一」。
- 转发口诀:「L 拉进来、R 推出去、D 全都走」——
-L把远端服务拉到本地,-R把本地服务推到远端,-D起 SOCKS5 全局代理。
知识框架
| |