由于数据分析的需要,计划抓取「青桔骑行」共享单车手机 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×tamp=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
|
其中,appKey
、token
、userId
等数据推测是用于鉴权,每个请求均无太大变化。而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×tamp=' + 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 分析汇编代码,这才是真正的地狱难度。