Android客户端单向验证
客户端单向验证手段很多,可以参考JustTrustMe,SSLKiller 之类的Hook框架源码去探索,这里选取几个常用的API讲解。
这里我们依旧拿 https://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel=XXX 接口做测试,首先代码里嵌入证书(下面代码中的 TAOBAO_CERTIFICATE 变量)用来做校验:
public final static String TAOBAO_CERTIFICATE = "-----BEGIN CERTIFICATE-----\n" +
"MIIeCzCCHPOgAwIBAgIMUsU3P5Y0P8vdHcZnMA0GCSqGSIb3DQEBCwUAMGYxCzAJ\n" +
"BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMTwwOgYDVQQDEzNH\n" +
...此处省略百来行...
"GwQ/XhBRqDw9PZIlGokmQEjKrHTA1/F3a7ZI4penyDeIVP5Qcum0IBFbZl1vaxSf\n" +
"zSg424KuPxlXi6ivheAC\n" +
"-----END CERTIFICATE-----\n";
X509TrustManager
从下面代码中不难看出证书验证逻辑都在X509TrustManager的方法中:
//HttpsURLConnection请求https://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel=13999999999
public void getHtmlByHttpsUrlconnection(String path) throws Exception {
URL url = new URL(path);
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setDoInput(true);
conn.setConnectTimeout(3000);
conn.setSSLSocketFactory(getSSLContext().getSocketFactory()); //关键在这里
if (conn.getResponseCode() == 200) {
InputStream inStream = conn.getInputStream();
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = inStream.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
inStream.close();
String res = new String(outStream.toByteArray(), "GBK");
Log.d("GRAB", res);
}
}
private static SSLContext getSSLContext() {
X509TrustManager x509TrustManager = new X509TrustManager() {
/**
* 校验服务端证书
* @param chain 证书链(不包含根证书)
* @param authType 算法类型
* @throws CertificateException
*/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length <= 0) {
throw new CertificateException("没证书");
}
if (TextUtils.isEmpty(authType) || authType.toUpperCase().contains("RSA")) {
throw new CertificateException("算法类型不对劲");
}
//请求获取的证书
X509Certificate x509Certificate0 = chain[0]; //证书从子往父
PublicKey publicKey = x509Certificate0.getPublicKey();
String strPublicKey = Base64.encodeToString(publicKey.getEncoded(), Base64.DEFAULT);
Log.d("公钥是:", strPublicKey);
//用服务端证书生成对象
CertificateFactory x509Certificate = CertificateFactory.getInstance("X.509");
Certificate taobaoCertificate = x509Certificate.generateCertificate(new ByteArrayInputStream(TAOBAO_CERTIFICATE.getBytes()));
String strTaobaoPublicKey = Base64.encodeToString(taobaoCertificate.getPublicKey().getEncoded(), Base64.DEFAULT);
Log.d("服务器公钥是:", strTaobaoPublicKey);
if (!strPublicKey.equalsIgnoreCase(strTaobaoPublicKey)) {
throw new CertificateException("公钥不对劲");
}
x509Certificate0.checkValidity();//验证证书到期时间
//可以用来验证的东西很多
String subjectDN = x509Certificate0.getSubjectDN().toString();
if (subjectDN.equalsIgnoreCase("taobao.com")) {
throw new CertificateException("subjectDN不对劲");
}
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext sslContext = null;
try {
sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{x509TrustManager}, new SecureRandom());
} catch (Exception e) {
e.printStackTrace();
}
return sslContext;
}
如果我们直接通过Fiddler抓包,那肯定是得不到数据的,于是使用Frida Hook上面的校验方法:
function hookX509TrustManager() {
Java.perform(function () {
let MainActivity = Java.use("com.zyc.grabdata.MainActivity");
MainActivity.getSSLContext.implementation = function () {
//构造自己的X509TrustManager实现类
let X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
let MyX509TrustManager = Java.registerClass({
name: 'com.zyc.grabdata.MyX509TrustManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) {
console.log("Frida Hook checkClientTrusted()", "Success!!!");
},
checkServerTrusted: function (chain, authType) {
console.log("Frida Hook checkServerTrusted()", "Success!!!");
},
getAcceptedIssuers: function (chain, authType) {
console.log("Frida Hook getAcceptedIssuers()", "Success!!!");
return [];
}
}
});
//实例化一个SSLContext
let SSLContext = Java.use("javax.net.ssl.SSLContext");
let sslContext = SSLContext.getInstance("TLS");
let TrustManagers = [MyX509TrustManager.$new()];
let SecureRandom = Java.use("java.security.SecureRandom");
let secureRandom = SecureRandom.$new();
sslContext.init(null, TrustManagers, secureRandom);
return sslContext;
}
});
}
运行:
HostnameVerifier
注释掉checkServerTrusted()中的验证,在 HttpsURLConnection 加入下面代码,这里让verify()强制返回false,即无论如何都不让通过。
conn.setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) { // 这里也可以通过session验证证书
Log.d("GRAB", "hostname就不给你过");
return false;
}
});
此时发送HttpsURLConnection肯定是不通过的,同样使用Frida Hook上面的校验方法:
function hookHostnameVerifier() {
Java.perform(function () {
//getHostnameVerifierInterfaces();
var HostnameVerifier = Java.use("com.zyc.grabdata.MainActivity$5");
HostnameVerifier.verify.implementation = function(){
return true;
}
});
}
/**
* 遍历查找到HostnameVerifier实例是com.zyc.grabdata.MainActivity$5
*/
function getHostnameVerifierInterfaces(){
Java.enumerateLoadedClasses({
onMatch: function (name){
if (name.indexOf("com.zyc.grabdata") != -1 ) {
var clazz = Java.use(name)
var interfaces = clazz.class.getInterfaces()
if (interfaces.length > 0) {
console.log(name + ": ")
for (var i in interfaces) {
console.log("\t", interfaces[i].toString())
}
}
}
},
onComplete: function () {
console.log("end")
}
})
}
运行,可以正常请求了。
CertificatePinner
用例OkHttpClient初始化部分改为下面代码锁定证书:
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add("*.taobao.com", "sha256/IfXz1a0gWBA5oH+zasmRutUiyoZN3I8wLxHNQxk3NVo=")
.add("*.taobao.com", "sha256/IQBnNBEiFuhj+8x6X8XLgh01V9Ic5/V3IRQLNFFc7v4=")
.add("*.taobao.com", "sha256/K87oWBWM9UZfyddvDfoxL+8lpNyoUB2ptGtn0fv6G2Q=")
.build();
OkHttpClient okHttpClient = new OkHttpClient().newBuilder().certificatePinner(certificatePinner).build();
通过代理证书肯定是无法请求到数据和抓包了,使用下面代码Hook:
function hookCertificatePinner(){
Java.perform(function () {
let Builer = Java.use("okhttp3.CertificatePinner$Builder");
Builer.add.implementation = function () {
console.log("Frida Hook hookCertificatePinner()", "Success!!!");
return this;
}
});
}
Hook之后可以正常取得数据并抓到了:
服务端单向验证
上面介绍了客户端单向验证的通用方案,接下来介绍一个服务端单向验证的案例。打开App试图抓包,会发现提示证书相关错误:
这里其实是服务端对客户端的证书进行了校验,Charles没有其需要的证书自然是通过不了。那么只要将客户端证书导入Charles问题就解决了,从apk的assets目录可以找到p12文件,这个便是服务端要校验的证书。拖出来发现该文件没有加固,可以直接导入。
不过导入p12是需要密码的:
密码常在下面API中使用:
// 参看java.security.KeyStore
/**
* Loads this KeyStore from the given input stream.
*
* <p>A password may be given to unlock the keystore
* (e.g. the keystore resides on a hardware token device),
* or to check the integrity of the keystore data.
* If a password is not given for integrity checking,
* then integrity checking is not performed.
*
* <p>In order to create an empty keystore, or if the keystore cannot
* be initialized from a stream, pass {@code null}
* as the {@code stream} argument.
*
* <p> Note that if this keystore has already been loaded, it is
* reinitialized and loaded again from the given input stream.
*
* @param stream the input stream from which the keystore is loaded,
* or {@code null}
* @param password the password used to check the integrity of
* the keystore, the password used to unlock the keystore,
* or {@code null}
*
* @exception IOException if there is an I/O or format problem with the
* keystore data, if a password is required but not given,
* or if the given password was incorrect. If the error is due to a
* wrong password, the {@link Throwable#getCause cause} of the
* {@code IOException} should be an
* {@code UnrecoverableKeyException}
* @exception NoSuchAlgorithmException if the algorithm used to check
* the integrity of the keystore cannot be found
* @exception CertificateException if any of the certificates in the
* keystore could not be loaded
*/
public final void load(InputStream stream, char[] password)
throws IOException, NoSuchAlgorithmException, CertificateException
{
keyStoreSpi.engineLoad(stream, password);
initialized = true;
}
/**
* Loads this keystore using the given {@code LoadStoreParameter}.
*
* <p> Note that if this KeyStore has already been loaded, it is
* reinitialized and loaded again from the given parameter.
*
* @param param the {@code LoadStoreParameter}
* that specifies how to load the keystore,
* which may be {@code null}
*
* @exception IllegalArgumentException if the given
* {@code LoadStoreParameter}
* input is not recognized
* @exception IOException if there is an I/O or format problem with the
* keystore data. If the error is due to an incorrect
* {@code ProtectionParameter} (e.g. wrong password)
* the {@link Throwable#getCause cause} of the
* {@code IOException} should be an
* {@code UnrecoverableKeyException}
* @exception NoSuchAlgorithmException if the algorithm used to check
* the integrity of the keystore cannot be found
* @exception CertificateException if any of the certificates in the
* keystore could not be loaded
*
* @since 1.5
*/
public final void load(LoadStoreParameter param)
throws IOException, NoSuchAlgorithmException,
CertificateException {
keyStoreSpi.engineLoad(param);
initialized = true;
}
根据这个方法写出Hook代码:
function hookKeyStore() {
Java.perform(function () {
const String = Java.use("java.lang.String");
const KeyStore = Java.use("java.security.KeyStore");
KeyStore.load.overload("java.security.KeyStore$LoadStoreParameter").implementation = function (param) {
console.log("进入了param重载");
if(param){
console.log("param:", param);
}
this.load(param);
}
KeyStore.load.overload("java.io.InputStream", "[C").implementation = function (stream, password) {
console.log("进入了stream, password重载");
if(stream){
console.log("stream:", stream);
}
if(password){
console.log("password:", String.$new(password));
}
this.load(stream, password);
}
});
}
运行拿到密码:
填入Charles可以通过。
能正常抓包了: