New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一次域名切换引发的血案 #13

Open
ditunes opened this Issue Jan 21, 2017 · 0 comments

Comments

Projects
None yet
1 participant
@ditunes
Owner

ditunes commented Jan 21, 2017

惊现问题

最近在做域名切换的时候,从某个业务系统登录进行统一身份服务时候出现了如下异常:在此之前,我们使用的是xx.cn 目前改为xx.com

javax.net.ssl.SSLHandshakeException: java.security.cert.CertificateException: No subject alternative DNS name matching cas-qa.linesum.com found.
        at com.sun.net.ssl.internal.ssl.Alerts.getSSLException(Alerts.java:174)
        at com.sun.net.ssl.internal.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1747)
        at com.sun.net.ssl.internal.ssl.Handshaker.fatalSE(Handshaker.java:241)
        at com.sun.net.ssl.internal.ssl.Handshaker.fatalSE(Handshaker.java:235)
        at com.sun.net.ssl.internal.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1209)
        at com.sun.net.ssl.internal.ssl.ClientHandshaker.processMessage(ClientHandshaker.java:135)
        at com.sun.net.ssl.internal.ssl.Handshaker.processLoop(Handshaker.java:593)
        at com.sun.net.ssl.internal.ssl.Handshaker.process_record(Handshaker.java:529)
        at com.sun.net.ssl.internal.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:943)
        at com.sun.net.ssl.internal.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1188)
        at com.sun.net.ssl.internal.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1215)
        at com.sun.net.ssl.internal.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1199)
        at sun.net.www.protocol.https.HttpsClient.afterConnect(HttpsClient.java:434)
        at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:166)
        at sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1195)
        at sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:234)
        at org.jasig.cas.client.util.CommonUtils.getResponseFromServer(CommonUtils.java:326)
        at org.jasig.cas.client.util.CommonUtils.getResponseFromServer(CommonUtils.java:305)
        at org.jasig.cas.client.validation.AbstractCasProtocolUrlBasedTicketValidator.retrieveResponseFromServer(AbstractCasProtocolUrlBasedTicketValidator.java:50)
        at org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator.validate(AbstractUrlBasedTicketValidator.java:207)
        at org.apache.shiro.cas.CasRealm.doGetAuthenticationInfo(CasRealm.java:144)
        at cn.rxxxx.xxxx.server.shiro.realm.UapDbRealm.doGetAuthenticationInfo(UapDbRealm.java:80)
        at org.apache.shiro.realm.AuthenticatingRealm.getAuthenticationInfo(AuthenticatingRealm.java:568)
        at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doSingleRealmAuthentication(ModularRealmAuthenticator.java:180)
        at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doAuthenticate(ModularRealmAuthenticator.java:267)
        at org.apache.shiro.authc.AbstractAuthenticator.authenticate(AbstractAuthenticator.java:198)
        at org.apache.shiro.mgt.AuthenticatingSecurityManager.authenticate(AuthenticatingSecurityManager.java:106)
        at org.apache.shiro.mgt.DefaultSecurityManager.login(DefaultSecurityManager.java:270)
        at org.apache.shiro.subject.support.DelegatingSubject.login(DelegatingSubject.java:256)
        at org.apache.shiro.web.filter.authc.AuthenticatingFilter.executeLogin(AuthenticatingFilter.java:53)
        at org.apache.shiro.cas.CasFilter.onAccessDenied(CasFilter.java:85)
        at org.apache.shiro.web.filter.AccessControlFilter.onAccessDenied(AccessControlFilter.java:133)
        at org.apache.shiro.web.filter.AccessControlFilter.onPreHandle(AccessControlFilter.java:162)
        at org.apache.shiro.web.filter.PathMatchingFilter.isFilterChainContinued(PathMatchingFilter.java:203)
        at org.apache.shiro.web.filter.PathMatchingFilter.preHandle(PathMatchingFilter.java:178)
        at org.apache.shiro.web.servlet.AdviceFilter.doFilterInternal(AdviceFilter.java:131)
        at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
        at org.apache.shiro.web.servlet.ProxiedFilterChain.doFilter(ProxiedFilterChain.java:66)
        at org.apache.shiro.web.servlet.AbstractShiroFilter.executeChain(AbstractShiroFilter.java:449)
        at org.apache.shiro.web.servlet.AbstractShiroFilter$1.call(AbstractShiroFilter.java:365)
        at org.apache.shiro.subject.support.SubjectCallable.doCall(SubjectCallable.java:90)
        at org.apache.shiro.subject.support.SubjectCallable.call(SubjectCallable.java:83)

