author:白泽安全团队
随着安全的普及,https通信应用越发广泛,但是由于对https不熟悉导致开发人员频繁错误的使用https,例如最常见的是未校验https证书从而导致“中间人攻击”,并且由于修复方案也一直是个坑,导致修复这个问题时踩各种坑,故谨以此文简单的介绍相关问题。
本文第一节主要讲述https的握手过程,第二节主要讲述常见的“https中间人攻击”场景,第三节主要介绍证书校验修复方案,各位看官可根据自己口味浏览。
首先来看下https的工作原理,上图大致介绍了https的握手流程,后续我们通过抓包看下每个握手包到底干了些什么神奇的事。
注:本文所有内容以TLS_RSA_WITH_AES_128_CBC_SHA加密组件作为基础进行说明,其他加密组件以及TLS版本会存在一定差异,例如TLS1.3针对移动客户端有了很大的改动,现在的ECDHE等密钥交换算法与RSA作为密钥交换算法也完全不一样,所以有些地方和大家实际操作会存在一定出入。
TCP的三次握手,这是必须的。
证书是https里非常重要的主体,可用来识别对方是否可信,以及用其公钥做密钥交换。可以看见证书里面包含证书的颁发者,证书的使用者,证书的公钥,颁发者的签名等信息。其中Issuer Name是签发此证书的CA名称,用来指定签发证书的CA的可识别的唯一名称(DN, Distinguished Name),用于证书链的认证,这样通过各级实体证书的验证,逐渐上溯到链的终止点,即可信任的根CA,如果到达终点在自己的信任列表内未发现可信任的CA则认为此证书不可信。
验证证书链的时候,用上一级的公钥对证书里的签名进行解密,还原对应的摘要值,再使用证书信息计算证书的摘要值,最后通过对比两个摘要值是否相等,如果不相等则认为该证书不可信,如果相等则认为该级证书链正确,以此类推对整个证书链进行校验,引用高性能网络中的证书链校验图。
二级机构的公钥
网站证书的签名
不仅仅进行证书链的校验,此时还会进行另一个协议即Online Certificate Status Protocol, 该协议为证书状态在线查询协议,一个实时查询证书是否吊销的方式,客户端发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态,这个查询地址会附在证书中供客户端使用。
这是一个零字节信息,用于告诉客户端整个server hello过程已经结束。
客户端在验证证书有效之后发送ClientKeyExchange消息,ClientKeyExchange消息中,会设置48字节的premaster secret,通过密钥交换算法加密发送premaster secret的值,例如通过 RSA公钥加密premaster secret的得到Encrypted PreMaster传给服务端。PreMaster前两个字节是TLS的版本号,该版本号字段是用来防止版本回退攻击的。
从握手包到目前为止,已经出现了三个随机数(客户端的random_c,服务端的random_s,premaster secret),使用这三个随机数以及一定的算法即可获得对称加密AES的加密主密钥Master-key,主密钥的生成非常的精妙,通过阅读RFC2246文档以及openssl的函数 int master_secret(unsigned char *dest,int len,unsigned char *pre_master_secret,int pms_len,unsigned char *label,unsigned char *seed,int seed_len)
可得到主密钥的生成过程如下:
#!cpp PRF(secret, label, seed) = P_MD5(S1, label + seed) XOR P_SHA-1(S2, label + seed);
函数中的参数定义如下:
secret即为pre secret ,label是一个字符串,seed为random_c+random_s,S1为前半部分的pre secret,S2为后半部分的pre secret。分别使用P_MD5()和P_SHA-1进行hash计算得到两个hash,再使用这两个hash进行异或得到最终的主密钥master key,这两个hash由下面的运算得来:
#!cpp P_hash(1/2secret, label,seed) = HMAC_hash(1/2secret, A(1) + seed) + HMAC_hash(1/2secret, A(2) + seed) + HMAC_hash(1/2secret, A(3) + seed) + ...
P_hash()是P_MD5()和P_SHA-1()的统称。
A()的赋值相对比较复杂,变形后如下:
#!cpp A(0)=HMAC(md5,1/2secret,strlen(1/2secret), actual_seed(0), strlen(label)+strlen(seed),temp_md5,NULL); //temp_md5数组用于接收最后生成的hash,attual_seed为label和seed结合 A(0) = HMAC(sha,1/2secret,strlen(1/2secret) , actual_seed(0), strlen(label)+strlen(seed),temp_sha,NULL);// temp_sha数组用于接收最后生成的hash,attual_seed为label和seed结合
简化表达如下:
#!cpp A(i)=HMAC_hash(1/2secret,A(i-1)+seed)
由于只进行一次SHA-1或者MD5产生的hash字节数是不够的,所以使用迭代的方式产生足够多的hash,多出来的hash则直接抛弃。例如,使用P_SHA-1()产生64字节的数据,就需要迭代4次(通过A(4)),产生80字节的输出数据,最后迭代产生的16字节将会被抛弃,只留下64字节的数据,如果使用P_MD5()则需要迭代5次。
发送一个不加密的信息,浏览器使用该信息通知服务器后续的通信都采用协商的通信密钥和加密算法进行加密通信。
验证加密算法的有效性,结合之前所有通信参数的 hash 值与其它相关信息生成一段数据,采用协商密钥 session secret 与算法进行加密,然后发送给服务器用于数据与握手验证,通过验证说明加密算法有效。
Encrypted Handshake Message通过验证之后,服务器同样发送 change_cipher_spec 以通知客户端后续的通信都采用协商的密钥与算法进行加密通信。这里还有一个New Session Ticket并不是必须的,这是服务器做的优化,后续我们再讲解该协议的作用。
同样的,服务端也会发送一个Encrypted Handshake Message供客户端验证加密算法有效性。
经过一大串的的计算之后,终于一切就绪,后续传输的数据可通过主密钥master key进行加密传输,加密数据查看图中的Encrypted Apploication Data字段数据,至此https的一次完整握手以及数据加密传输终于完成。
https里还有很多可优化并且很多精妙的设计,例如为了防止经常进行完整的https握手影响性能,于是通过sessionid来避免同一个客户端重复完成握手,但是又由于sessionid消耗的内存性能比较大,于是又出现了new session ticket,如果客户端表明它支持Session Ticket并且服务端也支持,那么在TLS握手的最后一步服务器将包含一个“New Session Ticket”信息,其中包含了一个加密通信所需要的信息,这些数据采用一个只有服务器知道的密钥进行加密。这个Session Ticket由客户端进行存储,并可以在随后的一次会话中添加到 ClientHello消息的SessionTicket扩展中。虽然所有的会话信息都只存储在客户端上,但是由于密钥只有服务器知道,所以Session Ticket仍然是安全的,因此这不仅避免了性能消耗还保证了会话的安全性。
最后我们可以使用openssl命令来直观的看下https握手的流程:
https握手过程的证书校验环节就是为了识别证书的有效性唯一性等等,所以严格意义上来说https下不存在中间人攻击,存在中间人攻击的前提条件是没有严格的对证书进行校验,或者人为的信任伪造证书,下面一起看下几种常见的https“中间人攻击”场景。
由于客户端没有做任何的证书校验,所以此时随意一张证书都可以进行中间人攻击,可以使用burp里的这个模块进行中间人攻击。
通过浏览器查看实际的https证书,是一个自签名的伪造证书。
做了部分校验,例如在证书校验过程中只做了证书域名是否匹配的校验,可以使用burp的如下模块生成任意域名的伪造证书进行中间人攻击。
实际生成的证书效果,如果只做了域名、证书是否过期等校验可轻松进行中间人攻击(由于chrome是做了证书校验的所以会提示证书不可信任)。
如果客户端对证书链做了校验,那么攻击难度就会上升一个层次,此时需要人为的信任伪造的证书或者安装伪造的CA公钥证书从而间接信任伪造的证书,可以使用burp的如下模块进行中间人攻击。
可以看见浏览器是会报警告的,因为burp的根证书PortSwigger CA并不在浏览器可信任列表内,所以由它作为根证书签发的证书都是不能通过浏览器的证书校验的,如果将PortSwigger CA导入系统设置为可信任证书,那么浏览器将不会有任何警告。
上述第一、二种情况不多加赘述,第三种情况就是我们经常使用的抓手机应用https数据包的方法,即导入代理工具的公钥证书到手机里,再进行https数据包的抓取。导入手机的公钥证书在android平台上称之为受信任的凭据,在ios平台上称之为描述文件,可以通过openssl的命令直接查看我们导入到手机客户端里的这个PortSwiggerCA.crt
可以看见是Issuer和Subject一样的自签名CA公钥证书,另外我们也可以通过证书类型就可以知道此为公钥证书,crt、der格式的证书不支持存储私钥或证书路径(有兴趣的同学可查找证书相关信息)。导入CA公钥证书之后,参考上文的证书校验过程不难发现通过此方式能通过证书链校验,从而形成中间人攻击,客户端使用代理工具的公钥证书加密随机数,代理工具使用私钥解密并计算得到对称加密密钥,再对数据包进行解密即可抓取明文数据包。
一直在说中间人攻击,那么中间人攻击到底是怎么进行的呢,下面我们通过一个流行的MITM开源库mitmproxy来分析中间人攻击的原理。中间人攻击的关键在于https握手过程的ClientKeyExchange,由于pre key交换的时候是使用服务器证书里的公钥进行加密,如果用的伪造证书的公钥,那么中间人就可以解开该密文得到pre_master_secret计算出用于对称加密算法的master_key,从而获取到客户端发送的数据;然后中间人代理工具再使用其和服务端的master_key加密传输给服务端;同样的服务器返回给客户端的数据也是经过中间人解密再加密,于是完整的https中间人攻击过程就形成了,一图胜千言,来吧。
通过读Mitmproxy的源码发现mitmproxy生成伪造证书的函数如下:
通过上述函数一张完美伪造的证书就出现了,使用浏览器通过mitmproxy做代理看下实际伪造出来的证书。
可以看到实际的证书是由mimtproxy颁发的,其中的公钥就是mimtproxy自己的公钥,后续的加密数据就可以使用mimtproxy的私钥进行解密了。如果导入了mitmproxy的公钥证书到客户端,那么该伪造的证书就可以完美的通过客户端的证书校验了。这就是平时为什么导入代理的CA证书到手机客户端能抓取https的原因。
通过上文第一和第二部分的说明,相信大家已经对https有个大概的了解了,那么问题来了,怎样才能防止这些“中间人攻击”呢?
app证书校验已经是一个老生常谈的问题了,但是市场上还是有很多的app未做好证书校验,有些只做了部分校验,例如检查证书域名是否匹配证书是否过期,更多数的是根本就不做校验,于是就造成了中间人攻击。做证书校验需要做完全,只做一部分都会导致中间人攻击,对于安全要求并不是特别高的app可使用如下校验方式:
可参考 http://drops.wooyun.org/tips/3296 ,此类校验方式虽然在导入CA公钥证书到客户端之后会造成中间人攻击,但是攻击门槛已相对较高,所以对于安全要求不是特别高的app可采用此方法进行防御。对于安全有较高要求一些app(例如金融)上述方法或许还未达到要求,那么此时可以使用如下更安全的校验方式,将服务端证书打包放到app里,再建立https链接时使用本地证书和网络下发证书进行一致性校验,可参考安卓官方提供的https连接demo: https://developer.android.com/training/articles/security-ssl.html
#!java import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.FileInputStream; import java.io.InputStream; import java.net.URL; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; import android.content.Context; import android.util.Log; public class TestHttpsConnect { public static void test(Context mcontext, String name, String weburl) throws Exception { CertificateFactory cf = CertificateFactory.getInstance("X.509"); InputStream caInput = new BufferedInputStream(new FileInputStream("baidu.cer")); Certificate ca; try { ca = cf.generateCertificate(caInput); System.out.println("ca=" + ((X509Certificate) ca).getSubjectDN()); } finally { caInput.close(); } String keyStoreType = KeyStore.getDefaultType(); KeyStore keyStore = KeyStore.getInstance(keyStoreType); keyStore.load(null, null); keyStore.setCertificateEntry("ca", ca); String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm); tmf.init(keyStore); SSLContext context = SSLContext.getInstance("TLS"); context.init(null, tmf.getTrustManagers(), null); URL url = new URL(weburl); HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection(); urlConnection.setSSLSocketFactory(context.getSocketFactory()); InputStream in = urlConnection.getInputStream(); ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len = -1; while ((len = in.read(buffer)) >= 0) { arrayOutputStream.write(buffer, 0, len); } Log.e("", arrayOutputStream.toString()); } }
此类校验即便导入CA公钥证书也无法进行中间人攻击,但是相应的维护成本会相对升高,例如服务器证书过期,证书更换时如果app不升级就无法使用,那么可以改一下:
这样即可避免强升的问题,但是问题又来了,这样效率是不是低太多了?答案是肯定的,所以对于安全要求一般的应用使用第一种方法即可,对于一些安全要求较高的例如金融企业可选择第二种方法。
说了挺多,但是该来的问题还是会来啊!现在的app一般采用混合开发,会使用很多webveiw直接加载html5页面,上面的方法只解决了java层证书校验的问题,并没有涉及到webview里面的证书校验,对于这种情况怎么办呢?既然问题来了那么就一起说说解决方案,对于webview加载html5进行证书校验的方法如下:
提供参考代码如下:
#!java package com.example.testhttps; import java.io.InputStream; import java.net.URL; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; import android.app.Activity; import android.content.Intent; import android.graphics.Bitmap; import android.net.http.SslError; import android.os.AsyncTask; import android.os.Bundle; import android.util.Log; import android.webkit.SslErrorHandler; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Toast; public class TestWebViewActivity extends Activity { static final String TAB = "MainActivity"; WebView mWebView; SSLContext mSslContext; KeyStore keyStore; TrustManagerFactory tmf; @Override protected void onCreate(Bundle savedInstanceState) { // TODO Auto-generated method stub super.onCreate(savedInstanceState); setContentView(R.layout.test_webview_acitity); iniData(); mWebView = (WebView) findViewById(R.id.webView1); mWebView.setWebViewClient(new MyWebViewClient()); mWebView.getSettings().setJavaScriptEnabled(true); Intent i = getIntent(); String url = i.getStringExtra("url"); mWebView.loadUrl(url); } /** * 初始化证书 */ void iniData() { try { CertificateFactory cf = CertificateFactory.getInstance("X.509"); InputStream caInput = getAssets().open("baidu.cer"); Certificate ca; try { ca = cf.generateCertificate(caInput); System.out.println("ca=" + ((X509Certificate) ca).getSubjectDN()); } finally { caInput.close(); } String keyStoreType = KeyStore.getDefaultType(); keyStore = KeyStore.getInstance(keyStoreType); keyStore.load(null, null); keyStore.setCertificateEntry("ca", ca); String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); tmf = TrustManagerFactory.getInstance(tmfAlgorithm); tmf.init(keyStore); mSslContext = SSLContext.getInstance("TLS"); mSslContext.init(null, tmf.getTrustManagers(), null); } catch (Exception e) { Log.e("", "iniData error"); e.printStackTrace(); } } class MyWebViewClient extends WebViewClient { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { // TODO Auto-generated method stub super.onPageStarted(view, url, favicon); // 如果不是https,不用校验 if (!url.startsWith("https://")) { return; } final WebView tempView = view; final String tempurl = url; /** * 测试url校验,如果不通过,就不加载 */ new AsyncTask<String, Void, Boolean>() { @Override protected Boolean doInBackground(String... params) { // TODO Auto-generated method stub try { // 检验证书是否正确 URL url = new URL(tempurl); HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection(); urlConnection.setSSLSocketFactory(mSslContext.getSocketFactory()); InputStream in = urlConnection.getInputStream(); in.close(); // TestHttpsConnect.test(TestWebViewActivity.this, // "baidu.cer", tempurl); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); return false; } return true; } protected void onPostExecute(Boolean result) { if (!result) { Toast.makeText(TestWebViewActivity.this, "证书校验错误", Toast.LENGTH_SHORT).show(); tempView.stopLoading(); } }; }.execute(url); } @Override public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { // TODO Auto-generated method stub super.onReceivedSslError(view, handler, error); // Log.e(TAB, "onReceivedSslError"); } } }