SSL重新协商与客户端证书导致服务器缓冲区溢出。

6

我编写了一个Java客户端应用程序,使用客户端证书连接到Apache Web服务器,并通过HTTP PUT将文件上传到该服务器。对于小文件,它能够正常工作,但是在处理大文件时会崩溃。

Apache服务器日志显示如下:

...
OpenSSL: Handshake: done
...
Changed client verification type will force renegotiation
...
filling buffer, max size 131072 bytes
...
request body exceeds maximum size (131072) for SSL buffer
could not buffer message body to allow SSL renegotiation to proceed
...    
OpenSSL: I/O error, 5 bytes expected to read on BIO
(104)Connection reset by peer: SSL input filter read failed.
(32)Broken pipe: core_output_filter: writing data to the network
Connection closed to child 20 with standard shutdown

客户端的响应是:
java.io.IOException: Server returned HTTP response code: 401 for URL

我不太熟悉这个过程,所以不确定是否需要重新协商或者是否有什么我可以做来避免它。或者也许我可以让客户端在重新协商完成之前等待再发送应用数据?以下是客户端代码摘录(已删除错误处理):
        URL url = new URL("my url goes here");
        con = (HttpsURLConnection) url.openConnection();
        con.setSSLSocketFactory(getMyCustomClientCertSocketFactory());
        con.setRequestMethod("PUT");
        con.setDoOutput(true);
        con.connect();
        writer = new OutputStreamWriter(con.getOutputStream());
        writer.write(xml);
        writer.close();

        parseServerResponse(con.getInputStream());

我在考虑是否需要使用类似SSLSocket的低级API,并利用HandshakeCompletedListener?此外,我也想知道Apache SSLVerifyDepth指令是否与重新协商有关。我在每个目录上下文中设置了该指令(只有一个上传目录),值为2。Apache手册对此的解释如下:
“在每个目录上下文中,它会强制进行SSL重新协商,并在读取HTTP请求但在发送HTTP响应之前使用重新配置的客户端验证深度。”
根据您的要求,以下是Java调试输出:
keyStore is : 
keyStore type is : jks
keyStore provider is : 
init keystore
init keymanager of type SunX509
trustStore is: C:\Program Files\Java\jdk1.6.0_35\jre\lib\security\cacerts
trustStore type is : jks
trustStore provider is : 
init truststore
adding as trusted cert:
 ...
