首页
社区
课程
招聘
实战:某会员商店App的api接口分析
2024-4-1 11:32 14447

实战:某会员商店App的api接口分析

2024-4-1 11:32
14447

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,此时就可以愉快的看到请求记录了。

image-20240326145803810

如果是通过iOS抓包,直接通过小火箭抓包也是灰常方便。另外下载Drony App可能需要TZ,解决无法访问的问题。

在抓到的报文中,可以看到每次请求中,都包含了一些奇怪的header,比如t、spv、n、st,这些字段大概率与api接口的加密与签名有关。接下来,需要结合代码进一步分析。

3.2 脱壳&反编译

直接通过Xposed + 反射大师App,即可做到轻松脱壳,App未针对Xposed做检测。脱壳后得到7个dex文件,使用python脚本合并,将7个dex文件利用jadx全部反编译成Java文件到同一目录,即可直接翻阅App反编译后的源码。

image-20240326120729769

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_dir
if __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",找到了。

image-20240326151130082

这里,要提醒一下:针对反编译,同样的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调用的核心算法逻辑并加以验证。

image-20240326153601449

在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,通过调试和代码比对,基本确认了核心的算法签名逻辑位置:

image-20240326161215124

签名的传入参数为分别为: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 str
function 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 frida
import time
 
class 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 json
import time
import uuid
import requests
 
from frida_client import FridaClient
 
def _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()

再看看结果,已经成功得到响应数据了。大功告成!

image-20240327100412015

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 str
function 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 uuid
import frida
import threading
import time
 
 
class 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做签名即可。

打完收工!


阿里云助力开发者!2核2G 3M带宽不限流量!6.18限时价,开 发者可享99元/年,续费同价!

收藏
点赞2
打赏
分享
最新回复 (17)
雪    币: 431
活跃值: (390)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
浮事青云 2024-4-1 14:17
2
0
经典山姆会员店。。但是他的小程序好像没加密
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 2024-4-1 14:22
3
0
没有反编译过小程序,但是看抓包的报文,应该有用了微信的安全网关,报文请求和响应都是加密的。就没有深入研究山姆的小程序了
雪    币: 19968
活跃值: (29522)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2024-4-1 14:48
4
1
感谢分享
雪    币: 648
活跃值: (710)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_fidppcok 2024-4-2 09:15
5
0
Dalin1 没有反编译过小程序,但是看抓包的报文,应该有用了微信的安全网关,报文请求和响应都是加密的。就没有深入研究山姆的小程序了
这个是不是叫云函数?
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 2024-4-2 09:59
6
0
mb_fidppcok 这个是不是叫云函数?
不是,云函数类似于serverless,从客户端角度来看,和普通的http/https接口没区别。山姆这个报文都是加密了,大概率是用了腾讯的“Donut安全网关”,详细资料可以看看:https://dev.weixin.qq.com/docs/gateway/?utm_source=gateway_homepage
雪    币: 0
活跃值: (452)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Analysts1981 2024-4-5 19:43
7
0
????????????????
雪    币: 189
活跃值: (50)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_ubjkchtw 2024-4-15 19:47
8
0

最近在学习这个逆向,我只跟着做到RPC,剩下的去分析so看不明白,好像是动态加载的so,求指点

最后于 2024-4-15 20:08 被mb_ubjkchtw编辑 ,原因:
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 2024-4-18 14:27
9
0
我也没有深入分析so,只是发现低版本app的so跟最新版本app的不太一样,更简单一些。直接走rpc调用也就不用关心so细节了
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 2024-4-18 14:29
10
0
mb_ubjkchtw 最近在学习这个逆向,我只跟着做到RPC,剩下的去分析so看不明白,好像是动态加载的so,求指点
我也没有深入分析so,只是发现低版本app的so跟最新版本app的不太一样,更简单一些。直接走rpc调用也就不用关心so细节了
雪    币: 648
活跃值: (710)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_fidppcok 2024-4-22 09:11
11
0
Dalin1 不是,云函数类似于serverless,从客户端角度来看,和普通的http/https接口没区别。山姆这个报文都是加密了,大概率是用了腾讯的“Donut安全网关”,详细资料可以看看:https://d ...
那类似这样的方案,能拿到对应的代码或者Key?
雪    币: 189
活跃值: (50)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_ubjkchtw 2024-4-26 00:16
13
0
Dalin1 我也没有深入分析so,只是发现低版本app的so跟最新版本app的不太一样,更简单一些。直接走rpc调用也就不用关心so细节了
换思路通过曲线的方法搞出来了
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 2024-4-29 12:43
14
0
mb_fidppcok 那类似这样的方案,能拿到对应的代码或者Key?
理论上是可以的。小程序通过Dount组件,对接实际的请求和响应报文进行了加密。其具体加解密逻辑就需要逆向看看dount在客户端的各种处理了
雪    币: 226
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
小日日 5天前
15
0
哥哥有v5.0.80版本的下载地址吗
雪    币: 460
活跃值: (105)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Dalin1 5天前
16
1
小日日 哥哥有v5.0.80版本的下载地址吗
https://apkpure.net/shan-mu-hui-yuan-shang-dian/cn.samsclub.app/download/5.0.80 挂梯子
雪    币: 226
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
小日日 4天前
17
0
哥,我用objection尝试hook的时候提示找不到这个类,可以搜索到这个类,哥你是咋解决的呢,我用spawn也找不到,哥有联系方式吗,给红包
雪    币: 226
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
小日日 4天前
18
0
我按照你的方式已经搞出来,objection尝试hook获取参数的时候不知道咋hook,希望大哥能教下
游客
登录 | 注册 方可回帖
返回