Skip to content
On this page

案例 - 贝壳找房 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}

分析参数过程

使用 jadx-gui 工具直接加载 APK 文件,直接搜索 ,内容如下

为什么要把上面部分给用红框给框起来,那么来看下代码,前部分 netimpl.interceptor 猜测为网络拦截器,后部分 addHeader("Authorization", signRequest(newBuilder, request)) 其中 signRequest 根据其名可以大概猜出是把 request 请求加签名。

那么直接跟到 signRequest 方法,内容如下

可以看到不光除了 sigeRequest,还有 signPostFormBodysignPostMultipartBody ,根据名字来看,不同的情况加签的算法还不一样。

光猜是不行的,需要验证,那么接下来使用 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)

通过 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);
}

获取了请求方法,如果是 GET 的话,调用 getSignString 传入 urihashMap 获取最终签名结果。

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);
}

当请求方法为 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());
    }
});

以上代码主要的功能就是解析 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;
    }
}

以上代码是获取 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();
    }
}

获取 appId,同获取 appSecret 一样,就不讲了。

java
StringBuilder sb = new StringBuilder(httpAppSecret);

创建 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()));
}

遍历排序后的参数列表,将键值对添加到 StringBuilder

java
String SHA1ToString = DeviceUtil.SHA1ToString(sb.toString());
String encodeToString = Base64.encodeToString((str2 + ":" + SHA1ToString).getBytes(), 2);

首先通过 DeviceUtil.SHA1TOString()StringBuilder.toString() 进行 SHA-1 计算,然后将 appIdSHA-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 的流程

  1. 确认 appidappSecret 的值,分别是 20180111_androidd5e343d453aecca8b14b2dc687c381ca
  2. 获取请求参数,如果是 GET 请求,那么就是 URL 参数,如果是 POST 请求,那么就再包含 FormData,统统放到一个字典中;
  3. 对第 2 步的字典按照字母升序进行排序;
  4. 拼接待 sha1 的字符串,appSecret + 字典[key] + "=" +字典[value],;
  5. 拼接待 base64 的字符串,appid + ":" + 第 4 步的结果;
  6. 对第 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. 关于本次实验的环境做一下总结
    • 真机设备: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
  2. 当使用 Frida 产生各种错误的时候,就更换版本,不要钻牛角尖(原来用的 15.2.2),一直报错:Error: getPackageInfoNoCheck(): has more than one overload...
  3. authorization的生成逻辑为:对参数进行字母升序排序,然后拼接,最前面是 appSecret,最终前面加上 appid: 进行 base64 编码;
  4. 在 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)

Released under the MIT License.