Appearance
案例 - 贝壳找房 APP 请求头 authorization 分析 #
有一天领导给小明了一个数据采集的需求,要求从贝壳找房 APP 上采集全国的小区数据以及房源数据,小明使用 charles 抓到了一些请求报文,经过不断测试,发现在 headers 中有一个 authorization
字段比较特殊,不携带此字段无法得到正常数据,于是有了这篇文章。
分析请求报文 #
使用 charles + postern 通过 socks5 成功拿到一些请求报文,内容如下
通过 cURL 转换直接转成 Python 代码,经过反复测试 authorization
字段是校验的字段,authorization
与 请求参数 绑定,也就是当我们向通过 limit_offset 参数翻页获取更多数据的时候,如果 authorization
不随着参数的更新那么请求就会失败,无法正常获取数据,失败内容如下
json
{"request_id":"1adf6d93-0fab-42a5-89ee-c27c7ff89507","uniqid":"010A1A01334B9010038A01AD6DA84725","errno":20004,"error":"无效的请求(20004-8ZN)","data":{},"cost":18}
1
分析参数过程 #
使用 jadx-gui
工具直接加载 APK 文件,直接搜索 ,内容如下
为什么要把上面部分给用红框给框起来,那么来看下代码,前部分 netimpl.interceptor
猜测为网络拦截器,后部分 addHeader("Authorization", signRequest(newBuilder, request))
其中 signRequest
根据其名可以大概猜出是把 request
请求加签名。
那么直接跟到 signRequest
方法,内容如下
可以看到不光除了 sigeRequest
,还有 signPostFormBody
、signPostMultipartBody
,根据名字来看,不同的情况加签的算法还不一样。
光猜是不行的,需要验证,那么接下来使用 Frida
来验证。
bkapp.js
文件内容如下
js
// package: com.lianjia.beike
function main() {
Java.perform(function () {
let d = Java.use("com.bk.base.netimpl.interceptor.d");
d["signRequest"].implementation = function (builder, request) {
console.log(`d.signRequest is called: builder=${builder}, request=${request}`);
let result = this["signRequest"](builder, request);
console.log(`d.signRequest result=${result}`);
return result;
};
});
}
setTimeout(main, 0)
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
通过 frida -U -f com.lianjia.beike -l bkapp.js
启动,果然地方没错,输出结果如下
这里有个坑要说明下,本次的环境为 Pixel 5(Android 13),Python3.10.0,刚开始使用的 Frida 15.2.2 版本,会报错 Error: getPackageInfoNoCheck(): has more than one overload,于是更换了 Frida 16.1.13。
接下来仔细分析下 signRequest
这个方法的代码。
java
if ("GET".equalsIgnoreCase(request.method())) {
return com.bk.base.netimpl.a.ji().getSignString(uri, hashMap);
}
1
2
3
2
3
获取了请求方法,如果是 GET
的话,调用 getSignString
传入 uri
、hashMap
获取最终签名结果。
java
if ("POST".equalsIgnoreCase(request.method())) {
if (request.body() instanceof FormBody) {
return signPostFormBody(builder, request);
}
if (request.body() instanceof MultipartBody) {
return signPostMultipartBody(builder, request);
}
return com.bk.base.netimpl.a.ji().getSignString(uri, null);
}
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
当请求方法为 POST
的时候,分别判断请求数据是 FormBody
表单还是 MultipartBody
文件,当是表单数据的时候调用 signPostFormBody
,如果是文件数据,则调用 signPostMultipartBody
,不过最终都是调用 getSignString
来获取最终签名。
通过阅读代码有了以下结论
- 最终签名都是通过调用
getSignString(url, hashMap)
得到结果; - 当请求方法为
GET
时,直接调用getSignString()
获取签名; - 当请求方法为
POST
时,还要判断是表单数据
还是文件数据
,然后把数据全部通过hashMap
来存储,最终调用setSignString()
获取签名;
继续研究 getSignString()
方法,双击进去。
java
Map<String, String> urlParams = getUrlParams(str);
HashMap hashMap = new HashMap();
if (urlParams != null) {
hashMap.putAll(urlParams);
}
if (map2 != null) {
hashMap.putAll(map2);
}
ArrayList arrayList = new ArrayList(hashMap.entrySet());
Collections.sort(arrayList, new Comparator<Map.Entry<String, String>>() { // from class: com.bk.base.netimpl.a.1
@Override // java.util.Comparator
public int compare(Map.Entry<String, String> entry, Map.Entry<String, String> entry2) {
return entry.getKey().compareTo(entry2.getKey());
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
以上代码主要的功能就是解析 URL 的参数,然后创建了一个空的 HashMap,判断 URL 的参数是否为空,如果不为空则添加到 HashMap 中,然后还有判断 map2(也就是第二个参数),如果不为空也加入到 HashMap 中。
然后将 HashMap 转为 ArrayList,并按照键的字母顺序进行排序。
java
String httpAppSecret = ModuleRouterApi.MainRouterApi.getHttpAppSecret();
boolean notEmpty = a.e.notEmpty(httpAppSecret);
String str2 = BuildConfig.FLAVOR;
if (!notEmpty) {
try {
httpAppSecret = JniClient.GetAppSecret(com.bk.base.config.a.getContext());
} catch (Exception e) {
e.printStackTrace();
httpAppSecret = BuildConfig.FLAVOR;
}
}
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
以上代码是获取 appSecret
存储到 httpAppSecret 变量中去,然后判断是否成功获取到,否则从其他方式获取。
java
String httpAppId = ModuleRouterApi.MainRouterApi.getHttpAppId();
if (a.e.notEmpty(httpAppId)) {
str2 = httpAppId;
} else {
try {
str2 = JniClient.GetAppId(com.bk.base.config.a.getContext());
} catch (Exception e2) {
e2.printStackTrace();
}
}
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
获取 appId
,同获取 appSecret
一样,就不讲了。
java
StringBuilder sb = new StringBuilder(httpAppSecret);
1
创建 StringBuilder
并将 appSecert
作为参数传入。
java
for (int i = 0; i < arrayList.size(); i++) {
Map.Entry entry = (Map.Entry) arrayList.get(i);
sb.append(((String) entry.getKey()) + "=" + ((String) entry.getValue()));
}
1
2
3
4
2
3
4
遍历排序后的参数列表,将键值对添加到 StringBuilder
。
java
String SHA1ToString = DeviceUtil.SHA1ToString(sb.toString());
String encodeToString = Base64.encodeToString((str2 + ":" + SHA1ToString).getBytes(), 2);
1
2
2
首先通过 DeviceUtil.SHA1TOString()
对 StringBuilder.toString()
进行 SHA-1
计算,然后将 appId
与 SHA-1
哈希值进行拼接,最终进行 Base64
编码返回。
接下来 Hook 下 DeviceUtil.SHA1ToString
这个函数,输出一些内容
可以看到 DeviceUtil.SHA1ToString
到参数是一串参数拼接的字符串,最终返回一串哈希值,为了看的更明朗一些,继续 Hook getSignString()
获取到所有参数。
DeviceUtil.SHA1ToString
方法的入参为 d5e343d453aecca8b14b2dc687c381cacity_id=310000feed_query_id=010A1A0136CB0EAE078A01AE2256CB8Dhome_ab_group=Eis_first_entry=0latitude=31.238115longitude=121.461945page=2tab_id=summary
下面来分解下
d5e343d453aecca8b14b2dc687c381ca
是由 StringBuilder sb = new StringBuilder(httpAppSecret);
产生。
cacity_id=310000feed_query_id=010A1A0136CB0EAE078A01AE2256CB8Dhome_ab_group=Eis_first_entry=0latitude=31.238115longitude=121.461945page=2tab_id=summary
是所有参数拼接而成。
DeviceUtil.SHA1ToString()
返回一个哈希值(记做 h1),最终签名的计算公式为 Base64.encodeToString((appId + ":" + h1).getBytes(), 2);
。
获取 appid
,既然知道是 base64 编码,那么找一个 authorization
直接解码,解码内容如下 20180111_android:d4e0f15de0c306a4840dded3ba506c8f655f9f69,那么得出 appId
= 20180111_android,SHA1ToString
的结果为 d4e0f15de0c306a4840dded3ba506c8f655f9f69。
综上所述,最终签名的逻辑如下:
base64(appid + ":" + sha1(secret+params))
使用 Python 生成签名 #
通过上述的参数分析过程, 我们需要使用 Python 来实现相同的逻辑,首先梳理下生成 authorization
的流程
- 确认
appid
和appSecret
的值,分别是20180111_android
和d5e343d453aecca8b14b2dc687c381ca
; - 获取请求参数,如果是
GET
请求,那么就是 URL 参数,如果是POST
请求,那么就再包含FormData
,统统放到一个字典中; - 对第 2 步的字典按照字母升序进行排序;
- 拼接待
sha1
的字符串,appSecret
+ 字典[key] + "=" +字典[value],; - 拼接待
base64
的字符串,appid
+ ":" + 第 4 步的结果; - 对第 5 步的字符串进行
base64
编码即可。
以下是代码
python
def generate_authorization(params):
appid = '20180111_android'
hash_str = secret = 'd5e343d453aecca8b14b2dc687c381ca'
# 参数排序
params = sorted(params.items(), key=lambda x: x[0])
# 拼接参数
for param in params:
hash_str += param[0] + '=' + param[1]
# 计算 hash
hash_value = sha1(hash_str.encode('utf-8')).hexdigest()
# 拼接 appid
base64_str = appid + ':' + hash_value
# 进行 base64 编码
base_value = base64.b64encode(base64_str.encode('utf-8')).decode('utf-8')
print(base_value)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
总结 #
- 关于本次实验的环境做一下总结
- 真机设备:Pixel 5 (Android 13)
- Python 版本:Python 3.9.5
- Frida 版本:Frida 16.1.13、Frida-Tools 12.2.1
- 贝壳找房 APP 版本:2.66.0
- Jadx 版本:1.4.7
- 当使用 Frida 产生各种错误的时候,就更换版本,不要钻牛角尖(原来用的 15.2.2),一直报错:Error: getPackageInfoNoCheck(): has more than one overload...
- authorization的生成逻辑为:对参数进行字母升序排序,然后拼接,最前面是 appSecret,最终前面加上 appid: 进行 base64 编码;
- 在 Hook DeviceUtil 的时候发现怎么死活都 Hook 不上,需要在 setTimeout 增加 2000 ms 的延迟即可。
附录 #
完整的 Hook 代码 #
js
// package: com.lianjia.beike
function main() {
Java.perform(function () {
// 最初找到的获取签名的地方
let d = Java.use("com.bk.base.netimpl.interceptor.d");
d["signRequest"].implementation = function (builder, request) {
console.log(`d.signRequest is called: builder=${builder}, request=${request}`);
let result = this["signRequest"](builder, request);
console.log(`d.signRequest result=${result}`);
return result;
};
// hook getSignString 获取参数
let a = Java.use("com.bk.base.netimpl.a");
a["getSignString"].implementation = function (str, map2) {
// 把 map 遍历拼接
let itor = map2.keySet().iterator();
let map2Str = "";
while (itor.hasNext()) {
let keyStr = itor.next().toString();
let valueStr = map2.get(keyStr).toString();
map2Str += keyStr + "=" + valueStr + ";"
}
console.log(`a.getSignString is called: str=${str}, map2=${map2Str}`);
let result = this["getSignString"](str, map2);
console.log(`a.getSignString result=${result}`);
return result;
};
// 输出日志,查看原始 origin sign
// let LjLogUtil = Java.use("com.bk.base.util.bk.LjLogUtil");
// LjLogUtil["d"].overload('java.lang.String', 'java.lang.String').implementation = function (str, str2) {
// console.log(`LjLogUtil.d is called: str=${str}, str2=${str2}`);
// this["d"](str, str2);
// };
// String SHA1ToString = DeviceUtil.SHA1ToString(sb.toString());
let DeviceUtil = Java.use("com.bk.base.util.bk.DeviceUtil");
DeviceUtil["SHA1ToString"].implementation = function (str) {
console.log(`DeviceUtil.SHA1ToString is called: str=${str}`);
let result = this["SHA1ToString"](str);
console.log(`DeviceUtil.SHA1ToString result=${result}`);
return result;
};
});
}
setTimeout(main, 2000)
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
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