trigger seeding of SecureRandom
done seeding SecureRandom
***
found key for : key-alias
chain [0] = [
[
...
]
***
trigger seeding of SecureRandom
done seeding SecureRandom
Allow unsafe renegotiation: false
Allow legacy hello messages: true
Is initial handshake: true
Is secure renegotiation: false
%% No cached client session
*** ClientHello, TLSv1
RandomCookie:  ...
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:  ...
Session ID:  ...
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 = 4392
*** Certificate chain
chain [0] = [
[
...
Certificate Extensions: 8
[1]: ObjectId: 1.3.6.1.5.5.7.1.1 Criticality=false
AuthorityInfoAccess [
  [
   accessMethod: ...
   accessLocation: URIName: ...
   accessMethod: ...
   accessLocation: URIName: ...
]

[2]: ObjectId: 2.5.29.35 Criticality=false
AuthorityKeyIdentifier [
KeyIdentifier [
...
]
]
[3]: ObjectId: 2.5.29.19 Criticality=false
BasicConstraints:[
  CA:false
  PathLen: undefined
]
[4]: ObjectId: 2.5.29.31 Criticality=false
CRLDistributionPoints [
  [DistributionPoint:
     [URIName: ...
]]
[5]: ObjectId: 2.5.29.32 Criticality=false
CertificatePolicies [
  [CertificatePolicyId: ...
[PolicyQualifierInfo: [
  qualifierID: ...
  qualifier: ...
]]  ]
]
[6]: ObjectId: 2.5.29.37 Criticality=false
ExtendedKeyUsages [
  serverAuth
  clientAuth
]
[7]: ObjectId: 2.5.29.15 Criticality=true
KeyUsage [
  DigitalSignature
  Key_Encipherment
]
[8]: ObjectId: 2.5.29.17 Criticality=false
SubjectAlternativeName [
  DNSName: ...
]
]
  Algorithm: [SHA1withRSA]
  Signature:
...
]
...
***
main, READ: TLSv1 Handshake, length = 4
*** ServerHelloDone
*** ClientKeyExchange, RSA PreMasterSecret, TLSv1
main, WRITE: TLSv1 Handshake, length = 518
SESSION KEYGEN:
PreMaster Secret:
...
CONNECTION KEYGEN:
Client Nonce:
...
Server Nonce:
...
Master Secret:
...
Client MAC write Secret:
...
Server MAC write Secret:
...
Client write key:
...
Server write key:
...
Client write IV:
...
Server write IV:
...
main, WRITE: TLSv1 Change Cipher Spec, length = 1
*** Finished
verify_data:  { 18, 162, 18, 251, 82, 111, 87, 133, 53, 240, 114, 155 }
***
main, WRITE: TLSv1 Handshake, length = 48
main, READ: TLSv1 Change Cipher Spec, length = 1
main, READ: TLSv1 Handshake, length = 48
*** Finished
verify_data:  { 46, 206, 8, 40, 63, 252, 99, 190, 251, 183, 110, 201 }
***
%% Cached client session: [Session-1, TLS_RSA_WITH_AES_128_CBC_SHA]
main, WRITE: TLSv1 Application Data, length = 256
main, WRITE: TLSv1 Application Data, length = 32
main, WRITE: TLSv1 Application Data, length = 16416
main, WRITE: TLSv1 Application Data, length = 16416
...
main, WRITE: TLSv1 Application Data, length = 16416
main, WRITE: TLSv1 Application Data, length = 16416
main, WRITE: TLSv1 Application Data, length = 512
main, READ: TLSv1 Application Data, length = 304 

按要求,这里是 getMyCustomClientCertSocketFactory 源代码(从 PEM 文件中获取证书和密钥):

public static SSLSocketFactory getMyCustomClientCertSocketFactory(String pemPath,
        boolean verifyPeer)
        throws NoSuchAlgorithmException, FileNotFoundException, IOException,
        KeyStoreException, CertificateException, UnrecoverableKeyException,
        KeyManagementException, InvalidKeySpecException {
    SSLContext context = SSLContext.getInstance("TLS");

    byte[] certAndKey = IOUtil.fileToBytes(new File(pemPath));
    byte[] certBytes = parseDERFromPEM(certAndKey,
            "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----");
    byte[] keyBytes = parseDERFromPEM(certAndKey,
            "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----");

    X509Certificate cert = generateX509CertificateFromDER(certBytes);
    RSAPrivateKey key = generateRSAPrivateKeyFromDER(keyBytes);

    KeyStore keystore = KeyStore.getInstance("JKS");
    keystore.load(null);
    keystore.setCertificateEntry("cert-alias", cert);
    keystore.setKeyEntry("key-alias", key, "changeit".toCharArray(),
            new Certificate[]{cert});

    KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
    kmf.init(keystore, "changeit".toCharArray());

    KeyManager[] km = kmf.getKeyManagers();

    TrustManager[] tm = null;

    if (!verifyPeer) {
        tm = new TrustManager[]{new TrustyTrustManager()};
    }

    context.init(km, tm, null);

    return context.getSocketFactory();
}

如果我使用UNIX实用程序“curl”,我可以无事地传输大文件,所以我想知道它在做什么不同的事情... - Ryan
请在客户端中使用-Djavax.net.debug=ssl,handshake运行,并将输出发布在您的答案中。 - user207421
请将以下与编程有关的内容从英语翻译成中文。只返回翻译后的文本内容,不要进行任何解释或添加其他东西。 - user207421
这个问题,正如你正确地指出的那样,是在Apache端发生的。在Java端进行更改不会产生任何效果。 - user207421
请记住,当我使用UNIX实用工具curl将一个巨大的文件上传到受客户证书保护的目录时,它运行得很好。因此,客户端确实有一些方法可以防止服务器被洪水攻击,而我的Java客户端却没有做到。 - Ryan
显示剩余2条评论
3个回答

7
似乎Sun Java内置的HttpsUrlConnection设施不能以服务器友好的方式处理带有客户端证书的大型HTTP PUT请求(即,不会溢出服务器的SSL重新协商缓冲区)。
我检查了curl的操作以了解“服务器友好”的含义,结果发现有一个名为“Expect”的HTTP 1.1头部,其值为“100继续”(参见规范http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.20)。该头部本质上表示:“我有一个巨大的有效负载,但在发送它之前,请告诉我您是否可以处理它。”这使得终端有时间在发送有效负载之前重新协商客户端证书。
在Sun HttpUrlConnection实现中,似乎不允许使用此头部,并且实际上处于受限制的头部列表中。这意味着即使使用HttpUrlConnection.setRequestProperty方法设置了它,头部也不会被发送到服务器。您可以通过系统属性sun.net.http.allowRestrictedHeaders覆盖受限制的头部,但是由于Sun实现不知道如何处理协议的这一部分,所以客户端会崩溃并弹出套接字异常。
有趣的是,OpenJDK的Java实现似乎支持此头部。此外,Apache HTTP Client库也支持此头部(http://hc.apache.org/);我已经使用Apache HTTP客户端库实现了一个测试程序,并且可以成功使用客户端证书和Expect头部执行大文件的HTTP PUT请求。
因此,解决方案如下:
  1. 将Apache的SSLRenegBufferSize指令设置为一个巨大的数字(例如64MB)。默认值为128K。这种解决方案可能会带来拒绝服务的风险。
  2. 配置一个始终需要客户端证书的主机,而不是只有一些目录需要它。这将避免重新协商。在我的情况下,这不是一个好的选择,因为大多数用户都是匿名的或用户名/密码认证的。只有一个用于程序上传文件的上传目录。我们必须创建一个新的虚拟主机,其有自己的SSL证书,只为这个目录服务。
  3. 使用支持HTTP 1.1 Expect头的客户端。不幸的是,Sun Java不支持这个功能。必须使用第三方库,如Apache HTTP Component Client library,或使用Java socket API自己编写解决方案。
  4. 利用HTTP 1.1持久连接(使用keep-alive的管道化),通过最初发出一个没有大负载的HTTP请求,但会导致重新协商,然后重用连接进行HTTP PUT。理论上,客户端应该能够在上传目录上发出HTTP HEAD或OPTIONS,然后重用相同的连接进行PUT。为了使其工作,持久连接池可能需要仅包含一个连接,以避免“预热”一个连接,然后被另一个PUT请求占用。然而,似乎HttpUrlConnection类不会保留/重用涉及客户端证书或SSL的持久连接,因为我无法使此解决方案起作用。请参见(HttpsUrlConnection and keep-alive)。

看起来Java 7实际上支持Expect头。在我的测试中,我成功地与ChunkedStreamingMode一起使用。因此,有两行新代码:con.setChunkedStreamingMode(0);和con.setRequestProperty("Expect", "100-Continue"); - Ryan

0
java.io.IOException: 服务器返回HTTP响应代码:401,URL为
这是一个应用程序错误。它不是由SSL层引起的。我不确定为什么您在处理较大文件时会收到“401未经授权”的错误,但您还省略了什么是“getMyCustomClientCertSocketFactory()”。 另外,您是否尝试过其他方法,例如“POST”?您是否遇到同样的问题?

1
是的,当Apache在重新协商期间缓冲区溢出时,会发送401。使用GET、POST、PUT等并不重要,只要请求很大——如果应用程序数据大于缓冲区容量,则服务器缓冲区会溢出。是的,我可以增加缓冲区大小,问题就解决了,但这是一种拒绝服务漏洞,不是理想的解决方案。我没有提供getMyCustomClientCertSocketFactory的源代码,因为它只是间接相关的——为了使用HttpsUrlConnection的客户端证书,您必须设置自己的SocketFactory。 - Ryan
我可以增加缓冲区大小来解决问题,但这会导致拒绝服务漏洞,不是理想的解决方案。所以你的意思是Apache的缓冲区大小是问题所在?那么你应该增加它,否则它容易受到DoS攻击。我的意思是,即使你从客户端做出一些改变,如果DoS是你关心的问题,问题仍然存在于服务器上。 - Cratylus
@EJP: 但是HTTP 401是一个HTTP错误,而HTTP运行在SSL之上。那么,握手问题的结果如何可能发送HTTP响应呢?如果您能写一个答案来解释这个问题,我会点赞的,我相信其他人也会觉得有用。 - Cratylus
@EJP:如果Apache想要客户端证书进行身份验证,它将在SSL握手的一部分中请求该证书。如果没有收到证书并且客户端身份验证是强制性的,则服务器将拒绝连接。我能想到与您的评论一致的唯一解释是,握手成功,Apache在握手期间由于缺少证书而不会拒绝连接,但稍后会出现“401”错误。这是奇怪的行为。Tomcat不会这样工作。 - Cratylus
@EJP:我明白了。谢谢你解释这个! - Cratylus
显示剩余6条评论

0

根据现在提供的所有额外信息,我认为你应该将XML分成多个块而不是只有一个。目前你正在写入一个块,这将被SSL分成16k块,由于某种原因(它不应该),这使得Apache变得拥堵。我建议尝试不超过4k的块大小。调整块大小直到它正常工作。

一旦你解决了这个问题,你可能会发现客户端证书问题。不要灰心,这证明你至少已经解决了这个问题。


我不理解这个答案。你是怎么得出Apache无法处理16k块的结论的?如果这是问题的原因,那么我该如何更改块大小? - Ryan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接