证书绑定即客户端在收到服务器的证书后,对该证书进行强校验,验证该证书是不是客户端承认的证书,如果不是,则直接断开连接。浏览器其实已经这样做了,但是如“前面”所说,选择权交给了用户,且浏览器由于其开放性允许让用户自导入自己的证书到受信任区域。但是在APP里面就不一样,APP是HTTPS的服务提供方自己开发的客户端,开发者可以先将自己服务器的证书打包内置到自己的APP中,或者将证书签名内置到APP中,当客户端在请求服务器建立连接期间收到服务器证书后,先使用内置的证书信息校验一下服务器证书是否合法,如果不合法,直接断开。
证书锁定(SSL/TLS Pinning)顾名思义,将服务器提供的SSL/TLS证书内置到移动端开发的APP客户端中,当客户端发起请求时,通过比对内置的证书和服务器端证书的内容,以确定这个连接的合法性。证书锁定需要把服务器的公钥证书(.crt 或者 .cer 等格式)提前下载并内置到App客户端中,创建TrustManager 时将公钥证书加进去。当请求发起时,通过比对证书内容来确定连接的合法性。
但由于证书存在过期时间,因此当服务器端证书更换时,需同时更换客户端证书。既然是要锁定证书,那么我们客户端上应该事先存在一个证书,我们才能锁定这个证书来验证我们真正的服务端,而不是代理工具伪造的服务端。如果是锁定证书,那通常情况下会将证书放置在app/asset目录下。
公钥锁定则需提取证书中的公钥内置到客户端中,通过比对公钥值来验证连接的合法性,由于证书更换依然可以保证公钥一致,所以公钥锁定不存在客户端频繁更换证书的问题。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。
通过res/xml/network_security_config.xml配置文件对证书进行校验。对apk反编译后查看res/xml目录下的network_security_config.xml文件,打开看到<domain-config>标签,说明使用了证书绑定机制。生效范围:app全局,包含webview请求
证书锁定
<network-security-config> <domain-config> <domain includeSubdomains="true">example.com</domain> <trust-anchors> <certificates src="@raw/my_ca"/> </trust-anchors> </domain-config> </network-security-config>
公钥锁定
<network-security-config> <domain-config> <domain includeSubdomains="true">example.com</domain> <pin-set expiration="2099-01-01"> <pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin> </pin-set> </domain-config> </network-security-config>
生效范围:配置了该参数的实例
证书锁定
// 获取证书输入流 InputStream openRawResource = getApplicationContext().getResources().openRawResource(R.raw.bing); Certificate ca = CertificateFactory.getInstance("X.509").generateCertificate(openRawResource); // 创建 Keystore 包含我们的证书 KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null, null); keyStore.setCertificateEntry("ca", ca); // 创建一个 TrustManager 仅把 Keystore 中的证书 作为信任的锚点 TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); // 建议不要使用自己实现的X509TrustManager,而是使用默认的X509TrustManager trustManagerFactory.init(keyStore); // 用 TrustManager 初始化一个 SSLContext sslContext = SSLContext.getInstance("TLS"); //定义:public static SSLContext sslContext = null; sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom()); OkHttpClient pClient2 = client.newBuilder().sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustManagerFactory.getTrustManagers()[0]).build(); Request request2 = new Request.Builder() .url("https://www.bing.com/?q=SSLPinningCAfile") .build(); try (Response response2 = pClient2.newCall(request2).execute()) { message.obj += "\nhttps SSL_PINNING_with_CA_file access bing.com success"; Log.d(TAG, "https SSL_PINNING_with_CA_file access bing.com success return code:"+response2.code()); } catch (IOException e) { message.obj += "\nhttps SSL_PINNING_with_CA_file access bing.com failed"; Log.d(TAG, "https SSL_PINNING_with_CA_file access bing.com failed"); e.printStackTrace(); }
公钥锁定
client = OkHttpClient.Builder() .certificatePinner(new CertificatePinner.Builder() .add("xxxxxx.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .build()) .build();
javax.net.ssl.X509TrustManager接口被用来校验证书是否被信任。通常会校验 CA 是否为系统内置权威机构,证书有效期等。这个接口有三个方法,分别用来校验客户端证书、校验服务端证书、获取可信证书数组。其中我们重点关注checkServerTrusted方法
// 该方法检查客户端的证书,若不信任该证书则抛出异常 @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { ... ... } // 该方法检查服务器的证书,若不信任该证书同样抛出异常 @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { ... ... } // 返回受信任的X509证书数组 @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
xpose的justTrustme模块,也可以使用现成的frida hook脚本或使用objection,其核心都是hook HTTP请求库中用于校验证书的API,将结果返回正常。
设置完代理后打开某app提示网络错误无法正常使用
日志查看关键词发现存在证书绑定校验
通过日志中的关键词定位关键代码,当然也可以直接搜索checkServerTrusted
编写hook脚本
Java.perform(function(){ let SecureX509TrustManager = Java.use("com.xxx.xxxx.xxxx.common.ssl.SecureX509TrustManager"); SecureX509TrustManager["checkServerTrusted"].implementation = function (x509CertificateArr, str) { console.log('checkServerTrusted is called' + ', ' + 'x509CertificateArr: ' + x509CertificateArr + ', ' + 'str: ' + str); return;
spawn模式启动即可抓到app的数据包
这里也可以直接使用objection启动并执行android sslpinning disable来绕过证书邦定
objection -g com.xxxx.xxxx explore -s "android sslpinning disable"
在测试机中打开某APP发现存在root检测,查壳发现使用的是某加固企业版,这里可以用pixel2测试机,非root环境,利用提权漏洞提权后运行frida,不过分析代码后发现了检测是否为root环境的方法
跟踪发现在so层中实现,直接hook对应方法即可。
当然也可以通过重新编译安卓源码,去掉相关特征的方式去解决root检测问题。点击wifi添加代理地址,再次打开app提示网络风险,经过分析代码发现存在代理检测。我们使用postern工具对流量进行转发,但是发现只能抓到一点数据包
查看日志的错误信息
其使用了com.android.org.conscrypt.TrustManagerImpl.verifyChain方法去校验证书,编写hook脚本
var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl'); TrustManagerImpl.verifyChain.implementation = function(untrustedChain, trustAnchorChain, host, clientAuth, ocspData, tlsSctData) { console.log("[+] bypass success!"); return untrustedChain; };
将上一篇文章中绕过代理检测的代码组合后加载脚本
frida -U -f com.xxxx.xxx -l anti-ssl.js --no-pause
成功抓到目标数据包
双向认证要求服务器和用户双方都有证书,客户端会去验证服务端的证书,然后服务端也会去验证客户端的证书,双方拿到了之后会通过对通信的加密方式进行加密这种方法来进行互相认证,最后客户端再随机生成随机码进行对称加密的验证。
public class SSLHelper { /** * 存储客户端自己的密钥 */ private final static String CLIENT_PRI_KEY = "client.bks"; /** * 存储服务器的公钥 */ private final static String TRUSTSTORE_PUB_KEY = "publickey.bks"; /** * 读取密码 */ private final static String CLIENT_BKS_PASSWORD = "123321"; /** * 读取密码 */ private final static String PUCBLICKEY_BKS_PASSWORD = "123321"; private final static String KEYSTORE_TYPE = "BKS"; private final static String PROTOCOL_TYPE = "TLS"; private final static String CERTIFICATE_STANDARD = "X509"; public static SSLSocketFactory getSSLCertifcation(Context context) { SSLSocketFactory sslSocketFactory = null; try { // 服务器端需要验证的客户端证书,其实就是客户端的keystore KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); // 客户端信任的服务器端证书 KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE); //读取证书 InputStream ksIn = context.getAssets().open(CLIENT_PRI_KEY); InputStream tsIn = context.getAssets().open(TRUSTSTORE_PUB_KEY); //加载证书 keyStore.load(ksIn, CLIENT_BKS_PASSWORD.toCharArray()); trustStore.load(tsIn, PUCBLICKEY_BKS_PASSWORD.toCharArray()); //关闭流 ksIn.close(); tsIn.close(); //初始化SSLContext SSLContext sslContext = SSLContext.getInstance(PROTOCOL_TYPE); TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(CERTIFICATE_STANDARD); KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(CERTIFICATE_STANDARD); trustManagerFactory.init(trustStore); keyManagerFactory.init(keyStore, CLIENT_BKS_PASSWORD.toCharArray()); sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); sslSocketFactory = sslContext.getSocketFactory(); } catch (KeyStoreException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (CertificateException e) { e.printStackTrace(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (UnrecoverableKeyException e) { e.printStackTrace(); } catch (KeyManagementException e) { e.printStackTrace(); } return sslSocketFactory; } }
如果是正常的双向验证的话,只需要导入存储在APP端的CA证书即可,但是基于ssl pinning的双向验证的话,需要导入存储在APP端的CA证书,而且还需要绕过客户端的强校验
获取证书:
一般存放在App的raw或者assets目录下,常见证书后缀如下:
.p12 .bks .pfx
也可能无后缀名,如果在安装包内找不到证书的话,也可以使用objection hook java.io.File定位
objection -g cn.soulapp.android explore --startup-command "android hooking watch class_method java.io.File.$init --dump-args
获取密码:
逆向apk,可以通过找到的证书名去搜索如 "client.p12" ,".p12" 等,或者定位java.security.KeyStore.open()方法,找到使用证书的地方。定位关键点后通过代码去查看是否存在实体编码的证书密码,若不存在明文密码则可以通过去hook相关方法获取密码
常见关键词
.p12" getAssets().open
使用jadx反编译安卓apk文件,由于存在混淆搜索关键词没有获取什么有价值的信息,更换工具为GDA或Jeb(jeb的反混淆优化更好一些),这里使用GDA,搜索关键词client.p12
上图第二个红框中的load函数的第一个参数是证书的字节输入流,第二个参数就是证书的密码,由第一个红框我们可以知道str的定义,跟进SoulNetworkSDK.a方法
跟进发现a的返回值来源于native层的函数getStorePassword
查看具体加载的是哪个so文件
用IDA反编译soul-netsdk,定位函数getStorePassword
ios同理,ios可使用frida-ios-dump或fd_mac进行砸壳,获取证书"client.p12",完成后我们解压缩然后使用IDA加载二进制文件并在String窗口搜索证书的名称client,搜索后进入对应的类
通过跟踪发现了该证书密钥,如下:
app抓到包返回400
疑似使用了双向证书认证,对app进行脱壳查看代码,直接搜索.p12发现几处关键点
最终定位到密钥来源getvalue方法
查看该方法发现该方法来源于so层中
直接hook该方法返回值
Java.perform(function () { console.log("start..... "); var hook = Java.use('com.xxx.xxx.xxx'); console.log(hook); hook.getValue.implementation=function(a){ console.log(a); console.log(this.getValue(a)); return this.getValue(a); } });
成功获取证书密码
Flutter使用Dart编写,因此它不会使用系统CA存储,Dart使用编译到应用程序中的CA列表,Dart在Android上不支持代理,因此请使用带有iptables的ProxyDroid
可以通过设备信息app查看
也可以通过⽇志grep flutter,如果有输出,⾃然也可以说明是flutter的
logcat |grep flutter
static bool ssl_crypto_x509_session_verify_cert_chain(SSL_SESSION *session, SSL_HANDSHAKE *hs, uint8_t *out_alert) { *out_alert = SSL_AD_INTERNAL_ERROR; STACK_OF(X509) *const cert_chain = session->x509_chain; if (cert_chain == nullptr || sk_X509_num(cert_chain) == 0) { return false; } SSL *const ssl = hs->ssl; SSL_CTX *ssl_ctx = ssl->ctx.get(); X509_STORE *verify_store = ssl_ctx->cert_store; if (hs->config->cert->verify_store != nullptr) { verify_store = hs->config->cert->verify_store; } X509 *leaf = sk_X509_value(cert_chain, 0); const char *name; size_t name_len; SSL_get0_ech_name_override(ssl, &name, &name_len); UniquePtr<X509_STORE_CTX> ctx(X509_STORE_CTX_new()); if (!ctx || !X509_STORE_CTX_init(ctx.get(), verify_store, leaf, cert_chain) || !X509_STORE_CTX_set_ex_data(ctx.get(), SSL_get_ex_data_X509_STORE_CTX_idx(), ssl) || // We need to inherit the verify parameters. These can be determined by // the context: if its a server it will verify SSL client certificates or // vice versa. !X509_STORE_CTX_set_default(ctx.get(), ssl->server ? "ssl_client" : "ssl_server") || // Anything non-default in "param" should overwrite anything in the ctx. !X509_VERIFY_PARAM_set1(X509_STORE_CTX_get0_param(ctx.get()), hs->config->param) || // ClientHelloOuter connections use a different name. (name_len != 0 && !X509_VERIFY_PARAM_set1_host(X509_STORE_CTX_get0_param(ctx.get()), name, name_len))) { OPENSSL_PUT_ERROR(SSL, ERR_R_X509_LIB); return false; } if (hs->config->verify_callback) { X509_STORE_CTX_set_verify_cb(ctx.get(), hs->config->verify_callback); } int verify_ret; if (ssl_ctx->app_verify_callback != nullptr) { verify_ret = ssl_ctx->app_verify_callback(ctx.get(), ssl_ctx->app_verify_arg); } else { verify_ret = X509_verify_cert(ctx.get()); } session->verify_result = X509_STORE_CTX_get_error(ctx.get()); // If |SSL_VERIFY_NONE|, the error is non-fatal, but we keep the result. if (verify_ret <= 0 && hs->config->verify_mode != SSL_VERIFY_NONE) { *out_alert = SSL_alert_from_verify_result(session->verify_result); return false; } ERR_clear_error(); return true; }
我们如果测试flutter应用时,抓包看到错误日志:CERTIFICATE_VERIFY_FAILED,那么说明采用了证书校验,可以通过hook修改ssl_crypto_x509_session_verify_cert_chain函数返回值的方式解决抓包问题。
确认目标app报错日志为CERTIFICATE_VERIFY_FAILED,由于证书校验链逻辑在libflutter.so中实现,可以通过搜索 ssl_client和ssl_server字符来定位函数,用IDA打开libflutter.so,搜索对应字符
双击跳到字符串定义
ctrl+x查看交叉引用
3ecc00函数就是我们要找的CERTIFICATE_VERIFY_FAILED
编写hook脚本
function hook_ssl() { var base = Module.findBaseAddress("libflutter.so"); console.log("base: " + base); var ssl_crypto_x509_session_verify_cert_chain = base.add(0x5c6b7c); Interceptor.attach(ssl_crypto_x509_session_verify_cert_chain, { onEnter: function(args) { }, onLeave: function(retval) { console.log("校验函数返回值: " + retval); retval.replace(0x1); } }); }
加载 hook 脚本绕过
frida -U xxxxx -l hook.js