SSH 原理与报文 — 三层架构、二进制包格式与握手流程

SSH 传输层/认证/连接三层协议、Binary Packet 格式、密钥交换与通道机制 / 应用层 / TCP 22 / RFC 4253

先建立直觉

SSH 做的事只有一句话:在一条谁都能偷听的网络上,安全地操作另一台电脑。

打个比方:你要在人来人往的广场上跟远处的朋友说悄悄话。SSH 的做法分三步——先当着所有人的面商量出一套只有你俩懂的暗号(这个过程巧妙到即使被全程围观,旁人也推不出暗号);接着你核对对方的笔迹确认他是本人,他也确认你是本人;从此你们说的每句话,别人只能听见"有人在说话",听不懂内容。

而且这条悄悄话通道一旦建立,就不只能聊天——还能顺手传文件,甚至让朋友帮你把消息转交给他身边的其他人。

它解决什么问题

早年管理远程服务器用的是 Telnet:你敲的用户名、密码、每一条命令、服务器返回的每一行输出,全都以明文在网线上跑。同一个网段里任何一个人打开抓包工具,root 密码就到手了。

更糟的是,你根本无法确认"对面那台机器是不是你要连的那台"。有人在中途冒充服务器,你会毫无察觉地把密码送给攻击者。

还有一个现实痛点:内网里的数据库、管理后台通常不对外开放,你人在外面就够不着。总不能为了访问一个数据库,就把它直接暴露到公网上。

SSH 一次性把这三件事解决了:先加密再传任何东西用主机密钥确认对方身份在同一条加密连接里顺带打隧道

工作流程(简化版)

  1. 互报家门:TCP 连上之后,双方各发一行明文版本串,说明"我是谁、什么版本"。
  2. 商量用什么加密:双方各自列出支持的算法清单,按规则挑出共同支持的一套。
  3. 换密钥并验服务器:通过密钥交换算出只有双方知道的共享密钥;同时服务器用自己的主机私钥签名,客户端拿 known_hosts 里的记录验签,确认"还是上次那台机器"。从这一刻起全程加密。
  4. 验你是谁:在已加密的通道里做用户认证——公钥、密码、2FA 等方式任选。
  5. 开通道干活:认证通过后按需开通道,跑 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_idH 在首次 KEX 后固定为 session_id,后续认证签名、rekey 派生都绑定它,防止跨会话重放。

报文 / 头部长什么样

2.1 版本交换(唯一的明文行)

看这段前先记住:这是整个 SSH 会话里唯一"人眼可读"的一行——之后所有内容都会被加密。也正因为它明文,扫描器才能用它判断你跑的是哪个版本、有没有已知漏洞。

连接建立后双方各发一行 ASCII,以 CR LF 结尾:

1
2
SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5
SSH-2.0-PuTTY_Release_0.80

格式: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 开头是"管通道"。记住这三段,看抓包时就能一眼判断会话进行到哪一步了。

