Featured image of post 逆向「青桔骑行」Android App,爬虫抓取全市单车和停车点数据

逆向「青桔骑行」Android App,爬虫抓取全市单车和停车点数据

通过 BlackDex、jadx-gui、Burp Suite 等软件相互配合,反编译「青桔骑行」共享单车 App,并开发爬虫抓取全市单车和停车点数据。

由于数据分析的需要,计划抓取「青桔骑行」共享单车手机 App 的单车和停车点位置信息。

抓包

青桔骑行 App 中有一个「附近单车和停车点」的功能,使用 Burp 抓包如下:

尝试删除多余的 HTTP Header 部分,最终判断以下数据是关键:

一是请求 query string 部分:

1
/gateway?api=hm.fa.homeBikeRelated&apiVersion=1.0.0&appKey=fab20e5de8824a3fb238dd5491e05097&appVersion=3.6.12&lang=zh-CN&mobileType=2112123AG&osType=2&osVersion=12&timestamp=1662464686642&token=805_0nhsooZGcPVV5GzWnV_LBDjogh-OFAUL1-HfQJEkzLltxEAMheFe_pgQHjkajcjUuXvwIR_JGPBiI0G9b7ANfCdTFG3RIozplBszKJfUjdkoHz2j7y17KIYxV0rG7BQvrxhvFBjvVGRqG-u-Z8tMNz6p1Tiok9vf_f_joEJSXMYX5VtPTx-S8U3hXZsUPkZi_DzZX0rXIwAA__8%3D&ttid=bh_app&userId=299067488939991&userRole=1&sign=b20f0f5f7aecedac411649d8481d5657

其中,appKeytokenuserId等数据推测是用于鉴权,每个请求均无太大变化。而sign参数可能是经过某种哈希算法产生的字符串,每个请求均不一样,且同一个 sign 过一两分钟后就会过期无法继续使用。另外timestamp为当前毫秒级时间戳,应该与后端校验和 sign 关联。

二是请求的 body 部分:

1
{"bizType":"1","cityId":"34","clientRegionVersion":"123","dataType":"0","pointLat":"26.087146974981934","pointLng":"119.27779868245125","nearbyVehicleQueryRadius":"200","noParkingQueryRadius":"1000","parkingQueryRadius":"1000","powerOffRegionVersion":"0","scene":"1"}

很明显表示当前请求的中心点位置,这也是到时候写脚本需要修改的参数。

反编译

现在 App 基本都有加壳,这里使用 BlackDex 工具对 App 进行脱壳。

https://github.com/CodingGay/BlackDex

脱壳之后产生一堆文件,将其传到电脑上。

那么我们要反编译的源代码就散落在这些 dex 中间,在 jadx-gui 中多选 dex 打开。

Jadx gui是一款 JAVA 反编译工具。一个简单轻巧的 DEX 到 Java 反编译器,可让您导入 DEX,APK,JAR 或 CLASS 文件并将其快速导出为 DEX 格式。如果您是 Android 开发人员,您可能会理解,没有适当的软件帮助,就无法构建,测试或调试应用程序。幸运的是,如今有大量的产品可以帮助您实现快速,便捷的结果。

在菜单栏「文件」中选择将当前反编译结果保存为 Gradle 项目。我们接下来在 Android Studio 中进行调试,因为 AS 功能强大,对于代码搜索、分析等都比 jadx 自带的编辑器方便很多。

逆向代码

顺藤摸瓜

在 AS 中打开项目,全局搜索刚才抓包看到 query string 中的hm.fa.homeBikeRelated,这个名字看起来就很像是目标接口名。

查找到如下代码:

 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
@ApiAnnotation(mo24271a = "hm.fa.homeBikeRelated", mo24272b = BuildConfig.VERSION_NAME, mo24273c = "ofo")
public class RideHomeRelatedReq implements Request<RideHomeRelated> {
    @SerializedName("bizType")
    public int bizType;
    @SerializedName("cityId")
    public int cityId;
    @SerializedName("clientRegionVersion")
    public long clientRegionVersion;
    @SerializedName("dataType")
    public int dataType;
    @SerializedName("pointLat")
    public double lat;
    @SerializedName("pointLng")
    public double lng;
    @SerializedName("nearbyVehicleQueryRadius")
    public int nearbyVehicleQueryRadius;
    @SerializedName("noParkingQueryRadius")
    public int noParkingQueryRadius;
    @SerializedName("parkingQueryRadius")
    public int parkingQueryRadius;
    @SerializedName("powerOffRegionVersion")
    public long powerOffRegionVersion;
    @SerializedName("scene")
    public int scene;
}

