1、申 请 I D:KAAAsS
2、个人邮箱:admin@kaaass.net
3、原创技术文章:
PicaComic接口分析手记
好久没有更新技术类文章了,不过其实我也有慢慢在写几篇文章,然而它们依旧躺在草稿箱。刚好群里讨论写个Pica客户端,于是我就来分析下Pica的接口吧。
拆开来一看,竟然没有混淆……build.gradle改改也没多大成本吧,虽然给我省事就是了。
抓包得知,Pica的接口使用signature头以校验。所以首先要找出signature的计算方法。由于,Pica采用了okhttp3,所以signature的计算逻辑八成会写在interceptor里。搜索addInterceptor,果然只有一处,出现在com.picacomic.fregata.networks.RestClient的构造器里。
// 局部变量名称与部分逻辑有所调整
Request request = chain.request();
String nonce = UUID.randomUUID().toString().replace("-", "");
String path = request.url().toString().replace("https://picaapi.picacomic.com/", "");
String timeStamp = System.currentTimeMillis() / 1000L + PreferenceHelper.getTimeDifference(var1) + "";
String signature = MyApplication.getInstance().getStringCon(new String[]{"https://picaapi.picacomic.com/", path, timeStamp, nonce, request.method(), "C69BAF41DA5ABD1FFEDC6D2FEA56B", RestClient.version, RestClient.buildVersion});
于是顺藤摸瓜,看com.picacomic.fregata.MyApplication的getStringCon方法。
public String getStringCon(String[] strs) {
if (this.generateSignature == null) {
this.generateSignature = new GenerateSignature();
}
String rawParams = "";
for (String str : strs) {
rawParams += str + ", ";
}
PrintLog.PrintErrorLog(TAG, "RAW parameters = " + rawParams);
String concatParam = this.getStringConFromNative(strs);
PrintLog.PrintErrorLog(TAG, "CONCAT parameters = " + concatParam);
String concatKey = this.getStringSigFromNative();
PrintLog.PrintErrorLog(TAG, "CONCAT KEY = " + concatKey);
return this.generateSignature.getSignature(concatParam, concatKey);
}
getStringConFromNative和getStringSigFromNative两个方法都是native的,那暂且放一遍,先把Java层的getSignature看完。不过逻辑其实也不难猜到,就是转小写后HmacSHA256。所以重点还应该是在native层。不过有点挺有趣的,getStringSigFromNative很好理解,但getStringConFromNative怎么看都像是拼接字符串。在这上面做文章还是没怎么见过,有意思。
lib名是libJniTest,很优秀。丢进IDA,很顺利。找到函数,很顺利,毕竟静态注册。F5,依旧很顺利。嘛,看到lib名也应该能想到的。首先分析Java_com_picacomic_fregata_MyApplication_getStringConFromNative,依旧是当初分析B站App的套路,改类型、重命名,然后分析逻辑。其实也什么好分析,首先从数组中取出对应下标的字符串然后GetStringUTFChars,然后就是主要的拼接逻辑。
!([img]https://static.kaaass.net/wp-content/uploads/2019/02/QQ%E6%88%AA%E5%9B%BE20190202203249.png?watermark/1/image/aHR0cHM6Ly9ibG9nLmthYWFzcy5uZXQvd3AtY29udGVudC91cGxvYWRzLzIwMTYvMDQvd2F0ZXJtYXNrLnBuZw==/dissolve/100/gravity/SouthEast/dx/5/dy/5)
拼接很直接,就是这个用于判断的repack_chk_and_genKey10(原名genKey10)需要分析一哈。通过分析,发现这个函数是用来校验apk签名的。
!([img]https://static.kaaass.net/wp-content/uploads/2019/02/QQ%E6%88%AA%E5%9B%BE20190202215138-300x119.png?watermark/1/image/aHR0cHM6Ly9ibG9nLmthYWFzcy5uZXQvd3AtY29udGVudC91cGxvYWRzLzIwMTYvMDQvd2F0ZXJtYXNrLnBuZw==/dissolve/100/gravity/SouthEast/dx/5/dy/5)
然而校验失败返回false后,getStringConFromNative依旧可以拼接,而且会变更拼接的方式。至于为什么这么做,笔者暂且被蒙在吉他里。
接下来分析Java_com_picacomic_fregata_MyApplication_getStringSigFromNative。
!([img]https://static.kaaass.net/wp-content/uploads/2019/02/QQ%E6%88%AA%E5%9B%BE20190202215440.png?watermark/1/image/aHR0cHM6Ly9ibG9nLmthYWFzcy5uZXQvd3AtY29udGVudC91cGxvYWRzLzIwMTYvMDQvd2F0ZXJtYXNrLnBuZw==/dissolve/100/gravity/SouthEast/dx/5/dy/5)
...
!([img]https://static.kaaass.net/wp-content/uploads/2019/02/QQ%E6%88%AA%E5%9B%BE20190202215449.png?watermark/1/image/aHR0cHM6Ly9ibG9nLmthYWFzcy5uZXQvd3AtY29udGVudC91cGxvYWRzLzIwMTYvMDQvd2F0ZXJtYXNrLnBuZw==/dissolve/100/gravity/SouthEast/dx/5/dy/5)
心凉半截。看看汇编。
!([img]https://static.kaaass.net/wp-content/uploads/2019/02/QQ%E6%88%AA%E5%9B%BE20190202215711-188x300.png?watermark/1/image/aHR0cHM6Ly9ibG9nLmthYWFzcy5uZXQvd3AtY29udGVudC91cGxvYWRzLzIwMTYvMDQvd2F0ZXJtYXNrLnBuZw==/dissolve/100/gravity/SouthEast/dx/5/dy/5)
没办法,按照esp的偏移慢慢算咯。当然傻傻的一个个字符改也确实没效率,所以简单处理一下数据,写个py jio本。
char_dic = {0x9: 110, 0x2D: 107, 0x37: 97, 0x3B: 107, 0x40: 114, 0x46: 114, 0xC: 83, 0x14: 85, 0x19: 76, 0x1B: 82, 0x27: 67,
0x28: 69, 0x2C: 75, 0x33: 90, 0x45: 67, 0xD: 57, 0x16: 56, 0x1F: 57, 0x21: 52, 0x23: 51, 0x2F: 55, 0x38: 53, 0x42: 49}
int_dic = {0x17: "zf", 0x29: "sl", 0x39: "zk", 0x1D: "PM", 0x31: "BY",
0x35: "BA", 0x0F: "lG", 0x11: "ts", 0x3C: "RB", 0x3E: "L7"}
if __name__ == "__main__":
offset = 0x8
org_str = "~*}$#,$-\").=$)\",,#/-.'%(;$[,|@/&(#\"~%*!-?*\"-:*!!*,$\"%.&'*|%/*,*"
ls = list(org_str)
for pos, ch in char_dic.items():
ls[pos - offset] = chr(ch)
for pos, ch in int_dic.items():
ls[pos - offset] = ch[0]
ls[pos - offset + 1] = ch[1]
print("".join(ls))
这放飞自我的编码风格……嘛,总之算是跑出了结果。另外,校验失败同样会返回另一个key。
简单汇总一下signature的计算方法:
- 拼接请求路径(不包含/)+当前时间戳+nonce+请求方法+
- 转小写
- 计算其HmacSHA256,密钥为”~n}$S9$lGts=U)8zfL/R.PM9;4[3|@/CEsl~Kk!7?BYZ:BAa5zkkRBL7r|1/*Cr”
其中nonce生成的java实现为UUID.randomUUID().toString().replace(“-“, “”);,常见的随机串生成方式。实现时生成任意32长随机字符串即可。
总体感觉就是有安全意识但是做的很不够。不过讲字符串拼接的逻辑放在native层很有趣,而且native层的处理较B站早期的实现也更完善。改天测试下另一种signature有啥不同。
B站APP逆向工程手记
最近在重写BiliAPI,由于之前采集的接口都是两年前的东西了,所以这次打算再逆向B站客户端。版本使用了官网下载的5.29.1。
可以说比起两年之前,开发者安全意识高了很多。ak、sk对原先是采用令人费解的存储在native层,然后get出来在java层拼接请求地址。后来对sk进行了加密(好像是AES,太久了忘了),但是并没有太大的区别。而如今采用了正确的native层拼接的方式,安全性陡增。java层终于混淆字符串了。
突破口就是native层,顺势就找到了com.bilibili.nativelibrary.LibBili。s方法(descriptor:(Ljava/util/SortedMap;)Lcom.bilibili.nativelibrary.SignedQuery;)简直不要太明显,顺着就找到了bl.idh类。里面一个加通用请求参数一个加请求头的方法直接可以挖掘出很多信息。由于B站App用的是okhttp,而okhttp3包下类多有残缺,说明肯定被混淆了一部分。果然,bl.mhs对应okhttp3.Request、bl.mhm对应okhttp3.Headers……这样一恢复,那整个http请求相关的部分就清楚很多了。
native层也终于有点安全意识了,简单的看了一下,关键几个方法都进行了一定处理。不过并没有做特别的针对IDA的处理。这里看关键的libbili.so。在函数表可以看到一些方法名和descriptor名。
!([img]https://blog.kaaass.net/diary/wp-content/uploads/sites/2/2018/08/QQ%E6%88%AA%E5%9B%BE20180817114624.png)
分析起来有点费劲……毕竟和java字节码不同,而且我对汇编根本只是了解了点皮毛。不过看到一些函数打印的log里有J4A,那就瞬间舒服了。简单说明下,J4A是B站native层开发自己造的轮子,开源的ijkplayer就用的这个。于是,几个J4A的方法就陆续找到了。由此,一些变量的意义也清楚了。
之后主要是把LibBili的getAppkeyByDeviceNative(Obf name: a,descriptor: (Ljava/lang/String;)Ljava/lang/String;)和appendSignNative(Obf name: s,descriptor: (Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery;)俩方法搞清楚。另外一个AES有关的java层还没看是干啥用的,暂时鸽着。
getAppkeyByDeviceNative比较好分析,主逻辑在sub_2f60。没啥复杂的,就是字符串对比然后返回对应的appkey,唯一的重点就是知道了几个可用的device:android、android_i、android_b、android_tv、biliLink。
appendSignNative比较复杂,主逻辑在sub_27a0。先是一堆无关紧要的参数验证,然后是核心的sign计算。首先调用SignedQuery的静态方法mapParamToString(dword_C0AC)拼接参数,然后调用sub_2600获取appkey的类型。sub_2600接受appkey,返回整数0-4(对应上面的5个device)。
之后将返回的整数作为数组下标,访问存储secretkey的dword_7980、dword_7994、dword_79A8、dword_79BC。最后把内容拼接在之前拼接的参数之后,整体计算MD5(sub_2070),然后new一个SignedQuery对象并返回。
secretkey分别存储于数组dword_7980、dword_7994、dword_79A8、dword_79BC。
于是就可以拼出5个ak、sk对了:
Device: android
Description: 普通版
AppKey: 1d8b6e7d45233436
SecretKey: 560c52ccd288fed045859ed18bffd973
Device: android_i
Description: 国际版
AppKey: bb3101000e232e27
SecretKey: 36efcfed79309338ced0380abd824ac1
Device: android_b
Description: 概念版
AppKey: 07da50c9a0bf829f
SecretKey: 25bdede4e1581c836cab73a48790ca6e
Device: android_tv
Description: 电视版
AppKey: 4409e2ce8ffd12b8
SecretKey: 59b43e04ad6965f34319062b478f83dd
Device: biliLink
Description: 直播
AppKey: 37207f2beaebf8d7
SecretKey: e988e794d4d4b6dd43bc0e89d6e90c43
只能说比原先安全了点。总结下ak、sk存储方式的变化:
- (两年前)俩native方法直接get,安全性基本没有。
- (一年前)sk做了RSA(也许是记错了,反正加密了下)加密,安全性有所提升。但是新建个app然后调用下解码方法就行,并没有什么卵用。
- (现在)sign计算写进native层,使用整数数组拆分存储sk。比较安全,然而sk并没有编码保存。
有趣的是,视频接口的ak、sk并不在libbili.so。看来只能继续分析java层了。
java层的分析最后以IMediaResolver入手,找到了几个Resolver:NormalResolver(bl.gmh)、BangumiResolver(bl.gmd)、LiveResolver(bl.gmf)。然后就找到了拼接参数用的ApiRequest(bl.glw),然后就找到了实现ISigner(bl.gmb)的NormalSigner(bl.glx)和VideoSigner(bl.glz)。查看VideoSigner的实现发现调用了VideoSignHelper(bl.gmu)的相关方法进行参数拼接。VideoSignHelper使用了特殊的编码方式来存储视频用ak、sk对,代码如下:
public static String decode(int shift, String encodedStr) {
String result = "";
byte[] encodedBytes = encodedStr.getBytes();
for (byte chr : encodedBytes) {
int var8 = 65 + (chr - 65 + shift) % 57;
int shiftCount = 0;
while (var8 > 90 && var8 < 97) {
shift += shiftCount * shift;
++shiftCount;
var8 = 65 + (chr - 65 + shift) % 57;
System.out.println("t" + shift);
}
result += (char) var8;
}
return result;
}
类似凯撒密码,ak的偏移是3,sk的偏移是9。
另外,本人还有开发维护一个B站接口的第三方请求接口BiliAPI。文档位于:http://docs.kaaass.net/showdoc/web/#/2。