94、SSH协议:SSH握手、认证方式、通道复用

SSH,说白了就是给网络通信加了一把锁。你想想看,早期我们用Telnet、rlogin,密码都是明文在网络上跑,跟裸奔没什么区别。我刚开始做网络编程那会儿,有一次在客户机房抓包,看到Telnet会话里root密码清清楚楚,后背一阵发凉。从那以后,但凡涉及远程管理,我脑子里第一反应就是——上SSH。

这一讲,咱们就深入SSH协议的三个核心环节:握手认证通道复用。搞懂这些,你不仅能写出更安全的网络工具,还能理解OpenSSH底层到底在忙活什么。

1. SSH握手:先建信任,再谈别的

SSH握手不是简单的“你好我好”,它要完成三件事:
• 协商加密算法
• 交换密钥
• 验证服务器身份

我习惯把握手分成两个阶段:传输层握手认证层握手。传输层握手搞定加密通道,认证层握手搞定你是谁。

1.1 传输层握手流程

客户端连上服务器22端口后,双方先互发版本号。比如:

SSH-2.0-OpenSSH_8.9p1 Ubuntu-3
SSH-2.0-OpenSSH_8.9p1 Ubuntu-3

版本号对齐后,开始协商算法。协商的内容包括:
• 密钥交换算法(如diffie-hellman-group14-sha256)
• 主机密钥类型(如ssh-rsa、ecdsa-sha2-nistp256)
• 加密算法(如aes256-ctr、chacha20-poly1305)
• MAC算法(如hmac-sha2-256)
• 压缩算法(通常为none)

协商完成后,进入密钥交换阶段。经典的Diffie-Hellman交换,双方各自生成临时密钥对,交换公钥部分,最终计算出共享密钥。这个共享密钥不会在网络上传输,所以即使有人抓包,也拿不到。

关键点:SSH的密钥交换是前向安全的。就算以后服务器私钥泄露,之前记录的会话也无法被解密。我在项目中遇到过客户要求必须支持前向安全,当时用的就是curve25519-sha256算法。

密钥交换完成后,服务器会发送自己的主机公钥,并附上签名。客户端用已知的known_hosts文件验证这个签名。如果是第一次连接,客户端会提示你确认指纹。嗯,这里要注意:千万不要盲目点“yes”,一定要跟服务器管理员确认指纹。

1.2 认证层握手

传输层通道建好后,客户端开始认证。SSH支持多种认证方式,咱们接着往下看。

2. 认证方式:密码、公钥、还有更狠的

SSH认证不是只有密码登录这一种。我个人强烈建议:能不用密码,就别用密码。密码容易被暴力破解,也容易被中间人截获(虽然加密了,但客户端如果被植入木马,密码照样丢)。

2.1 密码认证

最传统的方式。客户端把密码加密后发给服务器,服务器验证。流程简单,但缺点也明显:
• 容易遭受字典攻击
• 密码强度依赖用户习惯
• 无法实现免密登录

我曾经帮一家公司做安全审计,发现他们的SSH服务器每天被尝试登录上万次,全是密码爆破。后来强制改成密钥认证,攻击日志直接清零。

2.2 公钥认证

这才是SSH的精华。流程如下:

  1. 客户端生成密钥对(私钥自己保存,公钥放到服务器~/.ssh/authorized_keys)
  2. 客户端发起认证请求,附上公钥指纹
  3. 服务器检查authorized_keys,找到匹配的公钥
  4. 服务器生成一个随机数,用公钥加密,发给客户端
  5. 客户端用私钥解密,再结合会话ID计算签名,发回服务器
  6. 服务器验证签名,通过则认证成功

避坑指南:我曾经遇到过一个问题:公钥认证死活不通过,查了半天才发现是authorized_keys文件权限不对。SSH要求这个文件必须是600权限,所属目录~/.ssh必须是700权限。少一个都不行。

2.3 键盘交互认证

这种认证方式允许服务器向客户端发送提示,客户端再输入响应。比如:
• 密码 + 动态口令(两步验证)
• 密码 + 安全问题

很多企业级SSH堡垒机就用这种方式实现双因子认证。我在项目中实现过基于PAM的键盘交互认证,配合Google Authenticator,效果不错。

2.4 主机认证

主要用于自动化脚本和集群管理。客户端用自己主机私钥签名,服务器验证客户端主机身份。这种方式在Ansible、SaltStack等工具中很常见。

3. 通道复用:一条连接干多件事

SSH最让我佩服的设计之一,就是通道复用。你想想看,如果你要同时执行远程命令、传输文件、还要做端口转发,难道要开三个SSH连接吗?

当然不是。SSH在一条TCP连接上,可以建立多个通道(channel)。每个通道独立工作,互不干扰。

3.1 通道的类型

通道类型 用途 常见场景
session 执行远程命令或启动shell ssh user@host "ls -l"
direct-tcpip 本地端口转发 ssh -L 8080:target:80 user@host
forwarded-tcpip 远程端口转发 ssh -R 8080:localhost:80 user@host
x11 转发X11图形界面 ssh -X user@host

每个通道都有一个唯一的通道ID,客户端和服务器通过这个ID来区分数据属于哪个通道。数据包格式里会带上通道ID,接收方根据ID把数据分发给对应的处理程序。

3.2 通道复用如何工作

我画了一张图,帮你理解通道复用的逻辑:

SSH客户端 通道1: 远程shell 通道2: SCP文件传输 通道3: 端口转发 SSH服务器 通道1: shell进程 通道2: SCP服务端 通道3: 转发目标 加密TCP连接(复用) 所有通道数据通过同一连接传输 数据包格式 | 通道ID (4字节) | 数据长度 (4字节) | 数据内容 | | 0x0001 | 0x0020 | "ls -l" 命令数据 | | 0x0002 | 0x0400 | 文件块数据 |