编号名称用途
1SSH_MSG_DISCONNECT(断开)断开并带原因码
2SSH_MSG_IGNORE(忽略)流量填充/保活
5 / 6SSH_MSG_SERVICE_REQUEST / ACCEPT(服务请求/接受)请求 ssh-userauthssh-connection 服务
20SSH_MSG_KEXINIT(密钥交换初始化)算法协商(双方各发一次,列出支持的算法)
21SSH_MSG_NEWKEYS(启用新密钥)切换到新密钥,之后的包全部用新密钥
30 / 31SSH_MSG_KEX_ECDH_INIT / REPLY(ECDH 交换初始/回复)ECDH 密钥交换(曲线/DH 各有对应编号,30~49 为 KEX 专用)
50SSH_MSG_USERAUTH_REQUEST(用户认证请求)认证请求(携带方法名与凭据)
51SSH_MSG_USERAUTH_FAILURE(认证失败)认证失败,返回仍可继续尝试的方法列表
52SSH_MSG_USERAUTH_SUCCESS(认证成功)认证成功
60SSH_MSG_USERAUTH_PK_OK(公钥可用)公钥可用(探测阶段响应)
80~82GLOBAL_REQUEST / REQUEST_SUCCESS / FAILURE(全局请求及结果)全局请求,如 tcpip-forward(远程转发)
90SSH_MSG_CHANNEL_OPEN(打开通道)打开通道(session / direct-tcpip / x11
91 / 92CHANNEL_OPEN_CONFIRMATION / FAILURE(通道打开确认/失败)通道打开结果
93SSH_MSG_CHANNEL_WINDOW_ADJUST(通道窗口调整)通道级流控窗口调整
94 / 95CHANNEL_DATA / CHANNEL_EXTENDED_DATA(通道数据/扩展数据)通道数据 / 扩展数据(stderr,type=1)
96~98CHANNEL_EOF / CLOSE / REQUEST(通道结束/关闭/请求)通道结束/关闭/请求(pty-reqshellexecsubsystemenvwindow-changeexit-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 得出后:

1
2
3
4
5
6
IV C→S = HASH(K ‖ H ‖ "A" ‖ session_id)
IV S→C = HASH(K ‖ H ‖ "B" ‖ session_id)
EncKey C→S = HASH(K ‖ H ‖ "C" ‖ session_id)
EncKey S→C = HASH(K ‖ H ‖ "D" ‖ session_id)
MACKey C→S = HASH(K ‖ H ‖ "E" ‖ session_id)
MACKey S→C = HASH(K ‖ H ‖ "F" ‖ session_id)

密钥不够长时用 HASH(K ‖ H ‖ 已生成部分) 迭代扩展。

交互时序

一句话看懂这张图:只有前两步(版本串、算法清单)是明文的,NEWKEYS 之后连你的用户名都看不见了;剩下的全部动作——验身份、开通道、跑命令——都在加密壳子里进行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
sequenceDiagram
    autonumber
    participant C as 客户端 (ssh)
    participant S as 服务端 (sshd:22)

    Note over C,S: ① TCP 与版本交换(明文)
    C->>S: TCP 三次握手 (22)
    S->>C: SSH-2.0-OpenSSH_9.6p1\r\n
    C->>S: SSH-2.0-OpenSSH_9.6p1\r\n

    Note over C,S: ② 算法协商(明文,可抓到算法清单)
    C->>S: SSH_MSG_KEXINIT (cookie + 10 组算法名单)
    S->>C: SSH_MSG_KEXINIT (cookie + 10 组算法名单)

    Note over C,S: ③ 密钥交换 + 服务端主机认证
    C->>S: SSH_MSG_KEX_ECDH_INIT (客户端临时公钥 Q_C)
    S->>C: SSH_MSG_KEX_ECDH_REPLY<br/>(主机公钥 K_S, 临时公钥 Q_S, sig(H))
    Note right of C: 计算共享密钥 K 与交换哈希 H<br/>用 known_hosts 中的 K_S 验签<br/>首次连接 → 显示 SHA256 指纹询问 (TOFU)
    C->>S: SSH_MSG_NEWKEYS
    S->>C: SSH_MSG_NEWKEYS
    Note over C,S: 此后所有报文加密 + MAC 保护

    Note over C,S: ④ 用户认证(RFC 4252)
    C->>S: SSH_MSG_SERVICE_REQUEST ("ssh-userauth")
    S->>C: SSH_MSG_SERVICE_ACCEPT
    C->>S: USERAUTH_REQUEST (user, "none")
    S->>C: USERAUTH_FAILURE (可用方法: publickey,password)
    C->>S: USERAUTH_REQUEST (publickey, 仅公钥探测)
    S->>C: SSH_MSG_USERAUTH_PK_OK (此公钥在 authorized_keys 中)
    C->>S: USERAUTH_REQUEST (publickey + 对 session_id 的签名)
    Note right of S: 用 authorized_keys 中公钥验签
    S->>C: SSH_MSG_USERAUTH_SUCCESS

    Note over C,S: ⑤ 连接协议:开通道(RFC 4254)
    C->>S: CHANNEL_OPEN ("session", 窗口大小, 最大包长)
    S->>C: CHANNEL_OPEN_CONFIRMATION
    C->>S: CHANNEL_REQUEST ("pty-req": 终端类型/行列/模式)
    C->>S: CHANNEL_REQUEST ("shell") %% 或 exec / subsystem sftp
    S->>C: CHANNEL_SUCCESS

    Note over C,S: ⑥ 数据交换(可并行多通道)
    par 交互式 shell
        C-->>S: CHANNEL_DATA (键盘输入)
        S-->>C: CHANNEL_DATA (stdout) / CHANNEL_EXTENDED_DATA (stderr)
        C<<-->>S: CHANNEL_WINDOW_ADJUST (通道级流控)
    and 端口转发通道
        C->>S: CHANNEL_OPEN ("direct-tcpip", 目标host:port)
    and SFTP 子系统
        C->>S: CHANNEL_REQUEST ("subsystem":"sftp")
    end
    opt 达到数据量/时间阈值
        C->>S: SSH_MSG_KEXINIT (重协商 rekey,默认 1GB 或 1 小时)
    end

    Note over C,S: ⑦ 结束
    S->>C: CHANNEL_REQUEST ("exit-status", 0)
    C->>S: CHANNEL_EOF + CHANNEL_CLOSE
    S->>C: CHANNEL_CLOSE
    C->>S: SSH_MSG_DISCONNECT + TCP FIN

关键机制 / 变体

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,而是一段确定性拼接:

1
2
3
4
5
6
7
8
string session_id
byte SSH_MSG_USERAUTH_REQUEST (50)
string user name
string service name ("ssh-connection")
string "publickey"
boolean TRUE
string public key algorithm name
string public key blob

因为绑定了 session_id(含本次 KEX 的临时密钥),签名无法跨会话重放,服务端也无法拿它去冒充你登录第三方(这正是 ForwardAgent 需要谨慎的原因——转发的是 agent 的签名能力,不是签名本身)。

4.3 三种端口转发的通道类型

这是干什么的:把 SSH 从"远程登录工具"变成"轻量 VPN"——三种方向分别解决"够不着内网"、“内网服务要暴露出去”、“想让浏览器整体走内网"三类需求。

形态命令通道类型数据流向
本地转发ssh -L 3306:db:3306 gwdirect-tcpip本地监听 → 经 SSH → 服务端向 db:3306 发起连接
远程转发ssh -R 8080:localhost:80 gwtcpip-forward(全局请求)+ forwarded-tcpip服务端监听 → 经 SSH → 客户端向本地 80 发起连接
动态转发ssh -D 1080 gwdirect-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 noprohibit-password
弱算法降级显式配置 KexAlgorithms / Ciphers / MACs 白名单,禁用 ssh-rsa(SHA-1)、CBC、hmac-sha1
Agent 转发被滥用避免 ForwardAgent yes,改用 ProxyJumpssh -J
端口转发被当跳板AllowTcpForwarding noPermitOpen host:portGatewayPorts no
中间人分发 known_hosts 或部署 SSH CA;核对 SHA256 指纹
Terrapin 攻击(CVE-2023-48795)升级 OpenSSH ≥ 9.6(支持 strict KEX),或禁用 chacha20-poly1305-etm CBC 组合

常见误区

  1. 误以为"SSH 加密了,所以抓包什么都看不到"。版本串(SSH-2.0-OpenSSH_9.6p1...)和 SSH_MSG_KEXINIT 里的完整算法清单都是明文的,只有 SSH_MSG_NEWKEYS(21)之后才全部加密。

  2. 误以为公钥认证时私钥会传给服务器。私钥始终不出本机:客户端只是用私钥对一段包含 session_id 的数据签名,服务端用 authorized_keys 里的公钥验签。

  3. 误以为主机密钥变更告警一定是被攻击了。服务器重装、迁移、更换负载均衡后端、容器重建都会导致变更。正确做法是带外核对指纹,而不是直接关掉校验。

  4. 误以为 ForwardAgent-A)和 ProxyJump-J)差不多-A 把签名能力暴露给跳板机,跳板机被攻陷就等于你的身份被借用;-J 只把跳板机当 TCP 隧道,认证仍在本地完成。优先用 -J

  5. 误以为 TCP 有流控了,SSH 的通道窗口是多余的。一条 TCP 上复用多个通道,只靠 TCP 流控会让某个慢通道拖垮全部通道,所以需要 CHANNEL_WINDOW_ADJUST 做通道级隔离。

  6. 误以为 -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 全局代理。