什么是 Subject Alternative name?

异常中no subject alternative DNS name让我很好奇,这是一个什么字段,于是查了一下证书的一些知识。

The Subject Alternative Name field lets you specify additional host names (sites, IP addresses, common names, etc.) to be protected by a single SSL Certificate, such as a Multi-Domain (SAN) or Extend Validation Multi-Domain Certificate.

可以看出subject alternative name 可以定义多个域名、ip地址等,以实现一个证书认证多个地址、多个域名。具体的解释可以参考Multi-Domain (SAN) Certificates - Using Subject Alternative Names
具体可以参见如下图片:

所以之前那个问题应该是证书中DNS name 字段值无法匹配cas.linesum.com.

于是打开证书去做比较,先记录几个在线查看的工具:

差别参见

左边是com证书 右边是cn证书,cn证书目前运行正常而com运行存在问题,于是做了对比发现com证书也仅是与cn证书顺序不一样而已。

初识SNI(服务器名称指示)

后来证书机构调整了SAN中域名的顺序,放到了测试环境进行测试,果然就可以使用了。然而在正式环境中去使用,仍然发生错误,还是之前No subject alternative DNS name matching的异常。于是我将测试环境的nginx配置修改为和正式环境nginx配置格式一致方式进行测试,此时测试环境也出现同样问题。我又在本机通过如下代码访问https://cas-qa/linesum.com进行测试发现了某种异常:

URL url = new URL("https://cas-qa.linesum.com");
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
conn.setHostnameVerifier(new CertificateHostNameVerifier());
conn.getContent();

那么问题来了,为什么我请求的是com的域名,服务端却给我一个cn的证书?那为什么用浏览器去访问com域名却没有证书不安全的提示呢?
nginx 关于如何配置https服务器(Configuring HTTPS servers)也给予了一些提示:

Name-based HTTPS servers
A common issue arises when configuring two or more HTTPS servers listening on a single IP address:

server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate www.example.com.crt;
...
}

server {
listen 443 ssl;
server_name www.example.org;
ssl_certificate www.example.org.crt;
...
}

With this configuration a browser receives the default server’s certificate, i.e. www.example.com regardless of the requested server name. This is caused by SSL protocol behaviour. The SSL connection is established before the browser sends an HTTP request and nginx does not know the name of the requested server. Therefore, it may only offer the default server’s certificate.

这一信息指出执行SSL协议是在http通信之前,此时nginx并不知道客户端请求的是哪一个服务器。所以我在nginx上配置了com和cn两个域名的https服务且cn的服务在前,使得其成为了默认https服务。当我的程序请求的时候,nginx不知道我要访问哪个https服务,于是就给了一个默认服务即cn证书,导致了上述异常的发生。后来去调整Nginx com和cn的顺序果然解决了异常。
这就解答了为什么我请求的是com的域名,服务端却给我一个cn的证书这个问题,然而那为什么用浏览器去访问com域名却没有证书不安全的提示呢?同样是在nginx这边说明文章中提到:

Server Name Indication

A more generic solution for running several HTTPS servers on a single IP address is TLS Server Name Indication extension (SNI, RFC 6066), which allows a browser to pass a requested server name during the SSL handshake and, therefore, the server will know which certificate it should use for the connection. However, SNI has limited browser support. Currently it is supported starting with the following browsers versions:

Opera 8.0;
MSIE 7.0 (but only on Windows Vista or higher);
Firefox 2.0 and other browsers using Mozilla Platform rv:1.8.1;
Safari 3.2.1 (Windows version supports SNI on Vista or higher);
and Chrome (Windows version supports SNI on Vista or higher, too).

也就是说浏览器存在支持TLS 的SNI协议扩展,允许在SSL执行握手的时候向服务器发送请求的服务名信息,使得服务端可以判断需要给客户端什么证书,实现一个IP使用多个域名和多个证书的需求。于是这里又引出一个概念叫SNI(服务器名称指示) 具体可以参考
SNI 服务器名称指示。 但其中最核心的一句话:

在该协议下,在握手过程开始时通过客户端告诉它正在连接的服务器的主机名称。这允许服务器在相同的IP地址和TCP端口号上呈现多个证书,并且因此允许在相同的IP地址上提供多个安全(HTTPS)网站(或其他任何基于TLS的服务),而不需要所有这些站点使用相同的证书

解答了上述两个疑惑之后,如何解决java代码请求拥有多个证书的https服务器的异常呢?首先要检测一下我们的nginx是否支持SNI。

$ nginx -V
 ...
TLS SNI support enabled
...