你看,客户端和服务器之间只有一条TCP连接,但里面可以跑多个通道。每个通道的数据包都带着自己的ID,接收方根据ID分发。这就是SSH能同时执行命令、传文件、做转发的秘密。

3.3 通道的打开与关闭

通道是动态创建的。比如你执行一条远程命令:

ssh user@host "uptime"

SSH客户端会:
1. 发送一个通道打开请求,类型为session
2. 服务器同意后,分配通道ID,返回确认
3. 客户端通过这个通道发送命令数据
4. 命令执行完毕,服务器关闭通道

通道关闭后,这个ID可以被回收重用。但注意,通道关闭不等于连接关闭。你还可以在同一个连接上继续打开新通道。这就是复用的精髓。

注意事项:通道复用虽然高效,但也有坑。我曾经在写一个SSH客户端时,忘记处理通道关闭事件,导致服务器端资源泄漏。每个通道在服务器端都对应一个进程或线程,如果不及时清理,连接数一多,服务器就挂了。所以,务必在通道关闭时做好资源回收。

4. 实战:用libssh实现一个简单的SSH客户端

光说不练假把式。下面我用libssh库写一个最小SSH客户端,演示握手、公钥认证、执行命令:

#include <libssh/libssh.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    ssh_session my_ssh_session = ssh_new();
    if (my_ssh_session == NULL) {
        fprintf(stderr, "ssh_new failed\n");
        return -1;
    }

    // 设置服务器地址和端口
    ssh_options_set(my_ssh_session, SSH_OPTIONS_HOST, "192.168.1.100");
    ssh_options_set(my_ssh_session, SSH_OPTIONS_PORT, &(int){22});
    ssh_options_set(my_ssh_session, SSH_OPTIONS_USER, "root");

    // 连接并握手
    int rc = ssh_connect(my_ssh_session);
    if (rc != SSH_OK) {
        fprintf(stderr, "连接失败: %s\n", ssh_get_error(my_ssh_session));
        ssh_free(my_ssh_session);
        return -1;
    }

    // 验证服务器主机密钥(这里简化,实际应检查known_hosts)
    ssh_key server_key;
    ssh_get_server_publickey(my_ssh_session, &server_key);
    unsigned char *hash;
    size_t hlen;
    ssh_get_publickey_hash(server_key, SSH_PUBLICKEY_HASH_SHA256, &hash, &hlen);
    printf("服务器公钥指纹: ");
    ssh_print_hash(SSH_PUBLICKEY_HASH_SHA256, hash, hlen);
    ssh_clean_pubkey_hash(&hash);
    ssh_key_free(server_key);

    // 公钥认证(自动使用默认私钥 ~/.ssh/id_rsa)
    rc = ssh_userauth_publickey_auto(my_ssh_session, NULL, NULL);
    if (rc != SSH_AUTH_SUCCESS) {
        fprintf(stderr, "公钥认证失败: %s\n", ssh_get_error(my_ssh_session));
        ssh_disconnect(my_ssh_session);
        ssh_free(my_ssh_session);
        return -1;
    }
    printf("公钥认证成功!\n");

    // 打开通道,执行命令
    ssh_channel channel = ssh_channel_new(my_ssh_session);
    if (channel == NULL) {
        fprintf(stderr, "通道创建失败\n");
        ssh_disconnect(my_ssh_session);
        ssh_free(my_ssh_session);
        return -1;
    }

    rc = ssh_channel_open_session(channel);
    if (rc != SSH_OK) {
        fprintf(stderr, "通道打开失败: %s\n", ssh_get_error(my_ssh_session));
        ssh_channel_free(channel);
        ssh_disconnect(my_ssh_session);
        ssh_free(my_ssh_session);
        return -1;
    }

    rc = ssh_channel_request_exec(channel, "uptime");
    if (rc != SSH_OK) {
        fprintf(stderr, "命令执行失败\n");
        ssh_channel_close(channel);
        ssh_channel_free(channel);
        ssh_disconnect(my_ssh_session);
        ssh_free(my_ssh_session);
        return -1;
    }

    // 读取输出
    char buffer[256];
    int nbytes;
    while ((nbytes = ssh_channel_read(channel, buffer, sizeof(buffer), 0)) > 0) {
        fwrite(buffer, 1, nbytes, stdout);
    }

    // 清理
    ssh_channel_send_eof(channel);
    ssh_channel_close(channel);
    ssh_channel_free(channel);
    ssh_disconnect(my_ssh_session);
    ssh_free(my_ssh_session);
    return 0;
}

这段代码虽然简单,但涵盖了SSH的核心流程:连接、握手、认证、通道复用。你可以把它当成一个模板,扩展出更复杂的功能,比如端口转发、SCP传输等。

个人建议:在实际项目中,不要自己从头实现SSH协议。直接用libssh或libssh2这些成熟库。我早期踩过坑,自己解析SSH协议包,结果被各种边界情况折磨得够呛。专业的事交给专业的库,你只需要关注业务逻辑。

5. 总结

SSH协议之所以能成为远程管理的标配,靠的就是这三板斧:
握手:建立加密通道,保证通信安全
认证:多种方式验证身份,灵活又安全
通道复用:一条连接干多件事,高效省资源

你想想看,从最初的Telnet明文传输,到SSH的加密通道+公钥认证+多路复用,这背后是无数工程师对安全性和效率的极致追求。搞懂了这些,你再看OpenSSH的配置、看各种SSH工具的实现,都会有一种“原来如此”的感觉。

嗯,这一讲就到这里。代码可以拿去跑跑看,有问题欢迎交流。

公众号:蓝海资料掘金营,微信deep3321