1、目的
探索学习app接口的加解密机制,并通过api模拟调用的方式,发起业务请求。仅供学习。
2、工具准备
样本App版本:v5.0.80,v5.0.90
设备:Oppo R9s(Android7.1.1)+ MacOS Big Sur(Intel)
注入框架:xposed、frida(hluda 15.2.2)
反编译&其他:JEB、jadx、Charles
3、过程
大致分为抓包、脱壳、反编译、动态调试/加解密算法探索,构造模拟请求几个步骤,每个步骤都可能有不同的异常出现,本文主要记录在过程中的主体脉络和流程,过程中会附上关键代码。
3.1 抓包
首先尝试在手机上配置wifi代理,但Charles中无法看到相应的包记录。猜测是因为App屏蔽了网络代理,因此改用其他方式。手机上安装Drony,并开启手机全局网络代理(类型选择:socks5),代理地址指向Chares,此时就可以愉快的看到请求记录了。

如果是通过iOS抓包,直接通过小火箭抓包也是灰常方便。另外下载Drony App可能需要TZ,解决无法访问的问题。
在抓到的报文中,可以看到每次请求中,都包含了一些奇怪的header,比如t、spv、n、st,这些字段大概率与api接口的加密与签名有关。接下来,需要结合代码进一步分析。
3.2 脱壳&反编译
直接通过Xposed + 反射大师App,即可做到轻松脱壳,App未针对Xposed做检测。脱壳后得到7个dex文件,使用python脚本合并,将7个dex文件利用jadx全部反编译成Java文件到同一目录,即可直接翻阅App反编译后的源码。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import os, sys# 合并dex# e.g: python3 merge_dex.py ./source_dir/ output_dirif __name__ == "__main__": if len(sys.argv) < 3: print("start error") sys.exit() source_dir = sys.argv[1] output_dir = sys.argv[2] print(source_dir, output_dir)files = os.listdir(source_dir) for file in files: if file.find(".dex") > 0: sh = '{your_path}/bin/jadx -Pdex-input.verify-checksum=no -j 1 -r -d ' + output_dir + " " + source_dir + file print(sh) os.system(sh) |
这时直接在反编译的结果中搜索关键词"spv",却发现找不到。难道这些字段都隐藏到so中了,那就麻烦了。这时使用JEB再次反编译试试看,再次搜索"spv",找到了。

这里,要提醒一下:针对反编译,同样的dex文件,用不同的反编译工具,结果也会不一样,可读性差异很多,因此当使用一种工具反编译失败的话,可以尝试用不同的工具,比如,通用一段代码的反编译结果,使用jadx时,提示反编译失败,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | /* JADX WARN: Code restructure failed: missing block: B:61:0x017a, code lost: r0 = r8.a("ssk"); b.f.b.l.a(r0); r3 = r8.a("siv"); b.f.b.l.a(r3); cn.xxxxclub.app.base.h.z.a(r0, r3); *//* JADX WARN: Removed duplicated region for block: B:54:0x0168 A[Catch: Exception -> 0x018c, TryCatch #0 {Exception -> 0x018c, blocks: (B:42:0x0138, B:46:0x0154, B:48:0x015c, B:54:0x0168, B:56:0x0170, B:61:0x017a, B:45:0x014d), top: B:66:0x0138 }] *//* Code decompiled incorrectly, please refer to instructions dump. To view partially-correct add '--show-bad-code' argument*/public okhttp3.ad intercept(okhttp3.w.a r19) { /* Method dump skipped, instructions count: 415 To view this dump add '--comments-level debug' option */ throw new UnsupportedOperationException("Method not decompiled: cn.xxxxclub.app.e.c.intercept(okhttp3.w$a):okhttp3.ad");} |
但是使用JEB时,结果则基本可用,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | public ad intercept(w.a arg19) { ....(略) String v8_1 = String.valueOf(z.b()); v4_1.b("t", v8_1); l.b("dc1ad18e-3e2d-4d49-a303-f637c6a5a3fb", "randomUUID().toString()"); String v9_1 = b.m.g.a("dc1ad18e-3e2d-4d49-a303-f637c6a5a3fb", "-", "", false, 4, null); v4_1.b("n", v9_1); v4_1.b("sy", "0"); int v10_1 = v10 == 0 || !cn.xxxxclub.app.base.manager.d.a.i() ? 0 : 1; String v5_4 = this.a(((boolean)v10_1), v8_1 + v5_3 + v9_1 + g.a.b()); if(((CharSequence)v5_4).length() > 0) { v4_1.b("st", v5_4); } v4_1.b("sny", (v10_1 == 0 ? "j" : "c")); v4_1.b("rcs", "1"); v4_1.b("spv", "1.1"); if(v11) { String v5_5 = URLEncoder.encode(String.valueOf(cn.xxxxclub.app.base.manager.f.a.b()), "utf-8"); l.b(v5_5, "encode(LocationManager.g…de().toString(), \"utf-8\")"); v4_1.b("Local-Longitude", v5_5); String v5_6 = URLEncoder.encode(String.valueOf(cn.xxxxclub.app.base.manager.f.a.a()), "utf-8"); l.b(v5_6, "encode(LocationManager.g…de().toString(), \"utf-8\")"); v4_1.b("Local-Latitude", v5_6); } .... return v5_7; } |
3.3 动态调试分析
拿到反编译源码后,接下来就需要结合frida动态分析代码调用链,找到api调用的核心算法逻辑并加以验证。