很明显 App 把不同的请求用面向对象的设计进行了统一封装,这个RideHomeRelatedReq就是附近单车接口被封装成的请求类,我们全局搜索这个关键词。

查找到这样的调用代码:

这里通过一个AmmoxBizService.m15717e().mo24284a()函数发送请求,我们搜一下这个函数,发现它在很多地方均有出现,应该是一个封装的 HTTP 调用方法:

跟进这个函数,发现 m15717e 这个函数是一个工厂模式的创建函数,填充了一个 KopService 接口的对象。

1
2
3
public static KopService m15717e() {                                               
    return (KopService) AmmoxServiceManager.m15986a().mo24356a(KopService.class);  
}                                                                                  

KopService 接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.didi.bike.ammox.biz.kop;
public interface KopService extends AmmoxService {
    /* renamed from: a */
    Lifecycle.Event mo24282a();

    /* renamed from: a */
    void mo24283a(Application application);

    /* renamed from: a */
    <T> void mo24284a(Request<T> request, HttpCallback<T> dVar);

    /* renamed from: c */
    String mo24286c();

    /* renamed from: d */
    long mo24287d();
}

另辟蹊径

到目前为止还是没发现生成签名的代码在哪里,只知道 App 对接口请求封装的很标准。

那么既然封装完善,有没有可能这个 sign 也是在某个地方统一处理的呢?

之前抓包的 HTTP Header 中还有一个特殊的字符串:

1
Host: htwkop.xiaojukeji.com

是请求服务的域名地址,我们搜一下这个 Host,找到下面这一个类,属于数据类:

 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
