2

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

 2 years ago
source link: https://blog.skyju.cc/post/didi-reverse-engineering-bike-crawler/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
技术

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

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

Sep 06, 2022   By  居正

阅读时长: 9 分钟

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

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

7e1d9496c8e390aa.png

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

一是请求query string部分:

/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部分:

{"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

06cd4d2b0933c717.png

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

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

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

1aca67df4eebc0f1.png

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

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

查找到如下代码:

@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就是附近单车接口被封装成的请求类,我们全局搜索这个关键词。

查找到这样的调用代码:

11823b745c47d076.png

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

8859dd184cc6e9fa.png

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

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

KopService接口:

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中还有一个特殊的字符串:

Host: htwkop.xiaojukeji.com

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

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作为参数:

/* 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这个函数中有这样的代码,将两段盐值提取出来:

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

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

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在下面这个函数用到:

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这个函数:

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这个函数,定位到下面的这些语句:

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;                                                           

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

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,进行签名:

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中有其他元素没有被签名进去。

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

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函数加入以下行:

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接口,抓取范围内单车和停车点数据,将它们插入到数据库而已。

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分析汇编代码,这才是真正的地狱难度。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK