ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java TLS握手失败排查:SSLHandshakeException根因与解决方案

Java TLS握手失败排查:SSLHandshakeException根因与解决方案 凌晨三点被值班电话叫醒定时任务掉了一批翻日志就看到javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure。这种错在Java服务里太常见了但它不像空指针看一眼就懂——它背后牵扯协议版本、加密套件、证书链、JDK版本、甚至是中间网络设备任何一环谈不拢服务端就直接扔一个致命警报连接当场断掉。这篇文章我把处理这类问题的完整思路写出来先讲握手失败到底发生在哪一步再讲怎么让JVM把日志吐干净然后按根因逐个给解决方案最后用一个真实排障案例串一遍。不管你是刚接触HTTPS调用的新人还是被线上报错折腾过的老手这套排查路径都能直接照着用。1. 先搞明白TLS握手时客户端和服务端到底在“吵”什么很多同学一看到SSLHandshakeException就以为是证书问题急着去导证书、换TrustManager。方向错了后边全白干。这个异常的精确定位是TLS协议协商阶段失败而不是证书校验失败。虽然二者经常一起出现但处理思路完全不同。1.1 一次完整TLS握手的生命周期用大白话打个比方TLS握手就像两个陌生人第一次见面要先互相确认“你会说什么语言、你带了什么证件、接下来我们用什么暗号通信”。具体到协议层面客户端和服务端要走这样几步ClientHello客户端发一份“自我介绍”里面带上自己支持的TLS协议版本列表比如TLSv1.2、TLSv1.3、加密套件列表Cipher Suites、随机数以及可选的SNI域名信息。ServerHello服务端从客户端的清单里挑一个它自己也支持的协议版本和加密套件回给客户端。如果挑不出来直接扔handshake_failure警报。Certificate服务端把自己的证书链发给客户端让客户端验证身份。密钥交换根据协商出的套件双方用DH/ECDH或RSA等方式算出同一个会话密钥。Finished双方互相对一下“暗号”确认中间没人篡改握手完成之后开始加密通信。handshake_failure发生在第2步。也就是说客户端把家底都亮出来了服务端一看“这里没有一样我能用”就直接拒绝。它甚至不会继续往下走证书流程所以你重点排查的方向应该是“协议版本和加密套件交集”而不是一上来就折腾证书。1.2 handshake_failure 警报的触发逻辑TLS协议里handshake_failure是一个编号为40的致命警报。关键就在“致命”这个词上——只要收到这个警报会话立即终止没有重试机会。触发它的典型场景有两种服务端无法从ClientHello的协议版本列表和加密套件列表里找到一种自己支持且未禁用的组合。多数情况都是这个。客户端无法接受服务端Selected的协议或套件。这种情况少一些但升级JDK后连接老服务时会遇到。要特别区分开的是证书类错误。证书不受信任通常报的是PKIX path building failed或unable to find valid certification path to requested target证书过期报的是CertificateExpiredException。虽然有些客户端库会把底层IOException包装成SSLHandshakeException但调试日志里能看到真实原因。后面的排查章节我会再强调这一点。所以当你看到handshake_failure时脑子里第一条判断应该是两端支持的“语言”没有交集或者某一端把交集里的“语言”给禁了。带着这个思路去定位效率会高很多。2. 定位问题先让JDK把心里话全部打印出来接手这类报错我从来不改代码先加JVM调试参数。很多时候日志一开根因直接写在脸上。盲目在代码里换TrustManager、加SSLContext反而会把问题搞复杂。2.1 第一步开启JSSE调试开关JSSEJava Secure Socket Extension是JDK内置的SSL/TLS实现它自带一套非常详细的调试输出。启动Java程序时加上这个参数java -Djavax.net.debugssl:handshake:verbose -jar your-service.jar如果你是Spring Boot服务可以把它写到JAVA_OPTS环境变量里再启动export JAVA_OPTS-Xmx1g -Djavax.net.debugssl:handshake:verbose java $JAVA_OPTS -jar your-service.jar建议第一次先只用ssl:handshake:verbose这个级别信息量刚好。如果怀疑证书链有问题可以再加:keymanager:trustmanagerjava -Djavax.net.debugssl:handshake:verbose:keymanager:trustmanager -jar your-service.jar注意生产环境流量大这个参数不要长期开着它会输出大量日志非常吃磁盘。定位完问题马上关掉。2.2 第二步分析ClientHello与ServerHello的关键行开启调试后日志里会出现类似下面的内容这里做了裁剪只留关键部分*** ClientHello, TLSv1.2 RandomCookie: GMT: 1710469872 bytes { ... } Cipher Suites: [TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA256, ...] Extension server_name, server_name: [host_name api.thirdparty.com] *** *** ServerHello, TLSv1.2 ... *** Alert: handshake_failure重点看两个地方ClientHello 最顶上的TLSv1.2这是本次客户端打出的最高协议版本。如果你看到的是TLSv1.1或TLSv1那大概率是JDK版本偏老不认新版协议。Cipher Suites 列表这里列出的是客户端全部家底。如果日志里出现Ignoring unsupported cipher suite: ...或者形如Filtered cipher suite: ...的行说明有套件被JDK的安全策略过滤掉了。最后一段如果看到*** Alert: handshake_failure基本可以确定是协商阶段被服务端拒绝。有的场景下日志还会给出更直白的提示比如main, RECV TLSv1.2 ALERT: fatal, handshake_failure这说明服务端已经给出了最终裁决。接下来要做的是搞清楚“裁决”的原因。2.3 第三步用外部工具反向探测目标服务器JVM日志只能看到客户端视角服务端支持哪些协议和套件最好用外部工具直接探一下。我常用的命令是openssl系统自带不需要额外装东西。先探测目标服务器支持的协议版本# 探测TLS 1.2 openssl s_client -connect api.thirdparty.com:443 -tls1_2 -servername api.thirdparty.com # 探测TLS 1.3 openssl s_client -connect api.thirdparty.com:443 -tls1_3 -servername api.thirdparty.com如果某一行的-tls1_2能成功但Java客户端还是报握手失败那问题基本不在协议版本而在加密套件或证书链。反过来如果两个协议版本都失败而openssl s_client -connect api.thirdparty.com:443不带参数时成功说明服务端只接受默认的最高版本很可能是TLS 1.3。连接成功时输出里会有关键行New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384New表示新建立的会话后面跟的就是协商出来的协议版本和加密套件。这样你就能知道服务端最终接受了什么组合。另外再补充一个小工具纯JDK也能列本地支持的协议和套件写段代码跑一下即可import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; import java.util.Arrays; public class ListTlsCapabilities { public static void main(String[] args) throws Exception { SSLContext context SSLContext.getInstance(TLS); SSLParameters params context.getSupportedSSLParameters(); System.out.println(Supported Protocols: Arrays.toString(params.getProtocols())); System.out.println(Supported Cipher Suites: Arrays.toString(params.getCipherSuites())); } }本地跑一下看看自己JDK到底支不支持TLSv1.3一清二楚。3. 根因对照与解决方案拿到调试日志之后对照下面几类根因基本能覆盖绝大多数线上案例。3.1 根因一JDK版本与协议代差这是最常见的场景分两种表现一种是客户端JDK太老。Java 8从8u261开始才backport支持TLS 1.3在这之前的版本最高只到TLSv1.2。而这两年服务端普遍开启TLS 1.3甚至有些云厂商的网关只开放TLS 1.3。两边一照面客户端报handshake_failure一点不冤。另一种表现正好反过来客户端JDK太新服务端太老。从JDK8u261、11.0.11开始JVM默认在java.security配置里禁用了TLSv1和TLSv1.1。如果内网老服务只支持到TLSv1.1新JDK上去直接握手失败。以前能通升级JDK小版本后突然不通多半是这个原因。对应关系可以看下面这张表JDK版本默认最高TLS协议是否支持TLS 1.3加密强度策略Java 6/7TLSv1.0不支持需要额外JCE策略包Java 8u141及更早TLSv1.2不支持需要额外JCE策略包Java 8u161及之后TLSv1.2不支持8u261前默认无限强度Java 8u261及之后TLSv1.3支持默认无限强度Java 11TLSv1.3支持默认无限强度Java 17TLSv1.3支持默认无限强度解决方案按优先级排首选升级JDKJava 8至少升到8u331或者直接上Java 11/17。这不仅是解决握手问题也是安全底线。客户端连接老服务时如果暂时不能升级服务端可以临时放开协议禁用。编辑$JAVA_HOME/lib/security/java.security找到jdk.tls.disabledAlgorithms把其中的TLSv1, TLSv1.1去掉。注意这是全JVM生效谨慎操作。也可以在启动参数里强制客户端只协商某个协议版本java -Djdk.tls.client.protocolsTLSv1.2 -jar your-service.jar或针对HttpsURLConnectionjava -Dhttps.protocolsTLSv1.2 -jar your-service.jar注意这类参数只影响客户端侧的协商范围不影响服务端。3.2 根因二加密套件没有交集协议版本两边都OK但加密套件没对上同样谈不拢。比如服务端只配置了ECDSA套件而你的证书和密钥都是RSA体系的再比如服务端只支持TLS_AES_256_GCM_SHA384这类TLS 1.3套件客户端走的还是TLS 1.2两边根本没有交叉项。判断方法是看调试日志里有没有“Filtered”开头的行例如Filtered cipher suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256Filtered是“被客户端过滤掉”的意思后面会跟着过滤原因。常见原因包括算法被安全策略禁用、密钥长度不满足要求、证书类型不匹配等。这类问题的解法分两头如果服务端是自己管理的优先调整服务端。Nginx的话打开配置ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on;Apache则是SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256改完要nginx -t nginx -s reload验证语法并重载。如果服务端是第三方只能在客户端想办法。可以临时收紧客户端的套件列表让它和服务端对齐比如在启动命令里指定java -Djdk.tls.client.cipherSuitesTLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 -jar your-service.jar但要记住这只是“让两边勉强凑在一起”根因还是服务端配置太旧或太偏。3.3 根因三JDK的算法黑名单误伤这个坑非常隐蔽排查起来最容易绕弯路。JDK在java.security文件里维护了一份“黑名单”禁止使用某些被认为不安全的算法和密钥长度。有两处配置要留意jdk.tls.disabledAlgorithms影响TLS握手协商时的算法选择jdk.certpath.disabledAlgorithms影响证书链验证在JDK 8u121之后jdk.tls.disabledAlgorithms默认包含类似这样的规则DH keySize 1024, DHE keySize 1024, RSA keySize 1024, 3DES_EDE_CBC, RC4, MD5, TLSv1, TLSv1.1我踩过的一个经典案例内网老服务用的是DHE-RSA-AES128-SHA服务端DHE密钥长度只有768位。客户端是JDK 8u161默认禁止密钥长度小于1024位的DHE。两边一握手服务端推荐DHE套件客户端直接过滤掉服务端又没备选的RSA套件最终handshake_failure。这种问题怎么快速确认还是看JSSE调试日志只要见到Filtered cipher suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA Ignoring unsupported cipher suite: TLS_DHE_RSA_WITH_AES_128_CBC_SHA再配合服务端探测确认它只有这一两个DHE套件基本就能锁定原因。处理方法按优先级最好让服务端升级放弃DHE这类可被攻击的套件改用ECDHE。如果服务端一时动不了常见于老设备、外包系统可以在客户端临时把黑名单相应条目去掉。编辑$JAVA_HOME/lib/security/java.security找到类似这一行jdk.tls.disabledAlgorithmsSSLv3, TLSv1, TLSv1.1, RC4, DES, MD5withRSA, DH keySize 1024, ...把DH keySize 1024或DHE keySize 1024临时去掉。但这等于把安全底线降了一截仅限于严格隔离的内网环境而且要记录在变更单里之后推动服务端整改。注意修改 java.security 前一定先备份。不同JDK版本这条配置的默认值不一样直接全局替换容易误伤其他功能建议先grep确认原内容再改动。3.4 根因四证书链不完整或证书不受信任虽然handshake_failure严格来说是协商失败但实际排障中证书链不完整也可能在客户端代码里表现出握手失败。尤其像Tomcat、Netty这类服务端容器如果证书链配置不全在握手阶段发送Certificate消息时就会异常。还有一种情况客户端和ServerHello都正常但服务端在证书阶段只发了一张叶子证书没有中间CA证书。客户端本地又没有存这个中间CA于是构建证书链失败。表现可能是PKIX path building failed也可能被框架包装成SSLHandshakeException。配合:trustmanager调试级别就能看到类似sun.security.validator.ValidatorException: PKIX path building failed处理分两步服务端要配置完整证书链。Nginx里就是ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/private.key;fullchain.pem要包含“服务器证书 中间CA证书 根CA证书”顺序不能反。市面上很多证书提供商下载时提供了ca-bundle别漏装。客户端如果是因为本地缺CA把CA证书导入JVM信任库keytool -importcert -alias my-ca -file ca.crt -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit或者在启动参数里指定自定义信任库java -Djavax.net.ssl.trustStore/path/to/truststore.jks -Djavax.net.ssl.trustStorePasswordyourpass -jar your-service.jar证书链问题有个特点同一套代码在浏览器里能访问在Java里报握手失败。因为浏览器自己维护了一套CA库而Java用的是自己的cacerts两者不完全一样。遇到这种情况优先核对服务端证书链完整性其次再往JVM信任库导CA。3.5 根因五SNI、中间设备和环境因素的干扰SNIServer Name Indication是ClientHello里附带的一个域名扩展。多证书服务器靠它决定返回哪张证书。Java 8默认开启SNI但当连接地址是IP而不是域名时JVM可能不发SNI扩展。某些服务端配置了多张证书、却没有默认证书就会在握手时出问题表现同样是handshake_failure或unrecognized_name。遇到这种情况先确认客户端连接的是域名而不是IP。如果是IP把代码或配置里的IP改成域名。实在必须用IP可以在服务端配置默认证书兜底。中间设备也是个容易忽略的点。公司网络出口如果有TLS拦截设备、负载均衡器配置了旧的SSL策略会在客户端和服务端之间做一次“中转协商”任何一方不支持就掐断连接。判断方法很简单在同一台服务器上用curl或openssl直接测目标端口如果外部工具能通、Java不通大概率不是Java配置问题而是Java侧的安全策略或客户端套件列表问题如果外部工具也不通那就要检查网络设备了。另外系统时间严重不同步时证书有效期校验会失败。虽然通常报的是CertificateExpiredException或PKIX path building failed但极端情况下也会被包装成握手失败。顺手跑一下ntpdate或确认系统时间源成本很低能排除一个潜在原因。4. 实战记录一次定时任务对接支付接口的完整排障前面讲了原理和方法这一节用一个真实案例把流程串起来。细节做了脱敏但所有日志内容和处理路径都是实操中真实的。4.1 现象与初步判断生产环境的定时任务每天凌晨拉取支付渠道对账单某天开始突然全部失败报错一模一样javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure at sun.security.ssl.Alert.createSSLException(Alert.java:127) at sun.security.ssl.TransportContext.fatal(TransportContext.java:340) ...第一反应是证书过期于是检查了证书有效期正常。然后用openssl直接探了一下目标地址openssl s_client -connect pay.example.com:443 -servername pay.example.com返回是成功的协商出TLSv1.3, TLS_AES_256_GCM_SHA384。这就奇怪了外部工具能连Java连不上而且报的是握手失败说明问题出在Java客户端这边。4.2 打开调试日志后的关键输出给应用加上JVM调试参数重启复现失败后抓到的日志核心片段如下*** ClientHello, TLSv1.2 Cipher Suites: [TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, ...] Extension server_name, server_name: [host_name pay.example.com] main, READ: TLSv1.2 Alert, length 2 main, RECV TLSv1.2 ALERT: fatal, handshake_failure注意看ClientHello顶部写的是TLSv1.2。也就是说这台服务器的JDK最高只支持到TLSv1.2而服务端在测试时已经协商到了TLSv1.3。问题基本明确了服务端优先选择TLS 1.3但客户端根本不会说TLS 1.3这门语言。再看一下JDK版本java -version输出是1.8.0_141正好是TLS 1.3 backport之前的版本客户端列表里压根没有TLSv1.3。4.3 最终的修复方案与验证临时方案是强制客户端走TLSv1.2在启动参数里加java -Djdk.tls.client.protocolsTLSv1.2 -jar pay-sync-task.jar加完确实能跑通因为服务端兼容TLSv1.2。但这只是缓兵之计TLSv1.2早晚会被淘汰治标不治本。最终方案是把JDK从8u141升级到8u331。升级后顺手重跑了一遍日志ClientHello的协议列表变成了Cipher Suites: [TLS_AES_128_GCM_SHA256(0x1301), TLS_AES_256_GCM_SHA384(0x1302), ...]顶部也出现了TLSv1.3连接成功后握手日志里能看到*** ServerHello, TLSv1.3问题彻底解决任务恢复。整个过程没有改一行业务代码。这里提醒一句升级JDK后一定要回归测试其他HTTPS调用特别是内网老系统。前面说过8u261之后默认禁用TLSv1和TLSv1.1升级JDK可能治好一个病又引出另一个病。我当时就发现另一个对接老票务系统的接口开始报错最后在服务端把TLSv1.0打开才恢复。升级前建议先拉一份所有对外HTTPS调用清单列好域名和端口升级后逐个冒烟。5. 常见问题速查与避坑清单处理这种问题多了整理一张速查表出来遇到类似报错可以直接对号入座。5.1 报错模式对照速查表报错场景调试日志特征最可能根因优先处理方式新JDK连老服务突然握手失败ClientHello里TLSv1.1被过滤JDK默认禁用TLSv1/1.1服务端升级协议或临时放开禁用配置老JDK连新服务一直失败ClientHello最高只有TLSv1.2JDK不支持TLS 1.3升级JDK到8u261内网服务DHE套件连不上Filtered cipher suite: TLS_DHE_...JDK禁用低强度DHE服务端换ECDHE套件证书链不完整PKIX path building failed或handshake_failure服务端漏配中间CA服务端配fullchain用IP访问多证书站点ClientHello里无SNI扩展SNI未携带域名连接地址改为域名openssl能连Java连不上客户端套件列表被过滤Java侧安全策略检查disabledAlgorithms配置这张表不是万能的但覆盖了我遇到的绝大多数情况。核心思路始终是先看调试日志找ClientHello与服务端支持的“交集”再看是谁把交集给挡了。5.2 平时最容易踩的五个坑第一个坑遇到握手失败就创建“信任所有证书”的TrustManager。网上很多教程会贴这种代码本地测试可以生产环境千万别这么做。它在绕过证书校验的同时也把你的流量暴露给了中间人攻击。我见过有人把信任所有证书的代码带上生产后来对方证书过期接口却还在“正常”返回数据全被第三方解密了。第二个坑改java.security之前不备份。这个文件内容不少一个误删可能导致JDK无法启动或者所有TLS连接异常。改之前先cp java.security java.security.bak改完用java -version验证还能正常启动。第三个坑调试日志开着不关。-Djavax.net.debugssl:handshake:verbose的输出量非常大高峰期能打满磁盘。定位完问题立刻去掉这个参数并重启这个动作要写进运维检查单。第四个坑用openssl探测时不带SNI。很多服务器依赖SNI选择证书不带-servername时探到的可能是默认证书导致探出来的结果和Java实际连的结果不一样。正确做法是始终带上openssl s_client -connect pay.example.com:443 -servername pay.example.com第五个坑只测一个协议版本。有些人习惯只跑openssl s_client -connect host:443看默认输出但服务端的默认版本不代表它能兼容所有版本。要分别测试-tls1_2、-tls1_3才能画出完整的能力边界。5.3 一段可以直接兜底的客户端配置如果你的代码走的是JDK原生HttpsURLConnection最简单的固定协议方式是启动参数前面已经说过了。如果走OkHttp可以这样限定OkHttpClient client new OkHttpClient.Builder() .connectionSpecs(Collections.singletonList(ConnectionSpec.MODERN_TLS)) .build();如果走 Apache HttpClient 4.x可以自定义连接套接字工厂SSLContext sslContext SSLContexts.custom() .useProtocol(TLSv1.2) .build(); SSLConnectionSocketFactory factory new SSLConnectionSocketFactory( sslContext, new String[]{TLSv1.2}, null, SSLConnectionSocketFactory.getDefaultHostnameVerifier()); CloseableHttpClient httpClient HttpClients.custom() .setSSLSocketFactory(factory) .build();再说一遍这只是给客户端划定协商范围不等于越过服务端限制。如果服务端本身只支持某一种老套件客户端怎么配也变不出新套件来。真正的治本方案永远是让两端都在一个相对现代、安全的协议区间里。个人经验是这类握手失败九成以上都能归结到“协议版本代差”和“算法黑名单”这两个原因上。每次处理完问题把当时的ClientHello片段、服务端探测结果、根因和修复方式整理成一条记录下次再遇到基本十分钟内就能定位。如果能推动团队在升级JDK或服务端TLS策略之后跑一轮全链路HTTPS接口回归这类“半夜报警”还能再少一半。
返回列表