在App最新版本v5.0.90上,连接frida客户端。frida注入失败。随后换了hluda、xcube等方案均以失败告终,看了下app的加固方案,使用的腾讯的加固方案,对应的壳文件是libshell-super.cn.xxxxclub.app.so,尝试绕过壳的反注入逻辑,也没有效果。
这时偶然看到旧版本的app使用的壳文件是libshell-super.2019.so,灵光一闪,感觉旧版本的app上应该有机会,于是下载安装v5.0.80,frida注入成功了。app上开启了强制更新,于是在Charles上hook重写了app检查更新接口的返回结果,让app检查不到新版本,app仍然可以继续使用(后续有风险,历史接口可能下线)。
旧版本app上也可以使用frida工具集:Objection,通过调试和代码比对,基本确认了核心的算法签名逻辑位置:

签名的传入参数为分别为:t - 时间戳、data_json - 按json序列化后的业务对象参数、n - 去掉"-"符号后的uuid(32位字符串)、auth_token - 登录后用户令牌,按照如下规则排列所得:
1 | "{t}{data_json}{n}{auth_token}" |
返回字符串即为签名结果 - st
该签名算法有使用native方法,具体算法逻辑应该需要反汇编相应的so文件了。签名规则已经基本明确了,直接调用java层方法,走RPC调用即可得到我们想要的结果。偷懒了,就不去深挖汇编代码了,笔者也不确认一定能找到结果-_-||
3.4 RPC调用
1)创建js文件app_inject.js,声明rpc接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | var g_instance = null;Java.enumerateClassLoaders({ onMatch: function (loader) { try { if (loader.findClass("cn.xxxxclub.app.e.c")) { Java.classFactory.loader = loader; g_instance = Java.use("cn.xxxxclub.app.e.c").$new(); console.log("target found!") } } catch (error) {} }, onComplete: function () { }});// boolean z, String strfunction sign(z, text){ console.log("js7 start run: sign", g_instance, text) var result = g_instance.a.overload('boolean', 'java.lang.String').call(g_instance, z, text); console.log("result = ", result) return result}rpc.exports = { getsign: sign, hello: function () { return 'hello'; }}console.log("injected.") |
2)创建frida客户端,声明rpc调用。文件名:frida_client.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | import fridaimport timeclass FridaClient: class StartMode: attach = 'attach' spawn = 'spawn' def __init__(self, package_name, js_file, mode=StartMode.attach, delay_sec_4_spawn=2): self.results = {} self.script = None self.package_name = package_name self.delay_sec_4_spawn = delay_sec_4_spawn self.mode = mode self.js_file = js_file def on_message(self, message, data): if message['type'] == 'send': payload = message['payload'] print("[on_message]:", payload) else: print(message) def start(self): print(f"starting frida client with mode: {self.mode} ... pkg = [{self.package_name}]") if self.mode == FridaClient.StartMode.attach: session = frida.get_device_manager().add_remote_device("127.0.0.1:1234").attach(self.package_name) elif self.mode == FridaClient.StartMode.spawn: device = frida.get_device_manager().add_remote_device("127.0.0.1:1234") pid = device.spawn([self.package_name]) device.resume(pid) time.sleep(self.delay_sec_4_spawn) session = device.attach(pid) with open(self.js_file, 'r') as f: js_code = f.read() script = session.create_script(js_code) script.on('message', self.on_message) self.script = script script.load() print("load ready") def stop(self): if self.script: self.script.unload() self.script = None def get_sign(self, text: str): return self.script.exports.getsign(True, text) |
3)构造参数,发起RPC调用。文件名:demo.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | # -*- coding: utf-8 -*-import jsonimport timeimport uuidimport requestsfrom frida_client import FridaClientdef _headers(auth_token, device_id, t, n, signed, lon, lat): return { 'system-language': 'CN', 'device-type': 'android', 'tpg': '1', 'app-version': '5.0.80', 'device-id': device_id, 'device-os-version': '7.1.1', 'device-name': 'OPPO_OPPO+R9s', 'treq-id': '1540d0ec530741abbab593af41966110.313.17103985647343144', 'auth-token': auth_token, 'longitude': lon, 'latitude': lat, 'p': '1656120205', 't': t, 'n': n, 'sy': '0', 'st': signed, 'sny': 'c', 'rcs': '1', 'spv': '1.1', 'Local-Longitude': '0.0', 'Local-Latitude': '0.0', 'Content-Type': 'application/json;charset=utf-8', 'Host': 'api-xxxx.walmartmobile.cn', 'User-Agent': 'okhttp/4.8.1' }def work(): frida_client = FridaClient(package_name='cn.xxxxclub.app', js_file='app_inject.js', mode=FridaClient.StartMode.spawn) frida_client.start() url = "https://api-xxxx.walmartmobile.cn/api/v1/xxxx/goods-portal/spu/search" device_id = 'b9fb859f7cfeb98ef39a31c410001f716c04' user_uid = '181864991321' auth_token = '740d926b981716f45de7a402b7b6761a46d9af48f752262b77a2cb0701d482f20c60e6345685b46681a1c23129bdffad022e2e75f60ac763' lon, lat = '114.151608', '22.554734' t = '1711440481379' # t = f"{int(time.time() * 1000)}" goods_name = '蛋糕' data = { "userUid": user_uid, "pageNum": 1, "pageSize": 20, "keyword": goods_name, "rewriteWord": goods_name, "filter": [], "storeInfoVOList": [ { "storeId": 9991, "storeType": 32, "storeDeliveryAttr": [10] }, { "storeId": 6758, "storeType": 256, "storeDeliveryAttr": [2, 3, 4, 5, 6, 9, 12, 13] }, { "storeId": 6580, "storeType": 2, "storeDeliveryAttr": [7, 13] }, { "storeId": 9992, "storeType": 8, "storeDeliveryAttr": [1] } ], "addressVO": { "cityName": "", "countryName": "", "detailAddress": "", "districtName": "", "provinceName": "" }, "uid": device_id, "uidType": 3, "sort": "0" } n = str(uuid.uuid4()).replace('-', '') data_json = json.dumps(data, indent=None, separators=(',', ':'), ensure_ascii=False) signed = frida_client.get_sign(text=f"{t}{data_json}{n}{auth_token}") headers = _headers(auth_token=auth_token, device_id=device_id, t=t, n=n, signed=signed, lon=lon, lat=lat) response = requests.request("POST", url, headers=headers, data=data_json.encode('utf-8')) print(response.text)work() |
再看看结果,已经成功得到响应数据了。大功告成!

