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的精华。流程如下:
- 客户端生成密钥对(私钥自己保存,公钥放到服务器~/.ssh/authorized_keys)
- 客户端发起认证请求,附上公钥指纹
- 服务器检查authorized_keys,找到匹配的公钥
- 服务器生成一个随机数,用公钥加密,发给客户端
- 客户端用私钥解密,再结合会话ID计算签名,发回服务器
- 服务器验证签名,通过则认证成功
避坑指南:我曾经遇到过一个问题:公钥认证死活不通过,查了半天才发现是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 通道复用如何工作
我画了一张图,帮你理解通道复用的逻辑:
你看,客户端和服务器之间只有一条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工具的实现,都会有一种“原来如此”的感觉。
嗯,这一讲就到这里。代码可以拿去跑跑看,有问题欢迎交流。