轉(zhuǎn)自天貓 Android開發(fā)工程師:Longerian
原文閱讀:Android App 安全的HTTPS 通信
起因
前段時(shí)間,同事拿著一個(gè)代碼安全掃描出來的 bug 過來咨詢,我一看原來是個(gè) https
通信時(shí)數(shù)字證書校驗(yàn)的漏洞,一想就明白了大概;其實(shí)這種問題早兩年就有大規(guī)模的暴露,各大廠商App
也紛紛中招,想不到過了這么久天貓客戶端里還留有這種坑;然后仔細(xì)研究了漏洞所在的代碼片段,原來所屬的是新浪微博分享 sdk 內(nèi)部的,因?yàn)檫@個(gè) sdk
是源碼引用的,一直沒有更新,年久失修,所以也就被掃描出來了。因此給出的解決方案是:
先獲取最新的 sdk,看其內(nèi)部是否已解決,已解決的話升級 sdk 版本即可;
第1步行不通,那就自己寫校驗(yàn)邏輯,貓客全局通信基本已經(jīng)使用 https 通信,參考著再寫一遍校驗(yàn)邏輯也不是問題;
后來查了一下網(wǎng)上信息,早在2014年10月份,烏云
平臺里就已經(jīng)暴露過天貓這個(gè)漏洞,想必當(dāng)時(shí)一定是忙于雙十一忽略了這個(gè)問題。
雖然這個(gè)問題通過升級 sdk
解決了,但是這個(gè)問題純粹是由于開發(fā)者本身疏忽造成的;特別是對于初級開發(fā)人員來說,可能為了解決異常,屏蔽了校驗(yàn)邏輯;所以我還是抽空再 review
了一下這個(gè)漏洞,整理相關(guān)信息。
漏洞描述
對于數(shù)字證書相關(guān)概念、Android 里 https 通信代碼就不再復(fù)述了,直接講問題。缺少相應(yīng)的安全校驗(yàn)很容易導(dǎo)致中間人攻擊,而漏洞的形式主要有以下3種:
自定義X509TrustManager
在使用HttpsURLConnection發(fā)起 HTTPS 請求的時(shí)候,提供了一個(gè)自定義的X509TrustManager,未實(shí)現(xiàn)安全校驗(yàn)邏輯,下面片段就是當(dāng)時(shí)新浪微博 sdk 內(nèi)部的代碼片段。如果不提供自定義的X509TrustManager,代碼運(yùn)行起來可能會(huì)報(bào)異常(原因下文解釋),初學(xué)者就很容易在不明真相的情況下提供了一個(gè)自定義的X509TrustManager,卻忘記正確地實(shí)現(xiàn)相應(yīng)的方法。本文重點(diǎn)介紹這種場景的處理方式。
TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
//do nothing,接受任意客戶端證書
}
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
//do nothing,接受任意服務(wù)端證書
}
public X509Certificate[] getAcceptedIssuers() {
return null;
}
};
sslContext.init(null, new TrustManager[] { tm }, null);
自定義了HostnameVerifier
在握手期間,如果 URL 的主機(jī)名和服務(wù)器的標(biāo)識主機(jī)名不匹配,則驗(yàn)證機(jī)制可以回調(diào)此接口的實(shí)現(xiàn)程序來確定是否應(yīng)該允許此連接。如果回調(diào)內(nèi)實(shí)現(xiàn)不恰當(dāng),默認(rèn)接受所有域名,則有安全風(fēng)險(xiǎn)。代碼示例。
HostnameVerifier hnv = new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
// Always return true,接受任意域名服務(wù)器
return true;
}
};
HttpsURLConnection.setDefaultHostnameVerifier(hnv);
信任所有主機(jī)名
SSLSocketFactory sf = new MySSLSocketFactory(trustStore);
sf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
修復(fù)方案
分而治之,針對不同的漏洞點(diǎn)分別描述,這里就講的修復(fù)方案主要是針對非瀏覽器App,非瀏覽器 App 的服務(wù)端通信對象比較固定,一般都是自家服務(wù)器,可以做很多特定場景的定制化校驗(yàn)。如果是瀏覽器 App,校驗(yàn)策略就有更通用一些。
自定義X509TrustManager。前面說到,當(dāng)發(fā)起 HTTPS 請求時(shí),可能拋起一個(gè)異常,以下面這段代碼為例(來自官方文檔):
try {
URL url = new URL("https://certs.cac.washington.edu/CAtest/");
URLConnection urlConnection = url.openConnection();
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
private void copyInputStreamToOutputStream(InputStream in, PrintStream out) throws IOException {
byte[] buffer = new byte[1024];
int c = 0;
while ((c = in.read(buffer)) != -1) {
out.write(buffer, 0, c);
}
}
它會(huì)拋出一個(gè)SSLHandshakeException的異常。
javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:322)
at com.android.okhttp.Connection.upgradeToTls(Connection.java:201)
at com.android.okhttp.Connection.connect(Connection.java:155)
at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:276)
at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:211)
at com.android.okhttp.internal.http.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:382)
at com.android.okhttp.internal.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:332)
at com.android.okhttp.internal.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:199)
at com.android.okhttp.internal.http.DelegatingHttpsURLConnection.getInputStream(DelegatingHttpsURLConnection.java:210)
at com.android.okhttp.internal.http.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:25)
at me.longerian.abcandroid.datetimepicker.TestDateTimePickerActivity$1.run(TestDateTimePickerActivity.java:236)
Caused by: java.security.cert.CertificateException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
at com.android.org.conscrypt.TrustManagerImpl.checkTrusted(TrustManagerImpl.java:318)
at com.android.org.conscrypt.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:219)
at com.android.org.conscrypt.Platform.checkServerTrusted(Platform.java:114)
at com.android.org.conscrypt.OpenSSLSocketImpl.verifyCertificateChain(OpenSSLSocketImpl.java:550)
at com.android.org.conscrypt.NativeCrypto.SSL_do_handshake(Native Method)
at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:318)
... 10 more
Caused by: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
... 16 more
Android 手機(jī)有一套共享證書的機(jī)制,如果目標(biāo) URL 服務(wù)器下發(fā)的證書不在已信任的證書列表里,或者該證書是自簽名的,不是由權(quán)威機(jī)構(gòu)頒發(fā),那么會(huì)出異常。對于我們這種非瀏覽器 app 來說,如果提示用戶去下載安裝證書,可能會(huì)顯得比較詭異。幸好還可以通過自定義的驗(yàn)證機(jī)制讓證書通過驗(yàn)證。驗(yàn)證的思路有兩種:
方案1
不論是權(quán)威機(jī)構(gòu)頒發(fā)的證書還是自簽名的,打包一份到 app 內(nèi)部,比如存放在 asset 里。通過這份內(nèi)置的證書初始化一個(gè)KeyStore,然后用這個(gè)KeyStore去引導(dǎo)生成的TrustManager來提供驗(yàn)證,具體代碼如下:
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
// uwca.crt 打包在 asset 中,該證書可以從https://itconnect.uw.edu/security/securing-computer/install/safari-os-x/下載
InputStream caInput = new BufferedInputStream(getAssets().open("uwca.crt"));
Certificate ca;
try {
ca = cf.generateCertificate(caInput);
Log.i("Longer", "ca=" + ((X509Certificate) ca).getSubjectDN());
Log.i("Longer", "key=" + ((X509Certificate) ca).getPublicKey();
} finally {
caInput.close();
}
// Create a KeyStore containing our trusted CAs
String keyStoreType = KeyStore.getDefaultType();
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
keyStore.load(null, null);
keyStore.setCertificateEntry("ca", ca);
// Create a TrustManager that trusts the CAs in our KeyStore
String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
tmf.init(keyStore);
// Create an SSLContext that uses our TrustManager
SSLContext context = SSLContext.getInstance("TLSv1","AndroidOpenSSL");
context.init(null, tmf.getTrustManagers(), null);
URL url = new URL("https://certs.cac.washington.edu/CAtest/");
HttpsURLConnection urlConnection =
(HttpsURLConnection)url.openConnection();
urlConnection.setSSLSocketFactory(context.getSocketFactory());
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);
} catch (CertificateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (KeyStoreException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
} catch (NoSuchProviderException e) {
e.printStackTrace();
}
這樣就可以得到正確的輸出內(nèi)容:
<html>
<head>
<title>UW Services CA Test Page</title>
</head>
<body>
<h2>
UW Services CA test page
</h2>
<p>
<b>QUESTION</b>:
Did you arrive here without any security alerts or warnings?</p>
<ul>
<p>
<li>
<b>YES</b> - This test page uses a certificate issued by the
UW Services Certificate Authority. If you reached this page
without any alerts or warnings from your browser, you
have successfully installed the UW Services CA Certificate
into your browser.
<p>
<li>
<b>NO</b> - If your browser warned you about the validity of this
test page's security certificate, or the certificate
authority is unrecognized, you may not have successfully
installed the UW Services CA Certificate.
<p>
</ul>
<form action="https://www.washington.edu/computing/ca/" method=get>
<input type=submit value="Return to the Install Page">
</form>
</body>
</html>
如果你用上述同樣的代碼訪問 https://www.taobao.com/ 或者 https://www.baidu.com/ ,則會(huì)拋出那個(gè)SSLHandshakeException異常,也就是說對于特定證書生成的TrustManager,只能驗(yàn)證與特定服務(wù)器建立安全鏈接,這樣就提高了安全性。如之前提到的,對于非瀏覽器 app 來說,這是可以接受的。
方案2
同方案1,打包一份到證書到 app 內(nèi)部,但不通過KeyStore去引導(dǎo)生成的TrustManager,而是干脆直接自定義一個(gè)TrustManager,自己實(shí)現(xiàn)校驗(yàn)邏輯;校驗(yàn)邏輯主要包括:
?服務(wù)器證書是否過期
?證書簽名是否合法
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
// uwca.crt 打包在 asset 中,該證書可以從https://itconnect.uw.edu/security/securing-computer/install/safari-os-x/下載
InputStream caInput = new BufferedInputStream(getAssets().open("uwca.crt"));
final Certificate ca;
try {
ca = cf.generateCertificate(caInput);
Log.i("Longer", "ca=" + ((X509Certificate) ca).getSubjectDN());
Log.i("Longer", "key=" + ((X509Certificate) ca).getPublicKey());
} finally {
caInput.close();
}
// Create an SSLContext that uses our TrustManager
SSLContext context = SSLContext.getInstance("TLSv1","AndroidOpenSSL");
context.init(null, new TrustManager[]{
new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain,
String authType)
throws CertificateException {
}
@Override
public void checkServerTrusted(X509Certificate[] chain,
String authType)
throws CertificateException {
for (X509Certificate cert : chain) {
// Make sure that it hasn't expired.
cert.checkValidity();
// Verify the certificate's public key chain.
try {
cert.verify(((X509Certificate) ca).getPublicKey());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
} catch (NoSuchProviderException e) {
e.printStackTrace();
} catch (SignatureException e) {
e.printStackTrace();
}
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}, null);
URL url = new URL("https://certs.cac.washington.edu/CAtest/");
HttpsURLConnection urlConnection =
(HttpsURLConnection)url.openConnection();
urlConnection.setSSLSocketFactory(context.getSocketFactory());
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);
} catch (CertificateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
} catch (NoSuchProviderException e) {
e.printStackTrace();
}
同樣上述代碼只能訪問 certs.cac.washington.edu 相關(guān)域名地址,如果訪問 https://www.taobao.com/ 或者 https://www.baidu.com/ ,則會(huì)在cert.verify(((X509Certificate) ca).getPublicKey());處拋異常,導(dǎo)致連接失敗。
?自定義HostnameVerifier,簡單的話就是根據(jù)域名進(jìn)行字符串匹配校驗(yàn);業(yè)務(wù)復(fù)雜的話,還可以結(jié)合配置中心、白名單、黑名單、正則匹配等多級別動(dòng)態(tài)校驗(yàn);總體來說邏輯還是比較簡單的,反正只要正確地實(shí)現(xiàn)那個(gè)方法。
HostnameVerifier hnv = new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
//示例
if("yourhostname".equals(hostname)){
return true;
} else {
HostnameVerifier hv =
HttpsURLConnection.getDefaultHostnameVerifier();
return hv.verify(hostname, session);
}
}
};
?主機(jī)名驗(yàn)證策略改成嚴(yán)格模式
SSLSocketFactory sf = new MySSLSocketFactory(trustStore);
sf.setHostnameVerifier(SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);