乐于分享
好东西不私藏

记一次某银行APP加密通信破解与抓包实战

记一次某银行APP加密通信破解与抓包实战

项目APP的初次“邂逅”与分析

APP运行问题分析

从客户那里拿到APP,进入APP就弹出提示框,第一感觉:地道~

试着点“知道了”,APP就直接闪退了。行吧,不让玩是吧,拖进Jadx里看看怎么个事:

搜索、查看了好一会儿,没找到这个提示文本是在哪里调用的,也没找到什么明显线索,一度大脑宕机……0_0

通过行为和堆栈定位代码

从行为分析,并打印堆栈信息

CPU开始高速运转——

点击“知道了”,APP就要退出;

点击“知道了”,APP就要退出;

既然点击按钮就退出,那最“省力”的办法就是:把最后退出时的堆栈调用信息打印出来!

Java.perform(function () {  var Activity = Java.use("android.app.Activity");  var Log = Java.use("android.util.Log");  var Exception = Java.use("java.lang.Exception");  Activity.finish.overload().implementation = function () {  console.log("调用 finish()");  console.log(Log.getStackTraceString(Exception.$new()));  return this.finish();  };  });

根据打印出来的堆栈调用信息,定位到对应的方法,然后重点分析这个方法以及调用链。

到这里,思路已经打开了,剩下的就是按流程一点点验证:

看看是谁调用了这个方法,再往上追调用关系。

安全检测分析与绕过

1.退出触发条件分析

通过代码可以看到,当判断条件成立时,就会调用这个退出方法。那么问题就变成了:这个判断条件为什么会成立?那就继续进入这个判断逻辑里面看看具体做了什么检测。

2.安全检测分析

进去一看就很明显了:里面做了一系列系统环境和APP环境的安全检测,比如ROOT检测、调试检测、模拟器检测等等。总结就是检测到不安全环境,就返回true,然后触发退出。

3.安全检测绕过

那思路就非常清晰了:

既然他检测不安全环境返回true就退出,那咱们就让这个方法永远返回false就行了。这里直接重写这个方法的返回值,让他始终返回false。

Java.perform(function () {     var cls = Java.use("com.xxx.RootCheck");     cls.isRooted.implementation = function () {         return false;     }; });

Nice!成了,成功绕过安全检测。

抓包失败原因分析与排查

1.抓包现象描述

接下来配置代理开始抓包,结果发现通信过程出现异常,数据无法正常通过代理。遇到这种情况基本可以直接往证书校验方向去想。

2.定位分析证书校验代码

继续回到Jadx里面找线索,慢慢排查。

感觉有点思路了,但又不是特别多。

看到有几个异常处理的地方,其中有几个异常比较常规,于是重点盯住第一个异常,先看看程序是不是执行到了这里。

Java.perform(function () {     var cls = Java.use("com.xxx.http.RequestCallback");     cls.onResponse.overloads.forEach(function (m) {         console.log(m);     }); });

经过调试,程序确实执行到这里并抛出了异常。

Java.perform(function () {     var cls = Java.use("com.xxx.http.RequestCallback");     cls.onResponse.overload(         'retrofit2.Call',         'retrofit2.Response'     ).implementation = function (call, response) {         console.log("==== onResponse 命中 ====");         try {             console.log("HTTP code:", response.code());         } catch (e) {}         try {             var body = response.body();             console.log("body:", body);         } catch (e) {}         return this.onResponse(call, response);     }; });

但光知道抛异常还不够,咱们还需要搞清楚:是哪个类的哪个方法触发了这个异常,以及为什么会触发。

于是开始疯狂“调教AI”打印异常堆栈,专门捕捉这个异常的调用栈信息。

Java.perform(function () {     var Exception = Java.use("java.lang.Exception");     Exception.$init.overload().implementation = function () {         var e = this.$init();         console.log("异常创建:");         console.log(Java.use("android.util.Log")             .getStackTraceString(this));         return e;     }; });

继续走登录流程,查看异常时的堆栈调用信息。

根据堆栈信息,成功定位到了证书校验相关的类和方法。

3.证书校验绕过

接下来就简单了:

把这个证书校验方法的返回值强制改为true,同时顺手把SSL Pinning一起绕过(这个网上资料很多,这里就不展开了)。

Java.perform(function () {     var hooked = {};     Java.enumerateLoadedClasses({       onMatchfunction (name) {         if (           name.startsWith("com.xxx.http.-$$Lambda$CerVerify$") &&           !hooked[name]         ) {           hooked[name] = true;           console.log("[+] Hit:", name);           try {             var cls = Java.use(name);             if (cls.verify) {               cls.verify.overloads.forEach(function (overload) {                 overload.implementation = function (hostname, session) {                   console.log("[+] Bypass verify:", hostname);                   return true;                 };               });             }           } catch (e) {             console.log("[-] Hook failed:", name);           }         }       },       onCompletefunction () {         console.log("[*] Done");       }     }); });

修改完成后再次点击获取验证码,提示发送成功 ^_^

此时查看Burp中的报文,请求已经能够正常发出,通信恢复正常,整体流程没有问题。

通信数据加密分析

1.加密报文分析和定位代码

输入验证码后点击登录发现:

我嘞个豆!Burp请求和响应报文全是加密的……

(密密麻麻一大片,看得人密集恐惧症都要犯了)

(仔细看加密报文的开头特征,如果对常见加密算法比较熟,一开始很容易误以为这是非对称加密证书相关的数据结构——当时我也是这么判断的,结果后面发现只能说是有点沾边,但方向并不完全对。)

接下来,继续在Jadx里找线索,重点从请求报文中的参数入手搜索相关字符串;如果响应报文没有明显特征参数,那思路就是去找response、body、decrypt、encrypt、cipher等关键字;再结合方法命名、参数类型、调用位置,基本就是连蒙带猜 + 逻辑分析慢慢定位加解密位置。

最后还真找到了加密和解密相关的代码。

妙啊,妙极了!梳理了加密与解密流程,整体处理逻辑已经很明朗,至于更底层的算法实现和“古法”逆向细节,这里就不再展开——楼主还是比较惜命的,怕一不小心被客户送进局子(主要是技术还没到位0.0)。

2.整体加解密流程分析与明文获取思路

由于这里很可能采用的是非对称加密与对称加密结合的方式:客户端使用公钥对对称加密密钥进行加密或签名,而该对称密钥只能通过服务器端的私钥进行解密;同时,对称密钥通常是随机生成的,会话之间并不固定,咱们也无法直接获取。另外,整个报文的数据结构和加解密算法并没有在Java层实现,而是在Native层完成。在这种情况下,咱们既拿不到对称加密密钥,也无法自行构造加密数据进行重放或伪造请求。

这就使得APP安全测试中遇到了一个非常现实的问题——Burp抓到的全部都是密文,请求和响应内容完全不可读,抓包几乎失去了分析意义。

既然拿不到私钥,常规的解密路线基本走不通,那就换个思路:不要想着在Burp里解密密文,而是想办法让APP在加密之前、解密之后,把明文数据“顺便”给咱们一份。这样即使咱们没有密钥,也一样可以看到完整的通信内容。

抓包“阅读”方法

思路整体可以拆成两条链路:

请求流程改造:

原本流程、现在流程:

响应流程改造:

原本流程、现在流程:

所以关键点其实就两个:

· 请求方向:在客户端调用加密函数之前进行Hook,获取加密前的明文请求数据,然后放行加密函数,由客户端完成加密并正常发送。

· 响应方向:

在客户端调用解密函数之后进行Hook,获取解密后的明文响应数据,然后交由客户端继续后续业务处理。

不破解加密算法,不获取密钥,只拿明文。

Frida RPC + MITMproxy联动

那么问题来了:谁来负责重新“加密”和“解密”?

这时候就可以祭出德玛西亚大神器之组合技: Frida RPC + MITMproxy = 梦幻联动打工组合

1.请求、响应流程改造

最终数据流就变成下面这样:

请求方向、响应方向

这样Burp里永远看到的都是明文,请求响应都能改,APP也不会崩,服务器也完全不知道中间发生了什么。这个方案的核心思路可以总结为一句话:拿不到密钥没关系,让APP帮咱们完成对应加解密就行,不需要深度逆向出具体的加解密算法,咱们只负责“遥控”他干活。

2.示例代码:

由于篇幅有限,下面只贴出部分核心功能代码,主要用于说明实现思路,代码不完整之处还请见谅,各位大佬轻喷QAQ-

MITMproxy:

class MyAddon1:     def __init__(self):         self.xxx = None     # ----------------------------     # Request     # ----------------------------     def request(self, flow: http.HTTPFlow):         if not _is_target(flow):             return         body = _get_text_safe(flow.request)         if not body:             return         if not _is_json(body):             return         print(f"{MITM_FLAG_REQUEST_URL}")         print(flow.request.pretty_url)         encrypted = CryptoBridge.encrypt(body)         if encrypted:             flow.request.set_text(encrypted)     # ----------------------------     # Response     # ----------------------------     def response(self, flow: http.HTTPFlow):         if not _is_target(flow):             return         body = _get_text_safe(flow.response)         if not body:             return         print(f"{MITM_FLAG_RESPONSE_URL}")         print(flow.request.pretty_url)         decrypted = CryptoBridge.decrypt(body)         if decrypted:             flow.response.set_text(decrypted) async def run_upstream():     mode = f"upstream:{UPSTREAM_PROXY}"     options_options = options.Options(listen_host=BIND_TO_ADDRESS, listen_port=BIND_TO_PORT, mode=mode,                                       ssl_insecure=True)     m = DumpMaster(options_options, with_dumper=False, with_termlog=False)     m.addons.add(MyAddon1())     await m.run() if __name__ == '__main__':     asyncio.run(run_upstream())

Frida:

class CryptoBridge:  def __init__(self, package_name=PACKAGE_NAME):  print("[*] Connecting to device...")  self.device = frida.get_usb_device(timeout=10)  print("[*] Spawning app...")  self.pid = self.device.spawn([package_name])  print("[*] Attaching...")  self.session = self.device.attach(self.pid)  print("[*] Injecting script...")  self.script = self.session.create_script(JS_CODE)  self.script.load()  print("[*] Resuming app...")   self.device.resume(self.pid)  self.api = self.script.exports_sync  print("[*] CryptoBridge Ready")  # =========================  # Rpc对外接口  # =========================  def encrypt(self, plaintext: str) -> str:  return self.api.encrypt(plaintext)  def decrypt(self, ciphertext: str) -> str:  return self.api.decrypt(ciphertext)

Frida RPC:

f""" Java.perform(function () {{   var manualEncryptFlag = false;   var manualDecryptFlag = false;   var globalCrypto = null;   var globalInterceptor = null;   var globalCert = null;   // ==========================   // Encrypt Function   // ==========================   var encryptOverload = CommInterceptor.encryptMessage.overload(     '{CLASS_FASTJSON_OBJECT}'   );   encryptOverload.implementation = function (data) {{     try {{       globalInterceptor = this;       globalCrypto = this.getCrypto();     }} catch (e) {{       console.log("getCrypto error:", e);     }}     if (manualEncryptFlag) {{       console.log("{RPC_FLAG_ENCRYPT}");       var result = encryptOverload.call(this, data);       console.log("{HTTP_FLAG_REQUEST_CIPHER_TEXT}");       console.log(result);       return result;     }}     console.log("{HTTP_FLAG_REQUEST_PLAIN_TEXT}");     console.log(data.toString());     var jsonObj = FASTJSONObject.$new(data);     return jsonObj;   }};   // ==========================   // Decrypt Function   // ==========================   var decryptOverload = CommInterceptor.decryptMessage.overload(     '{CLASS_JAVA_STRING}'   );   decryptOverload.implementation = function (data) {{     if (manualDecryptFlag) {{       console.log("{HTTP_FLAG_RESPONSE_CIPHER_TEXT}");       console.log(data);       console.log("{RPC_FLAG_DECRYPT}");       //var result = decryptOverload.call(this, data);       var resultToString = globalCrypto.decryptMessage(data, globalCert);       var result = StringClass.$new(resultToString, Charsets.UTF_8.value);       return result.toString();     }}     console.log("{HTTP_FLAG_RESPONSE_PLAIN_TEXT}");     console.log(data.toString());     return data.toString();   }};   // ==========================   // Frida RPC加解密对外接口   // ==========================   rpc.exports = {{     encrypt: function(data) {{       try {{           var fastJsonObj = FASTJSONObject.parseObject(data);           var instance = CommInterceptor.$new();           manualEncryptFlag = true;           var result = encryptOverload.call(instance, fastJsonObj);           return result ? result.toString() : "";       }} catch (e) {{           console.log("Encrypt Error:", e);           console.log(e.stack);           return "{RPC_FLAG_ENCRYPT_ERROR}:" + e;       }} finally {{           manualEncryptFlag = false;       }}     }},     decrypt: function(data) {{       try {{           var instance = CommInterceptor.$new();           manualDecryptFlag = true;           var result = decryptOverload.call(instance, data);           return result ? result.toString() : "";       }} catch (e) {{           console.log("Decrypt Error:", e);           console.log(e.stack);           return "{RPC_FLAG_DECRYPT_ERROR}:" + e;       }} finally {{           manualDecryptFlag = false;       }}     }}   }}; }}); """

代理链路搭建

手机代理 → Burp

接着,将Burp的监听主机地址和端口,设置为手机中配置的代理服务器地址和端口,使手机流量能够通过代理转发到Burp:

Burp上游代理 → MITMproxy

还需要将Burp的上游代理设置为MITMproxy所监听的主机和端口:

MITMproxy上游代理 → Yakit

为了能够同时查看和对比原始报文与处理后的报文,这里又为MITMproxy配置了上游代理Yakit:

最后,在Yakit中设置监听的主机地址和端口:

抓包与明文还原效果展示

Burp中明文请求与响应

至此,整个代理链路与加解密流程全部打通,大功告成!此时在Burp中看到的请求和响应报文已经不再是加密后的密文,而是可以直接阅读、修改和重放等操作的明文数据。如下图:

MITMproxy中的加解密数据

再来看MITMproxy中的报文情况。与Burp中显示的明文不同,MITMproxy中可以同时看到加密前与加密后的数据,用于加解密处理和数据转发。如下图:

Yakit中的数据记录

最后再来看Yakit中的报文情况。由于咱们将Yakit设置成了MITMproxy的上游代理,因此所有流量在完成加密处理之后、解密处理之前的请求与响应数据,都会经过Yakit并被记录下来,如下:

总结

在成功还原请求与响应明文之后,很多原本被加密“保护”起来的问题也就无所遁形了。通过对业务逻辑和接口参数的进一步安全测试与篡改等,最终挖掘到了1个中危漏洞和多个低危漏洞,也算是没有白折腾这一套Frida RPC + MITMproxy的组合技。

事实证明:很多漏洞并非不存在,而是被加密“挡住了眼睛”。一旦能够看到明文,漏洞往往也就暴露出来了。

(彩蛋)

后来在闲暇时间,楼主对加解密实现又做了“深入交流”,结果发现和之前的猜测大致一致:报文的加解密和签名主要采用了国密算法SM2、SM4和SM3的组合完成。

监制丨铁   子

策划丨Cupid

美工丨molin