freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

移动安全入门之常见抓包问题二
2023-03-16 18:28:41
所属地 广东省

证书邦定

概述

证书绑定即客户端在收到服务器的证书后,对该证书进行强校验,验证该证书是不是客户端承认的证书,如果不是,则直接断开连接。浏览器其实已经这样做了,但是如“前面”所说,选择权交给了用户,且浏览器由于其开放性允许让用户自导入自己的证书到受信任区域。但是在APP里面就不一样,APP是HTTPS的服务提供方自己开发的客户端,开发者可以先将自己服务器的证书打包内置到自己的APP中,或者将证书签名内置到APP中,当客户端在请求服务器建立连接期间收到服务器证书后,先使用内置的证书信息校验一下服务器证书是否合法,如果不合法,直接断开。

1678702354_640ef71234e470cacd186.png!small?1678702355337

认证方式:证书锁定

证书锁定(SSL/TLS Pinning)顾名思义,将服务器提供的SSL/TLS证书内置到移动端开发的APP客户端中,当客户端发起请求时,通过比对内置的证书和服务器端证书的内容,以确定这个连接的合法性。证书锁定需要把服务器的公钥证书(.crt 或者 .cer 等格式)提前下载并内置到App客户端中,创建TrustManager 时将公钥证书加进去。当请求发起时,通过比对证书内容来确定连接的合法性。

但由于证书存在过期时间,因此当服务器端证书更换时,需同时更换客户端证书。既然是要锁定证书,那么我们客户端上应该事先存在一个证书,我们才能锁定这个证书来验证我们真正的服务端,而不是代理工具伪造的服务端。如果是锁定证书,那通常情况下会将证书放置在app/asset目录下。

认证方式:公钥锁定

公钥锁定则需提取证书中的公钥内置到客户端中,通过比对公钥值来验证连接的合法性,由于证书更换依然可以保证公钥一致,所以公钥锁定不存在客户端频繁更换证书的问题。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。

实现方式:配置文件(android7.0及以上

通过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];
}

常规解决方法

  • App中会有相关函数对内置的证书或者公钥进行对比,我们可以让其一直返回通过,常见利用工具有

xpose的justTrustme模块,也可以使用现成的frida hook脚本或使用objection,其核心都是hook HTTP请求库中用于校验证书的API,将结果返回正常。

  • 反编译app修改其中代码逻辑后重新打包。
  • 若使用配置文件方式可以直接将文件中校验的部分<trust-anchors>或<pin-set>注释掉,再重新打包和签名即可

案例一

设置完代理后打开某app提示网络错误无法正常使用

1678702594_640ef80296aeb066c9cfd.png!small?1678702595133

日志查看关键词发现存在证书绑定校验

1678702616_640ef818efe97e4cc8351.png!small?1678702617800

通过日志中的关键词定位关键代码,当然也可以直接搜索checkServerTrusted

1678702638_640ef82e5f6902924f3c9.png!small?1678702639114

编写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的数据包

1678702665_640ef84977980311c6718.png!small?1678702666175

这里也可以直接使用objection启动并执行android sslpinning disable来绕过证书邦定

objection -g com.xxxx.xxxx explore -s "android sslpinning disable"

1678702737_640ef891a892d69deba69.png!small?1678702739613

案例二

在测试机中打开某APP发现存在root检测,查壳发现使用的是某加固企业版,这里可以用pixel2测试机,非root环境,利用提权漏洞提权后运行frida,不过分析代码后发现了检测是否为root环境的方法

1678786850_641041225eb04f385b3f9.png!small?1678786850773

跟踪发现在so层中实现,直接hook对应方法即可。

当然也可以通过重新编译安卓源码,去掉相关特征的方式去解决root检测问题。点击wifi添加代理地址,再次打开app提示网络风险,经过分析代码发现存在代理检测。我们使用postern工具对流量进行转发,但是发现只能抓到一点数据包

1678702944_640ef96098ff7e8464c86.png!small?1678702945106

查看日志的错误信息

1678703079_640ef9e71f3b6ef216f5a.png!small?1678703080050

其使用了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

成功抓到目标数据包

1678703247_640efa8fc6d460760840c.png!small?1678703248505

ClassLoader

当hook确定存在的网络库时产生Didn't find class的错误,此时需要拿到加载应用本身dex的classloader,通过这个classloader去hook被加固的类,不同的加固hook的地方不同,示例代码如下:



function main() {
	Java.perform(function(){
		var StubApp = Java.use("com.stub.StubApp");
		StubApp.attachBaseContext.implementation = function (context) {
    		var result = this.attachBaseContext(context); // 先执行原来的方法
    		var classLoader = context.getClassLoader(); // 获取classloader
    		Java.classFactory.loader = classLoader;
    		main2();//hook加固后的目标方法
    		return result;
};
    });
}

function main2() {
	Java.perform(function(){
		var MainActivity = Java.classFactory.use("xxx");
		MainActivity.xxxx.implementation = function () {
    		略
};		
    });
}

网络库被混淆

概述

Android项目直接打成apk包之后,其实是可以通过一定的反编译技术手段看到apk中的源码,因此一些厂商对APP的包名、类名、变量名等进行混淆处理,通过降低混淆后代码的阅读性,增加破译程序的难度。

1678962173_6412edfd63191d7de532c.png!small?1678962174143

网络库确认

当我们发现app抓包失败,常规的证书绑定脚本无效