public class HTWOnlineHostProvider implements HostProvider {
    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: a */
    public String mo24231a() {
        return "Online";
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: b */
    public String mo24232b() {
        return "htwkop.xiaojukeji.com";
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: c */
    public int mo24233c() {
        return 443;
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: d */
    public String mo24234d() {
        return "gateway";
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: e */
    public String mo24235e() {
        return OmegaConfig.PROTOCOL_HTTPS;
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: f */
    public String mo24236f() {
        return "fab20e5de8824a3fb238dd5491e05097";
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: g */
    public String mo24237g() {
        return "5225808e3fa64c5aafb839c505dc474a";
    }

    @Override // com.didi.bike.ammox.biz.env.HostProvider
    /* renamed from: h */
    public boolean mo24238h() {
        return false;
    }
}

这个 gateway 在 query string 中也有,还有这一串随机数字和字母看起来像是某一种签名所用的 salt。我们搜索一下它继承的这个 HostProvider,发现有一个叫 RequestBuilder 的类接受了 HostProvider 作为参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* renamed from: a */
String mo24305a(HostProvider bVar);

/* ...省略... */

/* renamed from: com.didi.bike.ammox.biz.kop.j$a */
/* compiled from: RequestBuilder */
public static abstract class AbstractC3250a implements RequestBuilder {

    /* ...省略... */

    @Override // com.didi.bike.ammox.biz.kop.RequestBuilder
    /* renamed from: a */
    public String mo24305a(HostProvider bVar) {
        String str;
        int i;
        String str2;
        String str3;
        String str4;
        String str5;
        // 省略...
    }

而这个RequestBuilder类刚好就和刚刚找到的KopService在同一个包下:

乘胜追击

那么合理推测这个 RequestBuilder 和 KopService 请求对应的服务有关。

上面 mo24305a 这个函数中有这样的代码,将两段盐值提取出来:

1
2
this.f11012e = bVar.mo24237g();// HTWOnlineHostProvider 中的 5225808e3fa64c5aafb839c505dc474a
this.f11013f = bVar.mo24236f();// HTWOnlineHostProvider 中的 fab20e5de8824a3fb238dd5491e05097

其中,f11013f 在下面这个函数中用到,可见 f11013f 实际就是请求中的 appKey:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private void m15919e() {// modify tree map                                       
    if (this.f11010c.mo24274d()) {                                               
        UserInfoService i = AmmoxBizService.m15721i();                           
        if (i.mo24253a()) {                                                      
            this.treeMapToSign.put(FusionBridgeModule.PARAM_TOKEN, i.mo24254b());
            this.treeMapToSign.put("userId", i.mo24257d());                      
        }                                                                        
        this.treeMapToSign.put("userRole", "1");                                 
    }                                                                            
    this.treeMapToSign.put("appKey", this.f11013f);// 这里用到
    this.treeMapToSign.put("appVersion", SystemUtil.m21009a(this.f11008a));      
    this.treeMapToSign.put("ttid", m15918d());                                   
    this.treeMapToSign.put("osType", "2");                                       
    this.treeMapToSign.put("osVersion", WsgSecInfo.m65631i(this.f11008a));       
    this.treeMapToSign.put("mobileType", WsgSecInfo.m65633j(this.f11008a));      
    this.treeMapToSign.put("timestamp", AmmoxBizService.m15717e().mo24286c());   
    this.treeMapToSign.put("lang", AmmoxBizService.m15714b().mo24239a());        
}                                                                                

为了便于阅读,上面函数中部分函数名和变量名经过了重命名。

treeMapToSign是类的一个全局变量,在多处对其调用了 put 和 putAll 方法,后续分析证明了这就是将待签名数据项放入其中的过程。最后是将整个变量计算生成一个哈希。

f11012e 在下面这个函数用到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private String m15913a(TreeMap<String, String> treeMap) {                                
    StringBuilder sb = new StringBuilder();                                              
    for (Map.Entry<String, String> entry : treeMap.entrySet()) {                         
        if (entry.getValue() != null) {                                                  
            sb.append(entry.getKey());                                                   
            sb.append(entry.getValue());                                                 
        }                                                                                
    }                                                                                    
    String str = this.f11012e;// 这里用到了
    String str2 = str + sb.toString() + str;// 将 treeMap 进行 stringify 后,与盐值前后拼接
    // 这里很明确的表示了该函数和 sign 有关
    AmmoxTechService.m15996a().mo24398b("RequestBuilder", "client sign source: " + str2);
    // 将拼接结果传入另一个函数,返回它的结果
    return C4111n.m20981a(str2);                                                         
}                                                                                        

我们跟进 m20981a 这个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public static String m20981a(String str) {                                              
    try {                                                                               
        byte[] bytes = str.getBytes("UTF-8");                                           
        MessageDigest instance = MessageDigest.getInstance(MessageDigestAlgorithms.MD5);
        instance.update(bytes);                                                         
        byte[] digest = instance.digest();                                              
        StringBuffer stringBuffer = new StringBuffer(digest.length * 2);                
        for (byte b : digest) {                                                         
            stringBuffer.append(Character.forDigit((b & 240) >> 4, 16));                
            stringBuffer.append(Character.forDigit(b & 15, 16));                        
        }                                                                               
        return stringBuffer.toString();                                                 
    } catch (Throwable unused) {                                                        
        return "";                                                                      
    }                                                                                   
}                                                                                       

很容易看出这是开发者他们自己发明的一套 MD5 增强版哈希算法。

接着我们继续分析之前找到的AbstractC3250a里面的mo24305a这个函数,定位到下面的这些语句:

 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
this.treeMapToSign.put(C1178c.f2344m, str4);// apiVersion                      
m15919e();
TreeMap treeMap = new TreeMap();                                               
mo24309a((Map<String, String>) treeMap);// do nothing                          
if (!treeMap.isEmpty()) {// do nothing                                         
    this.treeMapToSign.putAll(treeMap);                                        
}                                                                              
C3251a aVar = new C3251a(str3, str2, i, str);// 手动拼接将 treeMap 进行 stringify
aVar.m15928a("api", str6);
for (Map.Entry<String, String> entry : this.treeMapToSign.entrySet()) {        
    aVar.m15928a(entry.getKey(), entry.getValue());
}                                                                              
this.treeMapToSign.put("api", str6);
m15917b(this.treeMapToSign);                                                   
try {                                                                          
    str5 = m15913a(this.treeMapToSign);// 这里调用到了签名函数,对 treeMapToSign 进行签名
} catch (Exception e3) {                                                       
    e3.printStackTrace(System.out);                                            
    if (!CommonUtil.m20939a(this.f11008a)) {                                   
        str5 = "";
    } else {                                                                   
        throw new RuntimeException("sign4KOP error, msg===" + e3.getMessage());
    }                                                                          
}                                                                              
aVar.m15928a("sign", str5);// 果然是作为请求中的 sign 这个参数
this.f11015h = aVar.m15929a();// 由于 treeMap 是手动拼接的,最后会有一个「&」,这个函数的作用是删除最后的「&」
return this.f11015h;                                                           

本地签名

大概知道了签名用到的参数,那么在本地尝试实现一下。先写工具类:

 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
public class Util {
    public String signTreeMap(TreeMap<String, String> treeMap) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : treeMap.entrySet()) {
            if (entry.getValue() != null) {
                sb.append(entry.getKey());
                sb.append(entry.getValue());
            }
        }
        String str = "5225808e3fa64c5aafb839c505dc474a";
        String str2 = str + sb.toString() + str;
        return sign(str2);
    }

    public String sign(String str) {
        try {
            byte[] bytes = str.getBytes("UTF-8");
            MessageDigest instance = MessageDigest.getInstance(MessageDigestAlgorithms.MD5);
            instance.update(bytes);
            byte[] digest = instance.digest();
            StringBuffer stringBuffer = new StringBuffer(digest.length * 2);
            for (byte b : digest) {
                stringBuffer.append(Character.forDigit((b & 240) >> 4, 16));
                stringBuffer.append(Character.forDigit(b & 15, 16));
            }
            return stringBuffer.toString();
        } catch (Throwable unused) {
            return "";
        }
    }
}

尝试将数据放入 TreeMap,进行签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public Object latLng(Double lat, Double lng) {
    long timestamp = System.currentTimeMillis();
    TreeMap<String, String> map = new TreeMap<>() {{
        put("api", "hm.fa.homeBikeRelated");
        put("apiVersion", "1.0.0");
        put("appKey", "fab20e5de8824a3fb238dd5491e05097");
        put("appVersion", "3.6.10");
        put("lang", "zh-CN");
        put("mobileType", "2112123AC");
        put("osType", "2");
        put("osVersion", "11");
        put("timestamp", timestamp + "");
        put("token", "PBwR676Xlmw3LakzYSA2M0AVpwMwKsGZOn5-zTZltvYkzDtOxUAMheG9_LV1dcYTx7Fbe");
        put("ttid", "bh_app");
        put("userId", "299067488939991");
        put("userRole", "1");
    }};
    return Map.of("sign", util.signTreeMap(map), "timestamp", timestamp);
}

注意 Java 的 TreeMap 遍历拿到的数据顺序和放入的顺序无关,所以 put 顺序不论如何都不会影响到签名结果。

用 Burp 发送,显示系统错误,应该是签名不正确导致的:

推测可能 treeMap 中有其他元素没有被签名进去。

从刚才的函数下面发现了另一个可疑的函数:

 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
public String mo24304a() throws IllegalAccessException {
    Object obj;
    JsonObject jsonObject = new JsonObject();
    Request request = this.f11011d;
    if (request != null) {
        if (request instanceof DynamicRequest) {
            Map<String, Object> b = ((DynamicRequest) request).mo24270b();
            if (b != null) {// 对 JSON 数据进行处理
                for (Map.Entry<String, Object> entry : b.entrySet()) {
                    jsonObject.addProperty(entry.getKey(), m15916b(entry.getValue() + ""));
                }
            }
        } else {
            Field[] declaredFields = request.getClass().getDeclaredFields();
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    field.setAccessible(true);
                    if (!m15915a(field) && (obj = field.get(this.f11011d)) != null && field.getAnnotation(IgnoreInReq.class) == null) {
                        SerializedName serializedName = (SerializedName) field.getAnnotation(SerializedName.class);
                        jsonObject.addProperty(serializedName == null ? field.getName() : serializedName.value(), m15916b(obj + ""));
                    }
                }
            }
        }
     /* ...省略... */

又向一个 Map 里加入了很多东西,说不定也是签名的要素。

除了 query string,body 是一个 JSON,里面还有一堆字段(经度纬度等)。尝试一下将这些字段也加入 treeMap 进行签名,果然现在就可以了。

于是将自己写的 latLng 函数加入以下行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
put("bizType", "1");                            
put("cityId", "34");                            
put("clientRegionVersion", "122");              
put("dataType", "0");                           
put("pointLat", String.valueOf(lat));  
put("pointLng", String.valueOf(lng));           
put("nearbyVehicleQueryRadius", "200");         
put("noParkingQueryRadius", "1000");            
put("parkingQueryRadius", "1000");              
put("powerOffRegionVersion", "0");              
put("scene", "1");                              

这样就可以成功生成签名了。

爬虫脚本

脚本我习惯用 TypeScript 写,由于 App 自己实现的加强版 MD5 算法在其他语言中不好实现,于是将 Java 版的签名脚本写到 Spring Boot 里,开放一个接口,供我的脚本调用,进行数据签名。

脚本片段如下,逻辑很简单,就是指定两个坐标点确定矩形区域,以一定步长调用 API 接口,抓取范围内单车和停车点数据,将它们插入到数据库而已。

  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
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import mysql, {Pool} from 'promise-mysql'
import axios from "axios"
import {BikeResponse, GeoData, TokenServerInfo} from "./types"
import * as fs from "fs"
import {fail} from "assert"

let conn: Pool
let client = axios.create({timeout: 15000})
let savedSpotIDs: number[] = []
let leftTop: GeoData = {
    lat: 26.08140386792755,
    lng: 119.28208887577057
}
let rightBottom: GeoData = {
    lat: 26.089765487712256,
    lng: 119.28994104266167
}
let lngStep = 0.00102575
let latStep = 0.00130188
let failedGeo: GeoData[] = []

async function main() {
    conn = await mysql.createPool({
        host: 'localhost',
        user: 'root',
        password: 'root',
        database: 'shared_bikes'
    })
    let sRes = await conn.query('select id from parking_spots')
    for (let row of sRes) {
        savedSpotIDs.push(parseInt(row['id']))
    }

    let currentLat = leftTop.lat
    let currentLng = leftTop.lng
    while (currentLat < rightBottom.lat) {
        while (currentLng < rightBottom.lng) {
            let bool = await processLatLng(currentLat, currentLng)
            if (!bool) {
                failedGeo.push({
                    lat: currentLat,
                    lng: currentLng
                })
                fs.writeFileSync('failedGeo.json', JSON.stringify(failedGeo))
            }
            currentLng += lngStep
            await sleep(3000)
        }
        currentLng = leftTop.lng
        currentLat += latStep
    }
    conn.end()
}

async function processLatLng(lat: number, lng: number): Promise<boolean> {
    console.log('processing lat:' + lat + ' lng:' + lng)
    let resp = await client.get("http://localhost:8112/req/latLng?lat=" + lat + "&lng=" + lng)
    let tokenServerInfo: TokenServerInfo = resp.data
    try {
        resp = await client.post('https://htwkop.xiaojukeji.com/gateway?api=hm.fa.homeBikeRelated' +
            '&apiVersion=1.0.0&appKey=fab20e5de8824a3fb238dd5491e05097&appVersion=3.6.10' +
            '&lang=zh-CN&mobileType=2112123AC&osType=2&osVersion=11&timestamp=' + tokenServerInfo.timestamp +
            '&token=PBwR676X5-v1_utEvyy3ijxx45c4ss451mhHbJR2ZhfPyzn7SuvwAAAP__' +
            '&ttid=bh_app&userId=299067488939991&userRole=1' +
            '&sign=' + tokenServerInfo.sign, {
                "bizType": "1",
                "cityId": "34",
                "clientRegionVersion": "122",
                "dataType": "0",
                "pointLat": lat.toString(),
                "pointLng": lng.toString(),
                "nearbyVehicleQueryRadius": "200",
                "noParkingQueryRadius": "1000",
                "parkingQueryRadius": "1000",
                "powerOffRegionVersion": "0",
                "scene": "1"
            }
        )
        let data: BikeResponse = resp.data
        if (data.code != 200) {
            console.error(resp.data)
            return false
        }
        for (let spot of data.data.nearbyParkingSpotResult.nearbyParkingSpotList) {
            if (savedSpotIDs.includes(parseInt(spot.spotId))) {
                console.log('exists spot ' + spot.spotId)
                continue
            }
            let c = await conn.getConnection()
            c.beginTransaction()
            try {
                c.query('insert into parking_spots (id,city_id,name,lat,lng) values(?,?,?,?,?)', [
                    spot.spotId, 2, spot.spotPlaceName, spot.centerLat, spot.centerLng])
                for (let coord of spot.coordinates) {
                    c.query('insert into parking_spot_coordinates (spot_id,lat,lng) values(?,?,?)', [
                        spot.spotId, coord.lat, coord.lng])
                }
                c.commit()
                c.release()
                savedSpotIDs.push(parseInt(spot.spotId))
                console.log('inserted ' + spot.spotPlaceName + 'lat:' + lat + ' lng:' + lng)
            } catch (e0) {
                console.error(e0)
                c.rollback()
            }
        }
    } catch (e) {
        console.error('err getting lat:' + lat + ' lng:' + lng + '  ' + e)
        return false
    }
    return true
}

main()

function sleep(ms: number) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms)
    })
}

总结

反编译是一件很难的事,不仅考验技术,还和运气有很大关系。

青桔单车 App 签名逻辑是用 Java 实现的,这个其实还好了。有些 App 的安全性部分用 C++ 实现,编译生成 so 文件,用 jni 注入 native 函数调用,如果要调试还需要用到 IDA Pro 分析汇编代码,这才是真正的地狱难度。

Licensed under CC BY-NC-SA 4.0