知识框架

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
mindmap
  root((SSH 安全外壳))
    三层架构
      传输层 RFC4253
        版本交换
        KEXINIT 算法协商
        密钥交换 ECDH
        主机密钥签名验证
        NEWKEYS 切换
        Rekey 重协商
      认证层 RFC4252
        publickey
        password
        keyboard-interactive
        gssapi-with-mic
        hostbased
        none 探测
      连接层 RFC4254
        session 通道
        direct-tcpip
        forwarded-tcpip
        x11
        通道窗口流控
    报文结构
      packet_length
      padding_length
      payload 可压缩
      random padding
      MAC  AEAD
    密码学
      curve25519-sha256
      ed25519 主机密钥
      chacha20-poly1305
      aes-gcm / aes-ctr
      hmac-sha2-etm
      六密钥派生 A-F
      前向保密 PFS
    信任模型
      主机密钥 TOFU
      known_hosts
      SSHFP DNS 记录
      SSH 证书 CA
    功能应用
      远程 shell  exec
      sftp 子系统
      scp 传输
      本地转发 -L
      远程转发 -R
      动态转发 -D SOCKS5
      ProxyJump 跳板
      X11 转发
      ControlMaster 连接复用
    安全加固
      禁用口令与 root 直登
      算法白名单
      限制转发
      fail2ban
      Terrapin 补丁