1678962198_6412ee16c4629e289f7e9.png!small?1678962199270

检测app发现没有加壳,但是使用jadx打开后发现包名、类名、变量名等被混淆,此时可以先确认app所使用的网络库,使用全局抓包的工具,然后查看user-agent可以看到使用了okhttp

1678962215_6412ee27eacc4daa3dec8.png!small?1678962217060

定位混淆后的方法

未经混淆的okhttp check方法如下:

public void check(String str, List<Certificate> list) throws SSLPeerUnverifiedException {
        ...
      	...
        int size = list.size();
        for (int i = 0; i < size; i++) {
            X509Certificate x509Certificate = (X509Certificate) list.get(i);
            int size2 = findMatchingPins.size();
            ByteString byteString = null;
            ByteString byteString2 = null;
            for (int i2 = 0; i2 < size2; i2++) {
                Pin pin = findMatchingPins.get(i2);
                if (pin.hashAlgorithm.equals("sha256/")) {
                    ...
                  	...
                } else if (!pin.hashAlgorithm.equals("sha1/")) {
                    throw new AssertionError("unsupported hashAlgorithm: " + pin.hashAlgorithm);
                } else {
                    ...
                  	...
                }
            }
        }
      	StringBuilder sb = new StringBuilder();
        sb.append("Certificate pinning failure!");
        sb.append("\n  Peer certificate chain:");
        ...
      	...
    }

我们可以通过查看未经混淆对应方法中存在的一些关键词去搜索和定位对应的方法,如:

List<Certificate>
equals("sha256/")
unsupported hashAlgorithm
Certificate pinning failure
Peer certificate chain

也可以通过查看日志报错信息定位

1678962272_6412ee603ec3e1bcedd55.png!small?1678962272721

案例

jadx打开app,看到方法名被混淆

1678962358_6412eeb61f0d8afaae03d.png!small?1678962359291

直接搜索List<Certificate>找到okhttp混淆后的check方法

1678962373_6412eec543bf7a4854d4e.png!small?1678962374109

直接hook混淆后的check方法即可

Java.perform(function(){
		let C0683k = Java.use("a.b.c");
		C0683k["d"].overload('java.lang.String', 'java.util.List').implementation = function (str, list) {
    		//let ret = this.a(str, list);
    		//console.log('a ret value is ' + ret);
    		//return;
};});


双向证书认证

概述

双向认证要求服务器和用户双方都有证书,客户端会去验证服务端的证书,然后服务端也会去验证客户端的证书,双方拿到了之后会通过对通信的加密方式进行加密这种方法来进行互相认证,最后客户端再随机生成随机码进行对称加密的验证。

1678703393_640efb21c9822c219ab0a.png!small?1678703394624

实现方式

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

案例一(某app上古版本,仅作分析)

使用jadx反编译安卓apk文件,由于存在混淆搜索关键词没有获取什么有价值的信息,更换工具为GDA或Jeb(jeb的反混淆优化更好一些),这里使用GDA,搜索关键词client.p12

1678703422_640efb3e5b40e2b81154c.png!small?1678703423109

上图第二个红框中的load函数的第一个参数是证书的字节输入流,第二个参数就是证书的密码,由第一个红框我们可以知道str的定义,跟进SoulNetworkSDK.a方法

1678703439_640efb4f7113f1830fe86.png!small?1678703439870

跟进发现a的返回值来源于native层的函数getStorePassword

1678703453_640efb5de1b12d63e38d7.png!small?1678703454414

查看具体加载的是哪个so文件

1678703471_640efb6fa9d659f28726e.png!small?1678703473119

用IDA反编译soul-netsdk,定位函数getStorePassword

1678703490_640efb825029c25823ee9.png!small?1678703491017

ios同理,ios可使用frida-ios-dump或fd_mac进行砸壳,获取证书"client.p12",完成后我们解压缩然后使用IDA加载二进制文件并在String窗口搜索证书的名称client,搜索后进入对应的类

1678703510_640efb96ed69b4afbd13c.png!small?1678703511866

通过跟踪发现了该证书密钥,如下:

1678703529_640efba97df5eb6dbff2d.png!small?1678703530248

案例二

app抓到包返回400

1678704265_640efe89d3a7cb163b61f.png!small?1678704266533

疑似使用了双向证书认证,对app进行脱壳查看代码,直接搜索.p12发现几处关键点

1678703636_640efc14d064d82c7588a.png!small?1678703637344

最终定位到密钥来源getvalue方法

1678703701_640efc55c1101faae48d1.png!small?1678703702588

查看该方法发现该方法来源于so层中

1678703753_640efc8916774fed22a95.png!small?1678703753706

直接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框架

Flutter使用Dart编写,因此它不会使用系统CA存储,Dart使用编译到应用程序中的CA列表,Dart在Android上不支持代理,因此请使用带有iptables的ProxyDroid

判断flutter应用

可以通过设备信息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,搜索对应字符

1678703932_640efd3cd9db5af277641.png!small?1678703933482

双击跳到字符串定义

1678703948_640efd4ccb21771103a37.png!small?1678703950165

ctrl+x查看交叉引用

1678703963_640efd5bd8e666813a23a.png!small?1678703964551

3ecc00函数就是我们要找的CERTIFICATE_VERIFY_FAILED

1678703982_640efd6ec0fd0919fc81f.png!small?1678703983720

编写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 

# 渗透测试 # web安全
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录