Nginx - SSL协议与加密套件配置
背景
最近在检测网站SSL配置时,遇到一个比较疑惑的问题,同样监听在443端口下的server块配置不同的ssl_protocols时,检测出来支持的协议版本是某一个server块的配置(后面证实是nginx最先获取到的SSL server块配置),而不是配置对应的协议版本,但加密套件又是对应server块的配置😨
解决方案
方案1
添加default_server在nginx中添加默认服务器后,可以覆盖全局性的ssl_protocols配置,但是通过SNI匹配的server块中的ssl_ciphers不会受到其他块的影响。
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
# 在这个默认块中,配置最严格的协议,或者直接返回错误
ssl_protocols TLSv1.2 TLSv1.3; # 例如,在默认服务器禁用TLSv1.1
... # 你的其他SSL配置和证书
}
方案2
在nginx拿到的第一个server块中配置ssl_protocols信息,或直接在http块中配置相关信息。
http {
ssl_protocols TLSv1.2 TLSv1.3;
...
}
# 第一个server块
server {
ssl_protocols TLSv1.2 TLSv1.3;
...
}
原因
问题根源:SSL协议协商与Server块选择
问题的核心在于,TLS协议版本的协商发生在Nginx根据SNI选择具体的server块之前。
-
连接建立与协议协商先行:当客户端(如你的
openssl s_client)连接到Nginx的443端口时,SSL/TLS握手最先发生。在此阶段,客户端会告知服务器自己支持的TLS协议版本和加密套件列表。 -
Nginx的全局协议决定:此时,Nginx需要决定使用哪个TLS协议版本来响应客户端。关键在于,Nginx在处理一个SSL连接时,用于协商的TLS协议版本和加密套件,并非在确定了
server块后才决定。Nginx会使用一个它认为适用于该连接的配置,而这个配置可能来自默认服务器或者第一个匹配的配置。 -
SNI选择Server块在后:在基本的SSL连接建立后,客户端才会通过SNI扩展声明它想访问的具体域名。Nginx此时才利用这个SNI信息来选择对应的
server块。但此时,TLS的协议版本已经在第一步确定下来了。
简单来说,ssl_protocols指令的设置在Nginx中可被视为一个更全局的、针对监听端口(如443)的配置。这意味着,即使你为域名2的server块只配置了TLSv1.2,但如果Nginx在全局层面或者在默认服务器的配置中允许TLSv1.1,那么客户端仍然可以使用TLS 1.1协议建立连接并访问到域名2的服务。
详细解释
ssl_protocols的选择时机(在SNI之前)
- SSL/TLS握手的第一步是ClientHello,其中客户端会发送支持的协议版本(如 TLS 1.2、TLS 1.3)。
- 此时SNI还未被解析,Nginx只能使用默认虚拟主机(default_server)配置来决定是否接受这个协议版本。
- 如果没有设置default_server,Nginx会使用第一个加载的server块的ssl_protocols。
- ⚠️ 注意:一旦协议版本被拒绝,握手立即失败,无法回退或重试。
✅ 所以:ssl_protocols 是在 SNI 之前 决定的,不依赖具体虚拟主机。
ssl_ciphers的选择时机(在SNI之后)(具有版差异,可能在1.22以后支持,可能是1.15.9后支持)
- ClientHello中也包含客户端支持的cipher suites。
- Nginx在收到ClientHello后,会解析SNI,确定目标虚拟主机。
- 然后根据该虚拟主机配置的ssl_ciphers,与客户端支持的cipher列表进行匹配,选择最终使用的cipher。
✅ 所以:ssl_ciphers 是在 SNI 之后 决定的,依赖具体虚拟主机。
同一IP上多个HTTPS server共用监听套接字,ClientHello到达时内核先把连接交给唯一一个“默认”SSL_CTX(即 default_server 或第一个 server 块)。
ssl_protocols保存在SSL_CTX层,Nginx在解析ClientHello前就要用它做版本协商,因此此时SNI还未被提取,只能拿默认SSL_CTX里的协议列表;如果该列表里不含客户端提议的版本,握手立即失败,Nginx根本没有机会再切换到别的虚拟主机。
ssl_ciphers也保存在SSL_CTX里,但版本协商成功后,Nginx会在ssl_cert_cb(ServerHello之前)再次根据SNI把SSL_CTX切换到真正的虚拟主机,于是最终挑选cipher时已经用上了目标主机的cipher列表。
在Nginx源码(
ngx_event_openssl.c的ngx_ssl_certificate_callback与ngx_http_ssl_servername)及社区大量实验中均可验证。
测试
目录与证书
mkdir -p /etc/nginx/certs/{a.example.com,b.example.com}
# 自签两张不同的证书
openssl req -x509 -newkey rsa:2048 -nodes -keyout /etc/nginx/certs/a.example.com/key.pem \
-out /etc/nginx/certs/a.example.com/full.pem -days 365 \
-subj "/CN=a.example.com" -addext "subjectAltName=DNS:a.example.com"
openssl req -x509 -newkey rsa:2048 -nodes -keyout /etc/nginx/certs/b.example.com/key.pem \
-out /etc/nginx/certs/b.example.com/full.pem -days 365 \
-subj "/CN=b.example.com" -addext "subjectAltName=DNS:b.example.com"
Nginx配置
不设置default_server,使第一个server作为默认服务器,配置完后nginx -s reload重启
worker_processes auto;
error_log /var/log/nginx/error.log debug; # 需要 debug 模块
events { worker_connections 1024; }
http {
# ---------- 第一个 server(默认)仅允许 TLS1.2 ----------
server {
listen 443 ssl http2; # 无 default_server,但排在第一
server_name a.example.com;
ssl_certificate /etc/nginx/certs/a.example.com/full.pem;
ssl_certificate_key /etc/nginx/certs/a.example.com/key.pem;
ssl_protocols TLSv1.2; # 故意禁止 TLS1/1.1/1.3
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
return 200 "HOST:a PROTOCOL:$ssl_protocol CIPHER:$ssl_cipher\n";
}
# ---------- 第二个 server 允许 TLS1.1/1.2 ----------
server {
listen 443 ssl http2;
server_name b.example.com;
ssl_certificate /etc/nginx/certs/b.example.com/full.pem;
ssl_certificate_key /etc/nginx/certs/b.example.com/key.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
return 200 "HOST:b PROTOCOL:$ssl_protocol CIPHER:$ssl_cipher\n";
}
}
验证
# ① 用 TLS1.1 访问 b.example.com(SNI=b.example.com)
openssl s_client -connect 127.0.0.1:443 -servername b.example.com \
-tls1_1 -cipher ECDHE-RSA-AES256-GCM-SHA384
# ② 用 TLS1.1 访问 a.example.com(SNI=a.example.com)
openssl s_client -connect 127.0.0.1:443 -servername a.example.com -tls1_1
# ③ 用 TLS1.2 访问 b.example.com,观察 cipher 是否为 AES256-GCM-SHA384
openssl s_client -connect 127.0.0.1:443 -servername b.example.com -tls1_2
结果
| 测试 | SNI | 客户端协议 | 握手结果 | 返回 cipher | 说明 |
|---|---|---|---|---|---|
| ① | b.example.com | TLS1.1 | 失败 | —— | 默认 SSL_CTX 只含 TLS1.2,版本协商即被拒(SNI 尚未生效) |
| ② | a.example.com | TLS1.1 | 失败 | —— | 同上,证明协议过滤与 SNI 无关 |
| ③ | b.example.com | TLS1.2 | 成功 | ECDHE-RSA-AES256-GCM-SHA384 | 版本协商走默认 CTX → 成功;SNI 切换后 cipher 用 b 主机的列表 |
Wireshark 抓包可看到:
- ①②在ServerHello之前直接收到Alert(Level 2 / Fatal: Protocol Version)。
- ③完整的握手完成,ServerHello中cipher_suite值为0xC030(即AES256-GCM-SHA384),与a主机的AES128-GCM-SHA256不同,证明cipher确随SNI改变。
最终结论
- ssl_protocols绑定在“默认”SSL_CTX,决策点在SNI之前;
- ssl_ciphers可在SNI回调里随SSL_CTX切换而切换,决策点在SNI之后。
因此多域名共享IP时,务必保证default_server的ssl_protocols覆盖所有虚拟主机需要的最低协议版本,否则早夭的握手会让你误以为“配置没生效”。
评论