JDK 1.6连接MySQL SSL握手失败?一文讲透TLS兼容与安全加固
前阵子帮一个老项目排查数据库连接问题应用还跑在 JDK 1.6 上数据库是 MySQL 5.7启用了 SSL。某天傍晚监控告警突然刷屏应用日志里连续出现javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake。一开始我以为是数据库端口被防火墙挡了或者是连接数被占满查了一圈之后才发现问题出在 TLS 协议和加密套件的匹配上。数据库运维那边刚刚做了一轮安全加固把 TLSv1.0、TLSv1.1 以及 3DES 一类的弱加密算法全部关掉了而 JDK 1.6 默认只认识 TLSv1.0两边对面不相识自然握不上手。这不是一个冷门问题只要你的系统还在用 JDK 1.6 连接加密数据库MySQL、Oracle、SQL Server 都有一旦服务端安全策略收紧几乎都会撞上这一条。尤其是最近很多安全扫描工具都会报 CVE-2016-2183 原理扫描运维为了消漏洞把老协议老算法一关兼容性问题立刻浮出水面。这篇内容就针对这个具体场景从现象、原理到几套落地方案完整拆一遍。1. 问题现象报错长什么样什么场景容易出现1.1 常见的异常信息归纳在实际生产环境里JDK 1.6 走 SSL 连数据库失败的报错并不唯一具体取决于驱动版本、数据库类型和服务端反馈。最常见的有这几种javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshakejavax.net.ssl.SSLHandshakeException: Received fatal alert: protocol_versionjavax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failurejava.io.IOException: SQLException: Communications link failure底层包裹着 SSLHandshakeException极端情况下还会出现SSLException: Unsupported or unrecognized SSL message这些报错本质都指向同一个阶段TLS 握手失败。TLS 握手可以理解成双方在交换“该用什么版本、什么加密算法”的约定如果这个约定谈不拢服务端就会发送 fatal alert 或者干脆断开连接。不同 alert 的区别仅仅是服务端拒绝的环节不同protocol_version是版本谈不拢handshake_failure是算法或证书谈不拢remote host closed则是服务端直接不回应。单看报错容易误判我建议第一反应不要全怪 JDK先确认几件事数据库是否强制要求 SSL、驱动是否显式开启 SSL、服务端最近有没有调整过安全策略。因为这种问题往往不是某一天突然坏的而是“某次变更后”开始坏的变更点在数据库配置或者安全基线那边的概率更高。一个典型的异常栈可能长这样javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake at sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:946) at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1301) at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1328) ... Caused by: java.io.EOFException: SSL peer shut down incorrectly看到EOFException和SSL peer shut down incorrectly连在一起基本就是服务端在握手阶段直接关闭了连接而不是网络断开。如果你是在数据库驱动日志里看到驱动通常还会补一句MESSAGE: Communications link failure。这种时候不要反复重启应用浪费时间直接进入协议和套件层面的排查。1.2 最容易踩坑的三个场景第一个场景是老业务系统JDK 版本钉死在 1.6因为历史原因不好升级现场可能有大量基于 JDK 6 编译的老 jar 包、老中间件动一发牵全身。第二个场景是用 MySQL Connector/J 5.x 这类老驱动新驱动虽然支持更高的 TLS但老驱动默认连接行为也可能出现兼容问题比如sslMode配置不当、默认证书校验行为与老客户端不一致。第三个场景就是刚才说的安全扫描扫描报告里出现CVE-2016-2183 原理扫描运维按报告去加固关闭 TLSv1.0/1.1禁用 3DES/RC4只保留 TLSv1.2 和 AES-GCM 套件。这一步在一个支持 TLSv1.2 的客户端看来完全没问题但 JDK 1.6 的 ClientHello 只会携带 TLSv1.0 版本号和一组老套件服务端在自己的欢迎列表里扫一遍没有交集于是直接中断。如果你所在环境同时满足“老 JDK 数据库 SSL 近期安全策略调整”这三个特征基本可以锁定是这类问题。2. 深度拆解为什么 JDK 1.6 会握手失败2.1 JDK 1.6 的 TLS 能力边界要搞清楚为什么得先看看 JDK 1.6 自带的安全套接字层实现长什么样。JDK 1.6 默认支持的协议主要是 SSLv3 和 TLSv1.0TLSv1.1 和 TLSv1.2 并没有被整合进默认的 JSSE 实现。这并不是说 JDK 1.6 完全没有办法用 TLSv1.2——通过额外引入第三方 JSSE provider 也许可以做到但默认状态等于没有而大多数老项目用的就是默认实现。算法方面JDK 1.6 支持 RSA、DHE、AES-128、3DES、RC4 等常见的非 ECC 套件但它默认不支持 ECDHE/ECDSA 这类椭圆曲线加密套件这又是一个隐藏的坎。很多现代服务端首选ECDHE-RSA-AES128-GCM-SHA256这样的套件JDK 1.6 客户端听了根本不认识。我整理了一张简化对照表方便大家快速理解项目JDK 1.6 默认能力协议版本SSLv3、TLSv1.0TLSv1.1不支持TLSv1.2不支持密码套件RSA、DHE、AES-128、3DES、RC4、DESECC 套件不支持AES-256需要额外安装 JCE 无限制权限策略文件另外还有一个细节JDK 1.6 默认只支持 AES-128如果服务端只提供 AES-256客户端就算版本匹配也可能因为缺少local_policy.jar和US_export_policy.jar而协商失败。很多老项目的 JDK 安装目录里根本没有换过这两个策略文件遇到服务端只开 AES-256 时就算 TLS 版本能凑上也会卡在算法套件上没有交集。2.2 服务端收紧策略CVE-2016-2183 原理扫描带来的连锁反应这里要特别说说热词里那个 CVE-2016-2183。这个漏洞编号对应的就是大家常说的 SWEET32 攻击核心问题是 3DES 算法使用 64 位分组在 CBC 模式下长时间大量传输数据后攻击者有机会通过碰撞统计恢复部分明文。安全扫描器里的“原理扫描”是通过主动探测服务端是否还支持 3DES 等弱加密套件来判断是否存在风险并不需要实际发起攻击。一旦扫描报告显示存在 CVE-2016-2183运维通常会去修改服务端的 OpenSSL 或数据库自带 SSL 配置例如在ssl_ciphers里去掉DES-CBC3-SHA在协议配置里去掉TLSv1、TLSv1.1。从安全角度这样做完全正确但代价也很直接当一个只支持 TLSv1.0 3DES 或 RC4 的老客户端发起握手时服务端找不到任何可用的加密套件。TLS 协议规定在这种情况下服务端必须终止握手于是客户端就会看到handshake_failure、protocol_version、remote close等不同表现。我遇到过不少误判以为protocol_version是数据库版本太低导致的实际上是服务端只接受 TLSv1.2 而客户端只提供 TLSv1.0。客户端会在 ClientHello 里注明自己支持的最高协议版本服务端如果只支持更高版本就会返回一个protocol_versionalert意思是“你的版本太旧我不接受”。如果服务端支持 TLSv1.0 但不支持客户端提供的任何加密套件就会返回handshake_failure。如果服务端更严格——比如防火墙或安全模块直接丢弃不满足策略的握手包——客户端看到的就是连接被重置或者远端直接关闭。2.3 为什么升级 JDBC 驱动解决不了根因很多工程师会第一个想到把 JDBC 驱动升级到最新版这个思路方向没错但在这里解决不了根因。JDBC 驱动本身并不实现 TLS 协议栈它只是构建一条从 Java 到数据库的 SSL 连接时把协议的版本、证书信任、密钥库等参数交给 JSSE 去处理。JSSE 是 JDK 内置的安全套接字扩展JDK 1.6 里的 JSSE 不认识 TLSv1.2那么无论驱动如何配置enabledTLSProtocolsTLSv1.2也只是把配置传下去最终执行依然会受到 JDK 底层能力限制。我打个比方JDK 是发动机JDBC 驱动是变速箱你把变速挡位调到 6 档但发动机本身不支持 6 档转速车照样跑不动。升级驱动当然有价值例如修复一些 SSL 参数透传的 bug但如果服务端只接受 TLSv1.2单纯升级驱动还是无济于事。拿 MySQL Connector/J 5.x 举例它的sslEnabledProtocols参数只能在你底层 JSSE 支持 TLSv1.2 时才生效JDK 6 环境里配置这个参数会直接报NoSuchAlgorithmException或者被忽略。所以在着手换驱动之前先确认 JDK 版本支持什么再去讨论驱动配置才有意义。3. 解决方案与实操步骤3.1 首选方案升级 JDK从根上解决升级 JDK 是最推荐的方案也是最终能彻底解决 TLS 兼容性的唯一正规途径。如果你的团队有升级预算和时间窗口优先升到 JDK 8。JDK 7u95 之后的版本虽然也支持 TLSv1.2但默认不一定在所有场景都启用JDK 8 默认就启用了 TLSv1.2生态兼容性也更好。升级时除了替换 JDK 安装包还要关注启动参数、JCE 策略文件、垃圾回收器参数、Agent 类库以及第三方依赖里有没有使用了 JDK 内部 API 的代码。我给一个升级前的检查清单照着做能少踩很多坑确认应用代码里没有直接调用com.sun.*、sun.misc.*这类内部 APIJDK 8 里有些已经变了。确认 JVM 参数是否需要迁移比如-XX:MaxPermSize在 JDK 8 里改成了-XX:MaxMetaspaceSize。检查是否有老版本的 Java Agent如 APM、热部署工具不兼容的话需要同步升级。检查 JDBC 驱动版本MySQL 建议升到 Connector/J 8.0.xOracle 建议升到对应驱动的较新版本。检查 JCE 无限制策略文件JDK 8 的 jce 策略包也要安装否则 AES-256 套件依然受限。在灰度环境跑全量回归尤其是连接池、序列化、反射相关的功能。很多人担心升级 JDK 引入新问题实际上对于绝大多数 CRUD 类应用从 JDK 6 升到 JDK 8 的兼容成本远低于长期维护一个不安全的系统。真正麻烦的是那些嵌入了老版本中间件的项目这种建议单独立项做适配。3.2 临时方案服务端为老客户端保留一条兼容通道如果升级 JDK 暂时做不了只能靠服务端做临时适配。要点是不要全局降低安全基线而是有条件地给老客户端开一条小通道。比如 MySQL 可以同时配置 TLSv1.2 作为主协议再允许内网来源的旧客户端使用 TLSv1.0但加密套件里不要用 3DES/RC4改用 AES-128。因为 CVE-2016-2183 扫的是弱加密算法只要禁用了 3DES扫描结果通常就不会再报这个漏洞。MySQL 的my.cnf里可以这样临时追加[mysqld] tls_versionTLSv1.2,TLSv1.1,TLSv1.0 ssl_cipherAES128-SHA:AES256-SHA这样服务端虽然还会接收 TLSv1.0但只允许使用 AES 套件不会让 3DES 落地。如果数据库是 Oracle对应的sqlnet.ora里可以限制加密套件SQLNET.SSL_VERSION 1.2 SQLNET.SSL_CIPHER_SUITES (SSL_RSA_WITH_AES_128_CBC_SHA, SSL_RSA_WITH_AES_256_CBC_SHA)注意不同数据库版本的参数名和取值范围会有差异改之前先看官方文档。更重要的一点是这个临时方案必须限制来源 IP。如果你没法在数据库层面限制就在防火墙或安全组里只放行应用服务器的 IP 到数据库端口避免让老协议暴露给整个内网甚至公网。我见过不少团队把这个临时方案拖了一年最后被更高层安全检查强制整改反而更被动。注意临时兼容通道只适用于内网短时间过渡不要把它当成长期方案。安全基线下调越久被攻击面就越大。3.3 推荐实践用 stunnel 做 TLS 隧道不升级 JDK 也能安全连库如果你的处境是“JDK 升不动、数据库安全策略又不能降”还有一个通用且干净的招在应用服务器本地部署 stunnel让 stunnel 去承担现代 TLS 加密应用仍然用明文协议连本地端口。具体做法是在应用所在机器上安装 stunnel监听某个本地端口比如 13306转发到真实数据库的 3306 端口而在转发链路里使用强加密的 TLSv1.2 和 AES 套件。应用代码从jdbc:mysql://dbhost:3306改成jdbc:mysql://127.0.0.1:13306并且不开启 JDBC 层的 SSL因为加密已经由 stunnel 完成了。安装 stunnel 很简单CentOS 上执行yum install stunnelUbuntu/Debian 上是apt install stunnel4然后写一个配置文件比如/etc/stunnel/mysql-tunnel.conf[mysql-ssl-tunnel] client yes accept 127.0.0.1:13306 connect db.example.com:3306 protocol tcp verifyChain no sslVersion TLSv1.2 ciphers AES128-SHA:AES256-SHA启动方式stunnel /etc/stunnel/mysql-tunnel.conf如果想开机自启可以把服务配置成 systemd unit或者直接使用发行版自带的 stunnel service 加载配置。这个方案的核心含义是让 stunnel 扮演数据库连接的 TLS 客户端去和服务端完成 TLSv1.2 握手然后在本机 13306 端口接受应用的明文 MySQL 协议流量再原样封装进 TLS 隧道发到数据库。应用看到的还是普通 TCP 连接JDK 1.6 完全没有机会接触 TLS 栈所以就不会报握手问题了。我把这个方案排到“推荐实践”主要是因为它可以在不降低服务端安全等级的前提下稳定解决老客户端连库问题。它的代价是要多维护一个本地进程并且本地到 stunnel 这一段是明文。缓解方法很简单accept地址一定要绑127.0.0.1不要绑0.0.0.0这样只有本机能访问这个明文端口外部机器连不上。如果应用服务器本身已经被攻破那任何方案都谈不上安全所以这个风险在可控范围内。等到后续 JDK 升级完成这个隧道可以平滑下线应用改回直连即可。3.4 其他辅助手段与调试参数在等待上述方案落地期间可以先用几个参数确认问题细节。JDK 1.6 里通过-Djavax.net.debugssl:handshake:verbose启动应用能在控制台看到 ClientHello 携带的协议版本和 cipher suites 列表这是最直接的证据。如果你能确认客户端只发出 TLSv1.0 和 3DES/RC4 套件那就说明问题完全在协议匹配上。还有一点要注意JDK 1.6 虽然有很多安全属性可以在java.security文件里配置但jdk.tls.client.protocols这种属性它并不认识所以不要指望在 JDK 1.6 上通过设置系统属性来启用 TLSv1.2。环境变量层面没有银弹。至于手动往 JDK 1.6 里塞第三方 TLS 实现理论可行但维护成本极高也不推荐在生产环境尝试。如果非要探索可以看一下 Bouncy Castle 的 JSSE provider但它本身更新频率不高对老 JDK 的支持也是个问题容易引入新的安全风险。4. 排查工具与常见错误速查4.1 用 openssl 快速验证服务端能力第一步先确认服务端到底支持哪些协议和加密套件这一步建议用openssl s_client来做。不过要注意MySQL、Oracle、PostgreSQL 这类数据库往往在同一个端口上同时提供明文协议和 TLS 协议客户端需要先发送一个协议协商包再升级到 TLS直接拿openssl s_client连可能会握手失败。新版 OpenSSL 支持-starttls参数可以用来测试 MySQL 和 PostgreSQL。例如测试 MySQLopenssl s_client -connect dbhost:3306 -starttls mysql -tls1_0 -brief openssl s_client -connect dbhost:3306 -starttls mysql -tls1_2 -brief如果-tls1_0直接提示handshake failure说明服务端已经关闭了 TLSv1.0而你的 JDK 1.6 客户端只会 TLSv1.0那根因基本锁定。如果-tls1_0能连上再检查协商出来的 Cipher 是不是 3DES。可以用openssl s_client -connect dbhost:3306 -starttls mysql -tls1_0 -cipher DES-CBC3-SHA -brief如果这个命令能协商成功说明服务端还有 3DES 套件那 CVE-2016-2183 原理扫描为什么报弱算法就有答案了。如果你的 OpenSSL 版本不支持-starttls mysql更通用的办法是直接在应用里开 SSL 调试日志连接一次看 JSSE 到底在哪一步失败。另有一个办法是用 Wireshark 抓包过滤条件填tls.handshake.type 1直接看 ClientHello 和 ServerHello 里各自带了哪些协议和套件。抓包能避免数据库协议协商的干扰但需要能抓到数据库端口上的包实际排障里未必都有这个权限。4.2 常见错误速查表我把这次排查过程中经常遇到的错误整理成了一个速查表方便你在看到异常时快速定位方向报错信息根因方向处理建议Received fatal alert: protocol_versionTLS 版本没有交集客户端只支持 TLSv1.0服务端只接受 TLSv1.2升级 JDK或在服务端临时兼容 TLSv1.0Received fatal alert: handshake_failure版本可能匹配但加密套件没有交集常见于服务端禁用 3DES/RC4 后调整服务端套件为 AES-128或升级客户端Remote host closed connection during handshake服务端主动断开可能是不识别协议或安全设备拦截打开 ssl debug配合抓包确认阶段Connection reset / Broken pipe数据库端口能通但 TLS 握手被直接重置检查服务端 SSL 配置和防火墙策略SSLException: Unsupported record version Unknown客户端把 MySQL 协议或乱数据误当作 TLS 消息确认驱动 sslMode 配置确认数据库真的启用了 SSLNo appropriate protocol客户端 SSLContext 可用的协议都被禁用或没有可用的套件检查 JRE 安全配置文件、JCE 策略文件这张表不一定覆盖所有情况但能帮你在看到异常时更快缩小范围。我建议把每次排障的报错、服务端配置、JDK 版本、驱动版本记到一个文档里遇到类似告警能秒开。4.3 踩坑记录一次安全扫描引发的“事故”最后分享一个亲历的坑。当时安全部门发来一份扫描报告CVE-2016-2183 标红要求限期整改。负责数据库的同事在修改配置时把 MySQL 的tls_version和ssl_cipher一次性收紧只保留 TLSv1.2 和 AES-GCM 套件。改完不到十分钟监控就炸了十几个老项目全都连不上库。我们的排查顺序是先看应用日志发现全是SSLHandshakeException再用openssl s_client测服务端发现 TLSv1.2 正常但 TLSv1.0 已经握手失败进一步在应用上开ssl debug看到客户端只带 TLSv1.0 和几个老套件两边没有交集。事后我们复盘最大的问题不是安全加固本身而是没有提前评估存量客户端的能力边界。如果当时先梳理一下哪些应用还在用 JDK 6、哪些驱动比较老旧提前准备兼容通道或 stunnel完全可以避免一次线上事故。我也是从那次之后才养成一个习惯在处理安全漏洞整改时先做资产盘点把“服务端允许的协议/算法”和“存量客户端的实际能力”做一次交集分析再动手改配置。宁可多花一小时评估也不要让全链路在改完配置后集体熄火。我个人的经验是遇到 JDK 1.6 连接数据库 SSL 失败先别急着甩锅给数据库或者驱动按照“报错信息 → openssl 探测 → SSL 调试日志 → 服务端配置对照”这个顺序走一遍大多数问题都能定位。修复路径上能升级 JDK 就升级升不动就上 stunnel服务端兼容只能作为非常短期的止血手段。安全扫描发现 CVE-2016-2183 这类漏洞后合理的做法不是一刀切关掉所有老协议而是先做好资产梳理和兼容性验证再分批收紧。毕竟安全的最终目标不是把业务跑挂而是让业务在可控的风险里稳定运行。