ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

TLS握手从1.2到1.3:RTT优化、0-RTT机制与版本排查实战

TLS握手从1.2到1.3:RTT优化、0-RTT机制与版本排查实战 你打开一个网站地址栏显示小锁页面正常加载出来——这背后其实藏着一轮又一轮的“加密谈判”也就是TLS握手。最近我在排查一台老服务的时候浏览器直接弹出一条安全警告“协商的 TLS 1.0 是非安全协议只有在为了实现向后兼容性才受支持。”这话说得客气翻译过来就是你的服务还在用将近二十年前的老协议该换车了。TLS握手从TLS 1.2到TLS 1.3不仅仅是版本号的变化它最直观的差异就写在标题里——早期完整握手需要2-RTT到了TLS 1.3压缩成1-RTT配合PSK复用还能做到0-RTT。这篇文章把这些概念拆开讲清楚包括握手每一步在干什么、RTT怎么算的、0-RTT牺牲了什么最后附上我实际排查TLS版本报错和检测域名支持的TLS版本的完整套路。无论你是刚接触HTTPS的新手还是被线上证书、SNI、版本兼容问题折磨过的运维或后端开发都能从里面拿到能直接用的东西。1. 先搞懂TLS握手在解决什么问题1.1 握手要完成的“三件事”很多资料一上来就甩ClientHello、ServerHello一堆术语但我觉得先想清楚握手的目的是什么更重要。TLS握手本质上是一次通信会话开始前的“身份核验参数协商钥匙分发”流程。它要干的事可以压缩成三件第一确认对方是谁第二协商用什么算法和版本第三在不安全的网络上安全地生成一把协商密钥给后续所有应用数据加解密用。第三件事往往最容易被忽略。HTTP时代是明文传输谁在中间搭根网线就能看光你的请求这是不可接受的。TLS想达到的效果是就算抓包的人把所有握手报文都记录下来他也推导不出最终那把会话密钥当然也就解不开后面的加密流量。这里依赖的核心数学工具是非对称加密——服务器有一对公私钥公钥公开给全世界私钥只有自己知道。客户端把一串随机数“锁”进一个盒子里这个盒子只能由私钥打开于是客户端可以把盒子放心扔到公网上只有服务器能取出来这把钥匙就是后续对称加密的种子。用生活类比就是你在邮局寄一个带密码锁的包裹锁的钥匙只有收件人有邮局的人根本打开不了哪怕中途被拆包中转也只能看到密文。身份确认则靠数字证书。服务器把自己的公钥放在证书里由CA证书颁发机构签名。浏览器内置了信任的CA根证书列表收到服务器证书后沿着证书链一层层验证签名就像你拿到一份盖了公章的文件公章是不是真的得对照官方备案的印模。这部分环节解释了为什么你访问某些公司自签证书的站点会看到“不安全”因为浏览器不认这个“私章”。1.2 没有握手会怎样中间人攻击的长相理解了握手的用途再来看一个经典攻击模型。假设客户端C想访问服务器S如果双方不握手、不验身份直接交换一把对称密钥黑客M在中间同时跟C和S各自建立一条隧道C以为是和S说话S以为是和C说话M在中间解密、查看、篡改、再加密所有机密全部暴光。这就是中间人攻击。TLS握手最精彩的地方就是通过证书机制防住这种攻击。注意一个细节服务器向客户端证明身份时出示的是“证书签名”而不是光一个公钥客户端在验证证书合法性之前不会把自己选择的随机密钥材料发给对方。先验明正身再交换机密顺序绝对不能反。这也是我从多次排查经验里得出的结论凡是中间人攻击成功的案例几乎都是客户端跳过了证书校验或者服务器把私钥泄露了而不是RSA/ECDHE算法本身被破解。从业者脑子里始终要有一根弦TLS握手的核心是把“算法强度”和“身份信任”分开算法负责防破解证书负责防盗用。2. TLS 1.2的经典握手2-RTT的账怎么算2.1 一次完整握手的过程TLS 1.2的年代完整握手是教科书式的双向往来。我拆成六步每一步对应一次网络上的消息客户端发ClientHello带上客户端支持的TLS版本、随机数、支持的密码套件列表、压缩算法、扩展字段。服务器回ServerHello选一个版本、选一个密码套件、返回自己的随机数。服务器随后发Certificate把证书链传给客户端。服务器再发ServerKeyExchange对于ECDHE这类需要额外共享参数的算法把密钥交换参数比如椭圆曲线点发给客户端并附上签名。服务器最后发ServerHelloDone告诉你“我这边说完了轮到你”。客户端验证证书和服务器签名然后发ClientKeyExchange把“预主密钥”用服务器公钥加密或通过DH算法协商出来紧接着客户端计算会话密钥发ChangeCipherSpec通知对方“我开始加密了”再发Finished。服务器收到后也做同样的密钥计算回ChangeCipherSpec Finished。到这里双方才确认彼此的加解密状态一致之后才能发真正的应用数据。你可能注意到第6步里有两种密钥交换方式RSA和ECDHE。RSA方式简单粗暴客户端生成一个预主密钥用服务器公钥加密传过去双方用它派生会话密钥。ECDHE方式麻烦一些客户端和服务器各自生成临时密钥对交换公钥分量利用ECDH算法在两端独立算出同一个共享密钥。前者有个致命弱点如果服务器的RSA私钥被泄露那么历史上抓包存下来的会话都能被解密。后者因为每次握手都是临时密钥私钥泄露只能影响当前会话。正因为在“前向安全”Forward Secrecy上天差地别TLS 1.2后期几乎所有站点都切到了ECDHETLS 1.3则直接删除了RSA密钥交换。2.2 RTT计算为什么是“2-RTT”RTT的通俗定义是客户端发一个包到服务器再收到服务器的响应这样一次往返的时间。我们算一下TLS 1.2完整握手花了几次往返。第一次往返客户端发ClientHello服务器回ServerHello Certificate ServerKeyExchange ServerHelloDone。这是一次完整的RTT。第二次往返客户端发ClientKeyExchange ChangeCipherSpec Finished服务器回ChangeCipherSpec Finished。这又是一次RTT。只有等到第二次RTT结束后应用数据才能开始流动。所以TLS 1.2完整握手下从你发起连接到第一个应用数据字节到达服务器之间至少消耗2个RTT。物理距离会放大这个数字。假设客户端和服务器跨洋网络时延80毫秒那么2-RTT意味着光握手就要160毫秒。对看重延迟的场景来说这绝对是个大头。当年做性能优化最常见的手法就是用会话恢复把第二次握手的开销省掉——原理就是让客户端把第一次握手的“加密状态”缓存起来下次连接直接复用。2.3 会话恢复1-RTT的折中方案TLS 1.2提供了Session ID和Session Ticket两种会话恢复机制。Session ID是在服务器端缓存会话状态缺点是服务器集群部署时要共享缓存Session Ticket是把会话状态加密后存到客户端下次连上直接提交票据服务器解密验证后恢复会话。表面看这很完美但有一个漏洞如果攻击者截获了票据并重放服务器可能误认为这是同一个客户端。所以Session Ticket的实现必须支持票据过期和一次性校验。用Session Ticket时第二次连接流程被压缩成一次RTT——客户端发ClientHello带上票据服务器验证后直接回ChangeCipherSpec和Finished应用数据立刻可以发送。这一机制为TLS 1.3里的0-RTT打好了底子只是TLS 1.3做得更彻底。3. TLS 1.3提速的秘密完整握手压到1-RTT3.1 删除过时算法TLS 1.3的“减负”TLS 1.3在2018年定稿但它做的第一件事不是发明新算法而是疯狂做减法。密码套件从几十种砍到五种删掉了AES-CBC、RC4、SHA-1、RSA密钥交换只保留AEAD类对称加密算法密钥交换只留ECDHE和PSK。这背后的逻辑不难理解支持太多算法就意味着攻击面大、兼容逻辑复杂而且很多老算法有已知弱点。TLS 1.3想传递的信号很简单——“加密套件少而精每一个都至少是当前密码学教科书上的优等生”。对普通用户来说这个改动最直观的影响是配置变简单了。以前Nginx配置里要写长长的ssl_ciphers列表还要担心3DES、CBC套件的弱点现在你只需要写明TLS 1.3默认支持的几个套件剩下的安全研究员替你筛选过一遍。我维护过的老项目里凡是能升到1.3的密码套件配置篇幅都短了一截。3.2 一次握手搞定密钥共享提前到ClientHelloTLS 1.3压缩到1-RTT的核心是把“密钥交换参数”提前到了第一条消息。在TLS 1.2里服务器要先选定算法再发ServerKeyExchange给客户端客户端才能配合协商出预主密钥这一来二去就多了一轮往返。TLS 1.3的做法是客户端在ClientHello里直接带上多个密钥共享Key Share候选项同时声明自己支持的签名算法、证书签名算法、PSK标识。服务器收到后从客户端给的密钥共享里选一个自己支持的用自己的私钥协商出共享密钥然后在一条ServerHello里把结果返回给客户端同时立刻把加密后的证书和Finished传过来。这样一来客户端只发一次包就能拿到服务器证书和握手完成的确认信息整个完整握手正好1-RTT。更妙的是如果服务器从客户端的密钥共享里找不到合适的曲线可以回HelloRetryRequest让客户端重新尝试但这种“兜底”情况很少发生基本不影响整体延迟。我实测过为什么TLS 1.3普遍比1.2快除了算法更快更主要就是省了一次跨海往返。3.3 前向安全与HKDF密钥独立性的保障TLS 1.3另一个容易被忽略的改进是密钥调度机制。它用HKDFHMAC-based Key Derivation Function把共享密钥分阶段推导出多把独立子密钥握手密钥、主密钥、应用流量密钥、会话恢复密钥各自职责分开互不影响比TLS 1.2的PRF简单粗暴以前主密钥一把钥匙通到底。子密钥独立意味着即使握手阶段某一把密钥被破解也无法倒推出其他密钥。前向安全方面因为只有ECDHE密钥交换每个会话的临时私钥用完即弃即使服务器长期私钥泄露也无法回溯解密历史流量。这两点合起来回答了为什么TLS 1.3敢号称“默认安全”它用结构化的派生方式管理密钥而不是寄希望于密码套件列表里选出足够强的那个。对管理者来说这意味着你少了很多“配置不当”的操心但也多了几道必须背熟的规则——比如0-RTT模式下的重放防护就是接下来要讲的重点。4. 0-RTT握手还没完数据已经出发4.1 PSK复用和early_data0-RTT的机制0-RTT的含义是客户端在第一个报文里就可以携带应用数据服务器立即处理整个过程不需要任何往返。能做到这一点的前提是客户端之前和服务器完成过一次完整握手并且拿到了服务器下发的会话恢复票据——TLS 1.3里叫NewSessionTicket里面是一个预共享密钥PSK。后续连接时客户端在ClientHello里带上这个PSK的标识和密钥共享同时用PSK派生出的加密密钥直接加密一笔“早期数据”early_data一起发出去。服务器收到后验证PSK如果接受就直接解密early_data并处理然后回复ServerHello Finished完成后续握手。整个过程应用数据的发送和握手的第一条消息同批出发所以叫0-RTT即“零次额外往返”。用生活类比就是你去常去的咖啡馆店员认得你的脸PSK标识你还没坐下就把点单和咖啡币early_data递过去了店员直接开始做咖啡不用再核对身份。4.2 重放攻击的账一个0-RTT请求能被如何“赖”掉0-RTT最大的安全隐患是重放攻击。攻击者把截获的early_data原封不动转发给服务器多次由于这些数据是用PSK加密的PSK本身没有失效服务器每次收到都会正常解密把“下单”“转账”“修改配置”这类动作执行多遍。TLS 1.3规范没有强制服务器必须抗重放留给模块自己决定这就埋下了雷。缓解手段分为三个层面第一限制early_data只能用在幂等操作上比如查询、预加载、会话恢复不能用在支付、下单这类会改变状态的接口第二设置更短的票据生命周期客户端拿到PSK后只能在一分钟内或几小时内使用过期就回到1-RTT甚至完整握手把重放窗口压小第三服务器端维护一个去重缓存把所有early_data包的特征值存下来遇到相同的直接丢弃。但注意去重缓存本身需要考虑内存和分布式同步并没有想象中简单。我从实际项目的角度给个建议如果拿不准干脆把0-RTT关掉。现在主流Web服务器默认并不开启early_dataNginx需要显式配置ssl_early_data on;。等到你确实需要“客户端首次访问就能带回数据”的性能优化时再仔细评估它能不能承受重放风险。安全性和速度往往需要二选一0-RTT是教科书级别的例子。4.3 什么场景适合0-RTT什么场景坚决别用适合用0-RTT的场景有个共同点请求本身是无害、可重复执行的。典型包括CDN的预加载资源、API网关前的缓存填充、TLS心跳探活、客户端版本更新检查。这些请求即使被重复执行最多浪费一点带宽不会造成账务错误或状态错乱。万万不能用0-RTT的场景则是所有涉及写入、扣费、抢购、审批、唯一ID生成的接口。哪怕你有很硬核的去重服务我也不建议拿0-RTT赌运气。原因很简单攻击者重放时你的业务层需要把重放识别跟TLS层解耦本来就难一旦解耦不彻底被薅的羊毛就是真金白银。业内有个朴素的共识——0-RTT更像性能优化工具而不是安全功能把它当作隐形通道来用迟早出问题。5. 实操排查现实世界里总会碰见的TLS报错5.1 “协商的TLS 1.0是非安全协议”升级与禁用回到开头那条警告。当你的站点还在用TLS 1.0/1.1现代浏览器会直接标红有条件的话甚至会拒绝访问。TLS 1.0早在1999年发布已有太多已知弱点比如BEAST、POODLE等攻击手法都是针对这一代协议的。处理思路不是“让客户端兼容它”而是“让服务器彻底旁路它”。Nginx侧可以这样配置server { listen 443 ssl; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_conf_command Options PrioritizeChaCha; }Apache侧则对应SSLEngine on SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256修改后要立刻验证是否生效最直接的方法是写一个openssl命令轮巡openssl s_client -connect example.com:443 -servername example.com -tls1_1 /dev/null 2/dev/null | grep -E Protocol|Cipher如果输出里没有“Protocol : TLSv1.1”就说明旧协议已经被拒之门外。这里有个经验改完协议配置后不要只看浏览器验证因为浏览器可能通过TLS扩展自动选了高版本导致你以为旧版本还“通着”。用openssl指定协议版本去测才是真验证。如果问题出在Windows客户端侧浏览器/系统报出“协商的 TLS 1.0 不安全”这种提示你要检查的是Schannel配置注册表里HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols下面可以单独把TLS 1.0和TLS 1.1的“Enabled”项置0。修改完重启生效大多数IE内核浏览器和部分旧客户端的报错就能消除。5.2 SSL_ERROR_UNRECOGNIZED_NAME_ALERTSNI配置问题另一个高频错误是SSL_ERROR_UNRECOGNIZED_NAME_ALERT。它长这样客户端明明发了TLS请求服务器却回了一个“unrecognized_name”告警。按字面意思理解就是服务器认不出你传来的“名字”——这个名字就是SNIServer Name Indication即TLS扩展里带上域名供服务器在多个证书中挑选正确的那个。遇到这个报错的排查步骤很固定。第一步用curl带指定域名测试curl -v --resolve example.com:443:1.2.3.4 https://example.com/ 21 | grep -i error第二步用openssl验证相同域名和IP的握手echo | openssl s_client -connect 1.2.3.4:443 -servername example.com -showcerts如果openssl正常而curl报错多半是客户端、代理或负载均衡层的SNI传递被截断了。比如有的反向代理配置里回了“server_name不变”而没有透传SNI导致后端TLS无法定位虚拟主机。一个经典的Nginx反代配置是proxy_ssl_server_name on; proxy_ssl_name $host;这两行确保后端看到的SNI和客户端访问的域名一致。如果服务器端本身只配了一份默认证书没有按域名分证书也会出现“NAME不匹配”此时要注意检查证书里的SAN/DNS字段是否包含来访域名。5.3 创建TLS客户端凭据时内部错误状态10013这个错误在Windows平台上更常见报错往往出现在创建TLS客户端凭据的过程中系统提示内部错误状态为10013。10013在Winsock错误码里对应WSAEACCES直译为“权限被拒绝”。我遇到过两种典型触发方式。第一种是程序试图读取/使用某个证书存储时权限不足或凭据的私钥访问被拒。排查方法是确认当前进程的运行用户是否有权访问证书私钥Windows证书存储里右键证书“管理私钥”可以给账户设置读写权限如果是IIS应用池要检查应用池标识是否被加到私钥权限列表里。第二种是端口绑定冲突或网络栈受限。比如有些软件在启动TLS监听端口时被防火墙或另一个进程抢先占用底层socket bind失败底层错误码透传成了10013。用netstat排查一下netstat -ano | findstr :443 tasklist | findstr PID /R顺便用telnet测连通性telnet 127.0.0.1 443如果确实没权限尝试以管理员身份重新运行或者在服务配置里换一个专用非特权端口再配置负载均衡转发。另外有些杀毒软件会拦截TLS握手过程中的原始socket操作导致内部错误10013升级为凭据创建失败这时候加白名单或暂时关闭注入模块再测一次就能确认是不是安全软件在捣乱。5.4 如何查看域名支持的TLS版本openssl实战很多开发运维都有这个需求想知道某个域名到底支持到TLS几证书链有什么问题。网上有一些在线工具但脚本化批量检查还是要靠openssl。最基础的是逐个版本测for v in tls1 tls1_1 tls1_2 tls1_3; do echo $v echo | openssl s_client -connect example.com:443 -servername example.com -$v 21 \ | grep -E Protocol|Cipher|Verify return code|New, TLSv done要注意的是openssl是否支持-tls1_3取决于编译版本老版本的openssl可能没有-tls1_3参数。检查openssl版本可以用openssl version。如果服务器只返回“Wrong Version Number”或直接RST多半是不支持该版本。你可以把这段封装成一个小脚本加一个超时控制批量跑一批域名输出每个域名的支持矩阵check_tls() { domain$1 port${2:-443} for v in tls1 tls1_1 tls1_2 tls1_3; do res$(echo | timeout 5 openssl s_client -connect $domain:$port -servername $domain -$v 2/dev/null | grep -E ^ *Protocol | awk {print $2}) echo $domain $v $res done }另一个实用的探测工具是nmap的脚本。它会把支持的密码套件和TLS版本都列出来比openssl更直观nmap --script ssl-enum-ciphers -p 443 example.com输出会显示TLSv1.0、TLSv1.2、TLSv1.3各自支持哪些套件同时标记压缩方法和已知漏洞。对于只想知道“这个域名能不能开TLS 1.3”这种问题nmap其实比折腾openssl参数的效率高得多。再补充一个小经验检测时不要只测443端口很多内部域名跑在8443、9443等端口原理完全一样只要把端口参数换掉即可。还要注意有些CDN或云厂负载均衡会按SNI动态转发但如果客户端不发送SNI它可能返回一个默认证书这时候你检测出来的版本矩阵根本不是业务主域名的真实支持情况。所以脚本里始终带上-servername参数是一个好习惯。最后说说我踩过的一些坑。排查TLS版本问题时我见过最多的情况不是协议配置错了而是“中间层”把TLS截断了——比如公司出口代理强制把TLS 1.3降级成1.2甚至1.0你拿openssl在公网测一切正常一进内网就报出开头那种TLS 1.0警告。这种时候要把网络路径拆开看先本机测再跨一跳节点测逐步缩小范围。另一个常见坑是修改了服务器协议配置但忘记reload改了等于没改而且有些配置项必须重启才能生效。验证永远不要相信“我没动过应该没问题”代码会撒谎openssl输出不会。这些经验让我养成了一个习惯服务器侧改完配置后第一时间手动开一个openssl s_client -connect localhost:443 -servername example.com看看实际协商结果确认无误再让流量进去。做TLS这块手勤一点线上事故就少一点。
返回列表