3.5 踩坑说明
在执行frida js注入时,Java.enumerateClassLoaders()仅支持Android 7.0及以上系统,若使用低版本的Android系统,如Android 6.1,则需要使用send(),进行消息异步通知。当采用异步通知时,在Python客户端的编码中,需要定义消息回调函数,同时将异步调用封装成同步调用,方便上游调用使用。对应的js代码和python代码如下:
app_inject_for_android_6.0.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | var g_instance = null;// boolean z, String strfunction sign(msgId, z, text){ Java.perform(function(){ console.log("js start run: sign", g_instance, text) try { if (g_instance == null) { g_instance = Java.use('cn.xxxxclub.app.e.c').$new(); console.log("init instance success") } var result = g_instance.a.overload('boolean', 'java.lang.String').call(g_instance, z, text); send({'msgId': msgId, 'content': result}) } catch (e) {} return result });}rpc.exports = { getsign: sign, hello: function () { return 'hello'; }}log("injected.") |
frida_client_for_android_6.0.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | import uuidimport fridaimport threadingimport timeclass FridaClient: class StartMode: attach = 'attach' spawn = 'spawn' def __init__(self, package_name, js_file, mode=StartMode.attach, delay_sec_4_spawn=2): self.results = {} self.script = None self.package_name = package_name self.event = threading.Event() self.result_queue = [] self.delay_sec_4_spawn = delay_sec_4_spawn self.mode = mode self.js_file = js_file def on_message(self, message, data): if message['type'] == 'send': payload = message['payload'] msdId = payload['msgId'] content = payload['content'] print("[on_message]:", msdId, content) # 将结果存入队列 self.result_queue.append((msdId, content)) # 设置事件,通知主线程结果已经准备好 self.event.set() else: print(message) def get_result(self, msgId): # 返回指定id的结果 for idx, (id, result) in enumerate(self.result_queue): if id == msgId: del self.result_queue[idx] return result return None def start(self): print(f"starting frida client with mode: {self.mode} ... pkg = [{self.package_name}]") if self.mode == FridaClient.StartMode.attach: # session = frida.get_usb_device().attach(self.package_name) session = frida.get_device_manager().add_remote_device("127.0.0.1:1234").attach(self.package_name) elif self.mode == FridaClient.StartMode.spawn: device = frida.get_device_manager().add_remote_device("127.0.0.1:1234") pid = device.spawn([self.package_name]) device.resume(pid) time.sleep(self.delay_sec_4_spawn) session = device.attach(pid) with open(self.js_file, 'r') as f: js_code = f.read() script = session.create_script(js_code) script.on('message', self.on_message) self.script = script script.load() print("load ready") def stop(self): # 停止脚本和会话 if self.script: self.script.unload() self.script = None def get_sign_sync(self, text: str, timeout=5, poll_interval=0.1, max_polls=3): """ 因为rpc调用结果是异步返回的,因此通过线程等待唤醒的方式,得到结果后才返回,以此达到接口数据同步返回的效果 """ msgId = str(uuid.uuid4()) self.script.exports.getsign(msgId, True, text) # 等待事件,设置超时 self.event.wait(timeout=timeout) self.event.clear() # 清除事件,以便下次使用 # 返回结果 result = self.get_result(msgId) if result is None: # 如果超时未收到结果,启动轮询 start_time = time.time() poll_count = 0 while time.time() - start_time < timeout and poll_count < max_polls: result = self.get_result(msgId) if result is not None: break poll_count += 1 time.sleep(poll_interval) return result def get_sign(self, text: str): return self.script.exports.getsign(True, text) |
3.6 备注
通过测试验证,可以发现两个版本v5.0.80,v5.0.90的签名算法是一致的。因此可以直接利用v5.0.80做签名即可。
打完收工!
原文链接:https://bbs.kanxue.com/thread-281158.htm
未经授权,禁止转载!!