In order to use SNI in nginx, it must be supported in both the OpenSSL library with which the nginx binary has been built as well as the library to which it is being dynamically linked at run time. OpenSSL supports SNI since 0.9.8f version if it was built with config option “--enable-tlsext”. Since OpenSSL 0.9.8j this option is enabled by default. If nginx was built with SNI support, then nginx will show this when run with the “-V” switch:

得知目前服务器nginx是支持SNI的,那就需要检验一下我们的客户端是否支持该协议。

Java 对SNI的支持

经过查询发现java 6从6u121 b31版本开始就支持SNI协议,具体可以参见Java SE 6 Advanced and Java SE 6 Support

java 8 存在的SNI的BUG

本人在java 8中设置CertificateHostNameVerifier() 执行之前的测试代码依然有这样的问题,Java SSL handshake with Server Name Identification (SNI)这篇文章给出了解决方案,文中提到java8中存在一旦设置了HostNameVerifier就会丢失SNI信息的bug,具体可以参见Custom HostnameVerifier disables SNI extension 该文提到了一个解决方案,经过实测确实有效。但这个bug需要在java 9中才能得以解决。总的而言对于java 8来说不需要设置自定义的HostNameVerifier,就可以规避这个问题。

java 中如何调试SSL协议

增加java vm启动参数-Djavax.net.debug=ssl:handshake或者执行System.setProperty("javax.net.debug","ssl:handshake")。这样涉及到https请求的时候,所有SSL之间握手过程都会以日志形式在控制台中呈现。

错误是如何发生的?

掌握了调试SSL协议的方法之后,我们就可以重新事故发生现场,仍然使用如下测试代码:

URL url = new URL("https://cas-qa.linesum.com");
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
conn.getContent();

在java 6中SSL 握手过程如下所示




*** ClientHello, TLSv1
RandomCookie:  GMT: 1468207522 bytes = { 91, 115, 85, 231, 141, 180, 63, 237, 154, 71, 174, 193, 225, 14, 174, 122, 179, 152, 249, 109, 38, 117, 87, 29, 155, 196, 56, 112 }
Session ID:  {}
Cipher Suites: [SSL_RSA_WITH_RC4_128_MD5, SSL_RSA_WITH_RC4_128_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_DSS_WITH_AES_128_CBC_SHA, SSL_RSA_WITH_3DES_EDE_CBC_SHA, SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA, SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA, SSL_RSA_WITH_DES_CBC_SHA, SSL_DHE_RSA_WITH_DES_CBC_SHA, SSL_DHE_DSS_WITH_DES_CBC_SHA, SSL_RSA_EXPORT_WITH_RC4_40_MD5, SSL_RSA_EXPORT_WITH_DES40_CBC_SHA, SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA, SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA, TLS_EMPTY_RENEGOTIATION_INFO_SCSV]
Compression Methods:  { 0 }
***
main, WRITE: TLSv1 Handshake, length = 75
main, WRITE: SSLv2 client hello message, length = 101
main, READ: TLSv1 Handshake, length = 81

---------------------------------------

