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)的配置。这意味着,即使你为域名2server块只配置了TLSv1.2,但如果Nginx在全局层面或者在默认服务器的配置中允许TLSv1.1,那么客户端仍然可以使用TLS 1.1协议建立连接并访问到域名2的服务。

详细解释

  1. 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 之前 决定的,不依赖具体虚拟主机。


  1. 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.cngx_ssl_certificate_callbackngx_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.comTLS1.1失败——默认 SSL_CTX 只含 TLS1.2,版本协商即被拒(SNI 尚未生效)
a.example.comTLS1.1失败——同上,证明协议过滤与 SNI 无关
b.example.comTLS1.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_serverssl_protocols覆盖所有虚拟主机需要的最低协议版本,否则早夭的握手会让你误以为“配置没生效”。