浅析HTTPS中间人攻击与证书校验
0x00 引言
随着安全的普及,https通信应用越发广泛,但是由于对https不熟悉导致开发人员频繁错误的使用https,例如最常见的是未校验https证书从而导致“中间人攻击”,并且由于修复方案也一直是个坑,导致修复这个问题时踩各种坑,故谨以此文简单的介绍相关问题。
本文第一节主要讲述https的握手过程,第二节主要讲述常见的“https中间人攻击”场景,第三节主要介绍证书校验修复方案,各位看官可根据自己口味浏览。
0x01 Https原理
首先来看下https的工作原理,上图大致介绍了https的握手流程,后续我们通过抓包看下每个握手包到底干了些什么神奇的事。
注:本文所有内容以TLS_RSA_WITH_AES_128_CBC_SHA加密组件作为基础进行说明,其他加密组件以及TLS版本会存在一定差异,例如TLS1.3针对移动客户端有了很大的改动,现在的ECDHE等**交换算法与RSA作为**交换算法也完全不一样,所以有些地方和大家实际操作会存在一定出入。
1.1 TCP
TCP的三次握手,这是必须的。
1.2 Client Hello
TLS的版本号 随机数random_c:这个是用来生成最后加***的因子之一,它包含两部分,时间戳和随机数 session-id:用来标识会话,第一次握手时为空,如果以前建立过,可以直接带过去从而避免完全握手 Cipher Suites加密组件列表:浏览器所支持的加密算法的清单客户端支持的加密签名算法的列表,让服务器进行选择 扩展字段:比如密码交换算法的参数、请求主机的名字,用于单ip多域名的情况指定域名。
1.3 Sever Hello
随机数rando_s,这个是用来生成最后加***的因子之一,包含两部分,时间戳和随机数 32字节的SID,在我们想要重新连接到该站点的时候可以避免一整套握手过程。 在客户端提供的加密组件中,服务器选择了TLS_RSA_WITH_AES_128_CBC_SHA组件。1.4 Certificate
证书是https里非常重要的主体,可用来识别对方是否可信,以及用其公钥做**交换。可以看见证书里面包含证书的颁发者,证书的使用者,证书的公钥,颁发者的签名等信息。其中Issuer Name是签发此证书的CA名称,用来指定签发证书的CA的可识别的唯一名称(DN, Distinguished Name),用于证书链的认证,这样通过各级实体证书的验证,逐渐上溯到链的终止点,即可信任的根CA,如果到达终点在自己的信任列表内未发现可信任的CA则认为此证书不可信。
验证证书链的时候,用上一级的公钥对证书里的签名进行解密,还原对应的摘要值,再使用证书信息计算证书的摘要值,最后通过对比两个摘要值是否相等,如果不相等则认为该证书不可信,如果相等则认为该级证书链正确,以此类推对整个证书链进行校验,引用高性能网络中的证书链校验图。
二级机构的公钥
网站证书的签名
不仅仅进行证书链的校验,此时还会进行另一个协议即Online Certificate Status Protocol, 该协议为证书状态在线查询协议,一个实时查询证书是否吊销的方式,客户端发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态,这个查询地址会附在证书*客户端使用。
1.5 Server Hello Done
这是一个零字节信息,用于告诉客户端整个server hello过程已经结束。
1.6 ClientKeyExchange
客户端在验证证书有效之后发送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)可得到主**的生成过程如下:
1 2 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由下面的运算得来:
1 2 3 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()的赋值相对比较复杂,变形后如下:
1 2 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结合
简化表达如下:
1 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次。
1.7 Change Cipher Spec
发送一个不加密的信息,浏览器使用该信息通知服务器后续的通信都采用协商的通信**和加密算法进行加密通信。
1.8 Encrypted Handshake Message
验证加密算法的有效性,结合之前所有通信参数的 hash 值与其它相关信息生成一段数据,采用协商** session secret 与算法进行加密,然后发送给服务器用于数据与握手验证,通过验证说明加密算法有效。
1.9 Change_cipher_spec
Encrypted Handshake Message通过验证之后,服务器同样发送 change_cipher_spec 以通知客户端后续的通信都采用协商的**与算法进行加密通信。这里还有一个New Session Ticket并不是必须的,这是服务器做的优化,后续我们再讲解该协议的作用。
1.10 Encrypted Handshake Message
同样的,服务端也会发送一个Encrypted Handshake Message供客户端验证加密算法有效性。
1.11 Application Data
经过一大串的的计算之后,终于一切就绪,后续传输的数据可通过主**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握手的流程:
0x02 中间人攻击
https握手过程的证书校验环节就是为了识别证书的有效性唯一性等等,所以严格意义上来说https下不存在中间人攻击,存在中间人攻击的前提条件是没有严格的对证书进行校验,或者人为的信任伪造证书,下面一起看下几种常见的https“中间人攻击”场景。
2.1 证书未校验
由于客户端没有做任何的证书校验,所以此时随意一张证书都可以进行中间人攻击,可以使用burp里的这个模块进行中间人攻击。
通过浏览器查看实际的https证书,是一个自签名的伪造证书。
2.2 部分校验
做了部分校验,例如在证书校验过程中只做了证书域名是否匹配的校验,可以使用burp的如下模块生成任意域名的伪造证书进行中间人攻击。
实际生成的证书效果,如果只做了域名、证书是否过期等校验可轻松进行中间人攻击(由于chrome是做了证书校验的所以会提示证书不可信任)。
2.3 证书链校验
如果客户端对证书链做了校验,那么攻击难度就会上升一个层次,此时需要人为的信任伪造的证书或者安装伪造的CA公钥证书从而间接信任伪造的证书,可以使用burp的如下模块进行中间人攻击。
可以看见浏览器是会报警告的,因为burp的根证书PortSwigger CA并不在浏览器可信任列表内,所以由它作为根证书签发的证书都是不能通过浏览器的证书校验的,如果将PortSwigger CA导入系统设置为可信任证书,那么浏览器将不会有任何警告。
2.4 手机客户端Https数据包抓取
上述第一、二种情况不多加赘述,第三种情况就是我们经常使用的抓手机应用https数据包的方法,即导入代理工具的公钥证书到手机里,再进行https数据包的抓取。导入手机的公钥证书在android平台上称之为受信任的凭据,在ios平台上称之为描述文件,可以通过openssl的命令直接查看我们导入到手机客户端里的这个PortSwiggerCA.crt
可以看见是Issuer和Subject一样的自签名CA公钥证书,另外我们也可以通过证书类型就可以知道此为公钥证书,crt、der格式的证书不支持存储私钥或证书路径(有兴趣的同学可查找证书相关信息)。导入CA公钥证书之后,参考上文的证书校验过程不难发现通过此方式能通过证书链校验,从而形成中间人攻击,客户端使用代理工具的公钥证书加密随机数,代理工具使用私钥解密并计算得到对称加***,再对数据包进行解密即可抓取明文数据包。
2.5 中间人攻击原理
一直在说中间人攻击,那么中间人攻击到底是怎么进行的呢,下面我们通过一个流行的MITM开源库mitmproxy来分析中间人攻击的原理。中间人攻击的关键在于https握手过程的ClientKeyExchange,由于pre key交换的时候是使用服务器证书里的公钥进行加密,如果用的伪造证书的公钥,那么中间人就可以解开该密文得到pre_master_secret计算出用于对称加密算法的master_key,从而获取到客户端发送的数据;然后中间人代理工具再使用其和服务端的master_key加密传输给服务端;同样的服务器返回给客户端的数据也是经过中间人解密再加密,于是完整的https中间人攻击过程就形成了,一图胜千言,来吧。
通过读Mitmproxy的源码发现mitmproxy生成伪造证书的函数如下:
通过上述函数一张完美伪造的证书就出现了,使用浏览器通过mitmproxy做代理看下实际伪造出来的证书。
可以看到实际的证书是由mimtproxy颁发的,其中的公钥就是mimtproxy自己的公钥,后续的加密数据就可以使用mimtproxy的私钥进行解密了。如果导入了mitmproxy的公钥证书到客户端,那么该伪造的证书就可以完美的通过客户端的证书校验了。这就是平时为什么导入代理的CA证书到手机客户端能抓取https的原因。
0x03 App证书校验
通过上文第一和第二部分的说明,相信大家已经对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不升级就无法使用,那么可以改一下:
生成一对RSA的公私钥,公钥可硬编码在app,私钥放服务器。 https握手前可通过服务器下发证书信息,例如公钥、办法机构、签名等,该下发的信息使用服务器里的私钥进行签名; 通过app里预置的公钥验签得到证书信息并存在内容*后续使用; 发起https连接获取服务器的证书,通过对比两个证书信息是否一致进行证书校验。
这样即可避免强升的问题,但是问题又来了,这样效率是不是低太多了?答案是肯定的,所以对于安全要求一般的应用使用第一种方法即可,对于一些安全要求较高的例如金融企业可选择第二种方法。
说了挺多,但是该来的问题还是会来啊!现在的app一般采用混合开发,会使用很多webveiw直接加载html5页面,上面的方法只解决了java层证书校验的问题,并没有涉及到webview里面的证书校验,对于这种情况怎么办呢?既然问题来了那么就一起说说解决方案,对于webview加载html5进行证书校验的方法如下:
webview创建实例加载网页时通过onPageStart方法返回url地址; 将返回的地址转发到java层使用上述的证书校验代码进行进行校验; 如果证书校验出错则使用stoploading()方法停止网页加载,证书校验通过则正常加载。
提供参考代码如下:
#!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() {
@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");
}
}
}