*** ServerHello, TLSv1
RandomCookie:  GMT: 1468207524 bytes = { 118, 171, 75, 125, 101, 152, 60, 148, 228, 133, 78, 190, 27, 95, 134, 46, 4, 67, 72, 126, 211, 194, 139, 163, 20, 254, 183, 15 }
Session ID:  {224, 238, 97, 95, 170, 182, 150, 24, 223, 121, 181, 4, 34, 208, 55, 187, 39, 217, 195, 90, 85, 192, 204, 185, 107, 190, 94, 37, 191, 250, 239, 69}
Cipher Suite: TLS_RSA_WITH_AES_128_CBC_SHA
Compression Method: 0
Extension renegotiation_info, renegotiated_connection: <empty>
***
%% Created:  [Session-1, TLS_RSA_WITH_AES_128_CBC_SHA]
** TLS_RSA_WITH_AES_128_CBC_SHA
main, READ: TLSv1 Handshake, length = 4491
*** Certificate chain
chain [0] = [
[
  Version: V3
  Subject: CN=*.linesum.cn, O=乐商云集(厦门)供应链有限公司, L=厦门市, ST=福建省, C=CN
  Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11

从java 6 早期版本 SSL握手过程可以看到,服务端最终发来的是cn证书,这也就是导致后续认证域名与请求域名匹配异常的原因所在,那我们再看看java 8中的SSL握手。如下所示:


*** ClientHello, TLSv1.2
RandomCookie:  GMT: 1468207356 bytes = { 171, 203, 93, 153, 205, 48, 60, 39, 14, 223, 1, 232, 227, 1, 222, 96, 65, 204, 243, 118, 216, 101, 60, 151, 198, 140, 142, 246 }
Session ID:  {}
Cipher Suites: [TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA256, TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256, TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, TLS_DHE_DSS_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDH_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_RSA_WITH_AES_128_CBC_SHA, TLS_DHE_DSS_WITH_AES_128_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256, TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, TLS_DHE_DSS_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA, TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, SSL_RSA_WITH_3DES_EDE_CBC_SHA, TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA, TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA, SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA, SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA, TLS_EMPTY_RENEGOTIATION_INFO_SCSV]
Compression Methods:  { 0 }
Extension elliptic_curves, curve names: {secp256r1, sect163k1, sect163r2, secp192r1, secp224r1, sect233k1, sect233r1, sect283k1, sect283r1, secp384r1, sect409k1, sect409r1, secp521r1, sect571k1, sect571r1, secp160k1, secp160r1, secp160r2, sect163r1, secp192k1, sect193r1, sect193r2, secp224k1, sect239k1, secp256k1}
Extension ec_point_formats, formats: [uncompressed]
Extension signature_algorithms, signature_algorithms: SHA512withECDSA, SHA512withRSA, SHA384withECDSA, SHA384withRSA, SHA256withECDSA, SHA256withRSA, SHA1withECDSA, SHA1withRSA, SHA1withDSA
Extension server_name, server_name: [type=host_name (0), value=cas-qa.linesum.com]
***
main, WRITE: TLSv1.2 Handshake, length = 216
main, READ: TLSv1.2 Handshake, length = 93

---------------------------------------------------------------------

*** ServerHello, TLSv1.2
RandomCookie:  GMT: 1468207358 bytes = { 86, 175, 103, 44, 250, 178, 230, 69, 74, 47, 198, 156, 45, 216, 51, 177, 72, 117, 43, 122, 212, 250, 40, 92, 209, 145, 32, 33 }
Session ID:  {153, 14, 209, 38, 181, 54, 184, 193, 193, 227, 227, 228, 150, 121, 24, 228, 136, 12, 67, 9, 0, 249, 3, 160, 156, 104, 251, 53, 84, 126, 46, 61}
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
Compression Method: 0
Extension server_name, server_name: 
Extension renegotiation_info, renegotiated_connection: <empty>
Extension ec_point_formats, formats: [uncompressed, ansiX962_compressed_prime, ansiX962_compressed_char2]
***
%% Initialized:  [Session-1, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256]
** TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
main, READ: TLSv1.2 Handshake, length = 5412
*** Certificate chain
chain [0] = [
[
  Version: V3
  Subject: CN=*.linesum.com, OU=PositiveSSL Wildcard, OU=Domain Control Validated
  Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11

  Key:  Sun RSA public key, 2048 bits
  modulus: 20789488783322355908252760451112892924377419885794438130408928744024426930074322682004916417578973335721251078862041506577747156082374007036289271552650607742069565398969806374993529826484095580500761357431704443340138014817594884337771781697380501937100467592882289937965997729116806338070122574454286083741774010065504447236360509904250341648767965337000180819935392743532046185491462347874364758411693240178050814644303717246512828524436253153970633470422911026341924274711412001126197412964595991148737182530368795771514723328652324795362748798608124694309109392141153110573955895187360800086547833307754468308821
  public exponent: 65537
  Validity: [From: Fri Jan 06 08:00:00 CST 2017,
               To: Sun Jan 07 07:59:59 CST 2018]
  Issuer: CN=COMODO RSA Domain Validation Secure Server CA, O=COMODO CA Limited, L=Salford, ST=Greater Manchester, C=GB
  SerialNumber: [    eae355d7 62a3debb d58ed66b 38da5c79]

在java 8中在client hello阶段会传递如下数据Extension server_name, server_name: [type=host_name (0), value=cas-qa.linesum.com]这就是前面提及的SNI信息。我们会看到此时服务端能够正确返回com证书。

最后的解决方案

知道了那么多道理之后,生活依然过的不好。由于当前各个业务系统服务器所使用的都是早期jdk 6版本,而且我们的CAS client包中在执行https默认会使用DefaultHostNameVerifier(之前提及的java 8 Bug也会出现),所以即使升级到java 8 也没办法很好解决。最后的办法是将cas client包中设置使用AnyHostNameVerifier即客户端不检验服务端。


SSL协议资料
SSL/TLS协议运行机制的概述 阮一峰
图解SSL/TLS协议 阮一峰

@ditunes ditunes added the blog label Jan 21, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment