记一次某银行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({onMatch: function (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);}}},onComplete: function () {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):returnbody = _get_text_safe(flow.request)if not body:returnif not _is_json(body):returnprint(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):returnbody = _get_text_safe(flow.response)if not body:returnprint(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_syncprint("[*] 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
夜雨聆风