【实战】某哩视频app的crack尝试
这个app已经被大哥们玩烂了,我这算是复现吧,随手记录一下,留作以后温习。
目的:
- 分析传输字段的生成方式和作用
- 尝试破解部分VIP功能
从流量分析开始
Fiddler 抓包,前半部分还正常,从点开视频开始app在向localhost:1500
请求,Fiddler 就会返回:502 Fiddler - Connection Failed
。我猜app解析视频的业务逻辑是向本地1500端口启动的服务发出类似以下请求,通过本地服务解析视频地址,或向服务器发出加密解析请求。
GET http://localhost:1500/video?mode=online&video_id=100981&quality=qvga&type=.m3u8 HTTP/1.1
User-Agent: com.xxxxxxxx.other.MyApplication/2.3.1-2820 (Linux;Android 10) ExoPlayerLib/2.11.1
Accept-Encoding: gzip
Host: localhost:1500
Connection: Keep-Alive
手机WIFI代理设置localhost不走代理就可以在代理下看视频了,但是这样你也抓不到上述请求包了。
app启动过程中的流量:
接口 | 请求方式 | 提交内容 | 数据类型 | (猜测)用途 |
---|---|---|---|---|
host_xxxxxxxx.txt | GET | 仅有http头hash/lang/platform/time/userid/usertype/version | 密文(后续已破解) | 可能是返回能正常访问的主机群 |
/speed.html | GET | 同上 | 明文"ok" | 测速 |
/v1/initial | GET | 同上 | 明文 | 新版本检测+首页弹窗+视频分类 |
/v1/register/token | POST | device_id/platform/key/universal_id/lang | 明文 | 注册新用户,返回token和uid |
/v1/user/info | POST | token/lang/download_amount | 明文 | 查询并返回用户信息 |
/v1/firstpurchase | GET | 仅有http头 | 明文 | 买VIP悬浮广告 |
第一次启动app时向服务器请求token样例:eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDMxMTg0OX0.1ea8cfc740dc64f1b0a70adc0357be61.234d8d0043d2b0368285c605f974769d5eea56017ef0fcd116b613cf
稍后再分析token
视频请求流量解密
-
请求视频信息,返回json格式数据中video_url字段包含m3u8视频地址
-
演员信息
-
向服务器请求视频信息,返回数据是加密的,估计这就是m3u8的信息
-
5.都是本地请求,猜测是请求解析具体m3u8视频的数据
上图是具体视频数据信息可以看到是请求m3u8的切片ts文件。
在media/240/xxxx.m3u8?token=…的响应报文中有一个X-VTag将是解密该报文的重要信息之一
a.b.i.a.h.a部分代码:
String a4 = tVar.mo12446a("X-VTag");
if (a4 == null || (i = StringExt.m2783i(a4)) == null) {
str = null;
} else {
str = i.substring(8, 24);
C7509i.m26507a((Object) str, "(this as java.lang.Strin…ing(startIndex, endIndex)");
}
ResponseBody h0Var2 = a.f18837g;
MediaType c = h0Var2 != null ? h0Var2.mo12175c() : null;
if (!(str == null || c == null)) {
ResponseBody a5 = ResponseBody.m17589a(c, EncodeUtility.abaababa(CipherClient.decodeKey(), str, a2));
C5325g0.C5326a aVar2 = new C5325g0.C5326a(a);
aVar2.f18849g = a5;
C5325g0 a6 = aVar2.mo12169a();
C7509i.m26507a((Object) a6, "response.newBuilder().body(body).build()");
return a6;
}
去看一下StringExt.m2783i函数,虽然他虚晃你说他调用AES算法,但是你仔细看看就发现根本不符合AES的调用。再去看看AESEncryptor.HASH_ALGORITHM,就知道他应该是做md5散列的。所以综合这两个函数的分析得到:
变量 | 解析 | 值 |
---|---|---|
a4 | headers中的X-VTag的值 | 958893970 |
i | md5(a4) | e10adc3949ba59abbe56e057f20f883e |
str | i.substring(8, 24) | 49ba59abbe56e057 |
接着看CipherClient.decodeKey(),你会发现这个类里面全是用常量生成的返回值:
package net.idik.lib.cipher.p620so;
/* renamed from: net.idik.lib.cipher.so.CipherClient */
public final class CipherClient {
public CipherClient() throws IllegalAccessException {
throw new IllegalAccessException();
}
public static final String apiHashKey() {
return CipherCore.get("d708e111b5db90af74ef84ff4d5e647b");
}
public static final String decodeKey() {
return CipherCore.get("aa01bdd83d0f12833ddaea2f2af22865");
}
public static final String encodeType() {
return CipherCore.get("e82b6153b4ea2340333e2254c3553d03");
}
public static final String everIv() {
return CipherCore.get("c5b287eb7ee64e90ed015bac470f4b6b");
}
...
}
追踪下去发现追到native程序里了:CipherClient.decodeKey()–>CipherCore.get()–>native CipherCore.getString()
native库为:cipher-lib
不太想看native逻辑了,为了快速省事就用frida hook返回值看看:
function main(){
Java.perform(function(){
var CipherCore = Java.use("net.idik.lib.cipher.so.CipherCore")
var stringClass = Java.use("java.lang.String")
var str = stringClass.$new("aa01bdd83d0f12833ddaea2f2af22865")
var res = CipherCore.get(str)
console.log("getString("+str+"):"+res)
})
}
setImmediate(main);
通过上述方式我们可以把每个CipherClient方法的返回值都得到:
函数 | 常量 | 获取到的结果 |
---|---|---|
apiHashKey | d708e111b5db90af74ef84ff4d5e647b | 666wInteriscommingyoUshouldNotpassmotherfuckeR= |
ccToken | 810d903a88254b27c643e0bc471d406a | 2cfbf0f5d358406b96ab9fd1aa59ae89 |
decodeKey | aa01bdd83d0f12833ddaea2f2af22865 | 6e561ccd4aade2fed458d4da61e76770 |
encodeType | e82b6153b4ea2340333e2254c3553d03 | app |
everIv | c5b287eb7ee64e90ed015bac470f4b6b | BakinsodaIgotbakinsoda |
everKey | 0d7cb519eb483a597549f7f466b189bc | iaMiNloveWithtHecoCo |
hostIv | 6cf9e96524081ac264dc31982d0be319 | 5e8bf1f958f56644 |
hostKey | 51cdba173d412fdecec3e78572cde731 | f332ae8214fcbb0d98f8626f123459b4 |
imageDecodeIv | 9e1add49a87568f90c43e418e7370287 | E01EDE6331D37AFCC7BE05597D654D22 |
imageDecodeKey | a8ae2831cbea74111bc5116ba81ec191 | B2F3842866F9583D1ECE61C4E055C255 |
registerKey | 951eeb6b19b70177fd25706aa620edcf | ggh%*KXOqk882jO&Z3Sz43dQGTfD4y6SC&9z |
我们再继续回去看a.b.i.a.h.a,现在就知道EncodeUtility.abaababa(CipherClient.decodeKey(), str, a2)
应该是:
EncodeUtility.abaababa("6e561ccd4aade2fed458d4da61e76770", "49ba59abbe56e057", a2)
猜测 a2 应该是密文
EncodeUtility.abaababa 是 a.b.j.v3.a 的别名,阿巴阿巴是我不小心起的,忘记原来jadx给的命名是啥了。其定义如下
/* renamed from: a */
public static String abaababa(String str, String str2, String str3) throws UnsupportedEncodingException, NoSuchAlgorithmException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException {
return m3078a(new IvParameterSpec(str2.getBytes("UTF-8")), new SecretKeySpec(m3076a(str).getBytes("UTF-8"), "AES"), str3);
}
m3078a方法,看一下它的声明就知道,这个函数是AES解密,该函数中有声明AES模式:AESEncryptor.AES_MODE=="AES/CBC/PKCS5Padding"
,这里就不展示了。
m3076a方法,同样看下声明也容易知道是MD5散列。
现在基本就确定了abaababa方法最后一个参数就应该是密文,参数2是偏移,参数1的md5值是密钥,执行的是AES解密。
md5('6e561ccd4aade2fed458d4da61e76770')=ae52f7ffd2dd66ba5743bb180188b991
综上所述:
GET请求 /media/240/xxxxxx.m3u8?token=…向服务器请求视频信息,返回数据是是m3u8的信息,且经过AES加密的
AES模式CBC,padding模式PKCS5Padding
KEY:ae52f7ffd2dd66ba5743bb180188b991
IV:49ba59abbe56e057
解密成功。
到这里视频请求信息的解密就完成了,我们来复盘一下思考下开发者是怎么考虑视频信息加密的:
每个视频都有独特的x-vtag,md5(x-vtag).substring(8,24) 作为AES偏移量IV,app的java代码中的一个常量通过native 代码得到另一个常量作为AES的密钥KEY。KEY服务器和客户端都知道,x-vtag服务器明文发给客户端。服务端通过AES加密m3u8信息,客户端AES解密,获取m3u8信息。
视频请求步骤:
- 用户向服务器A请求某个视频的信息:
/v1/video/info/<视频id>?token=...
得到JSON格式视频信息,包括演员,类别,视频TAG,封面图片连接,video_url 等信息 - 用户向服务器B请求某个m3u8视频的媒体信息,就是使用上一步得到的video_url:
/media/240/100981.m3u8?token=...
客户端得到AES加密的m3u8视频的媒体信息 - APP解密上述信息得到视频的流媒体源,向本地请求:localhost:1500/video?mode=online&video_id=100981&quality=qvga&type=.m3u8 这一步真正的意义现在还没弄懂
- 根据m3u8解码得到的分片信息,向存储视频数据的服务器请求流媒体
总的来说到这里就分析完了m3u8视频获取的流程和机密视频信息的方法了,但是要通过m3u8信息想获取视频你需要附加请求头。
m3u8请求头
发现在请求m3u8密文的时候会经常提示拒绝访问access denied
看一下请求头中的参数
参数 | 值 | 来源 |
---|---|---|
version | 2.3.1 | app版本 |
platform | Android | 安卓平台 |
time | 1614354694 | 当前十位时间戳 |
userid | YaoIcnqMTOWO | 每次不一,可能是随机值 |
hash | e6195dd2a9cf277f480f797222823542 | 待探索 |
jadx中搜一下"hash"字符串,就能找到其定义,在a.b.a.v.f.e()中:
String i = StringExt.m2783i(a + value + valueOf + str + str2);
之前我们已经得出StringExt.m2783i是md5的操作。
依次看a + value + valueOf + str + str2
a = version的值 = APP版本号
value = platform的值 = 平台Android
valueOf = time的值 = 时间戳
str = userId的值 = 从"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"取随机字符
str2 = CipherClient.apiHashKey() = "666wInteriscommingyoUshouldNotpassmotherfuckeR="
所以i = md5(app版本 + 平台Android + 时间戳 + 随机userId + "666wInteriscommingyoUshouldNotpassmotherfuckeR=")
上述案例中 i = md5("2.3.1Android1614354694YaoIcnqMTOWO666wInteriscommingyoUshouldNotpassmotherfuckeR=") = "e6195dd2a9cf277f480f797222823542"
另外,这个hash在其他报头也出现了,生成方式应该都是一样的。
Host信息decrypt
还记得最开始流量分析的时候,APP启动时有请求host_xxxxxxxx.txt这个文件吗,我们已经获得了hostIV和hostKey,可以AES解密该txt文件,其中包含各类业务的domain url:
0xxxxxxx6dd4x26xxxxf0axxfx8{
"api": [
{
"url": "https://xxxxxxxxxx"
],
"img": [
{
"url": "https://xxxxxxxxxxxx"
},
],
"stream": [
{
"url": "https://xxxxxxxxxxxxx"
},
],
"cash":[
{
"url": "https://xxxxxxxxxx"
},
],
"pwaredirect": [
{
"url": "https://xxxxxxxx"
}
]}50xxxxxxxxxxxxxxxxxxxxxdc
VIP?
据大佬分析,修改两个函数就可以在本地实现vip,可以使用倍速和切换线路,但是高清播放这种功能大概是有云端验证所以没办法。具体来说需要修改:getExpiry()和getLevel()两个函数
我使用frida做hook验证了,确实可以,脚本和命令如下:
frida -U -f com.xxxxxxxx --no-paus -l .\hookvip.js
//打印参数、返回值
function main(){
Java.perform(function(){
Java.use("xxx.xxxxxxxx.model.response.ResponseUserInfo").getExpiry.overload().implementation = function (){
console.log("[+]function getExpiry() was called");
return 0x746a6480;
}
Java.use("xxx.xxxxxxxx.model.response.ResponseUserInfo").getLevel.overload().implementation = function (){
console.log("[+]function getLevel() was called");
return 2;
}
})
}
setImmediate(main);
另外提一嘴,前段时间这个应用的web端程序,如果在请求token的时候修改提交的参数pwa-ckv=1,则会返回一个尊容vip的token。这个bug已经被修复了。
Rigister请求字段生成
多次抓注册请求报文:
{"device_id":"ce793c3e-e720-4e0d-9e56-fddcd033d862","platform":"Android","key":"3d5f7885e4d45c17c547a0f2bc9d499a3799ce69d616f8504091df86e55d360a","universal_id":"884885A46B10364A5AF184395FF4DD93","lang":"cn"}
token=eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDY2MTAzN30.49f3f134c8f1d86478c5dd6cb0fbaeb8.7caa99b911ce29125339244676fd7c17a713f7117502dbc6948bc56c
{"device_id":"a591b897-0c35-4514-9a69-2c61ad40e812","platform":"Android","key":"631a809db87dae8c038ca63252cc3c67b0755a7c2a23ea180dca7deba768c3c0","universal_id":"884885A46B10364A5AF184395FF4DD93","lang":"cn"}
token=eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDY2MzQ0MX0.aae1a8f7a517e7802e00b36832a98475.fe497a9da7ae1867dcc50f151305d2989d3e7e7ca670b0e076226d2f
{"device_id":"8b12ee47-199a-4f0d-9937-1509d84eac3f","platform":"Android","key":"fde31433a815946f4debbd4d961d3c23142b5b41a60367f9bab7055afc988de2","universal_id":"884885A46B10364A5AF184395FF4DD93","lang":"cn"}
token=eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDY2MzQ1NH0.45689a362d7207a94dfd58ccebddfe66.979a33cbcf7f91cb671a830fc21c941c156a523b3b17efae97ce410b
下面先来研究一下注册请求中各个字段的生成,具体代码应该是在 a.b.h.b.m.toString()
public String toString() {
StringBuilder a = outline.m3774a("RegisterTokenBody(deviceId=");
a.append(this.f2668a);
a.append(", platform=");
a.append(this.f2669b);
a.append(", key=");
a.append(this.f2670c);
a.append(", universalId=");
a.append(this.f2671d);
a.append(", lang=");
a.append(this.f2672e);
a.append(", unicode=");
a.append(this.f2673f);
a.append(", utmSource=");
a.append(this.f2674g);
a.append(", utmMedium=");
return outline.m3771a(a, this.f2675h, ")");
}
很容易可以找到deviceId、key、universalId的生成函数:a.b.h.a.e0中的a()、b()、c()
public final String a() {
a a2 = g.a(g.f1311a, "app_uuid", "", null, null, 12);
boolean z2 = false;
i<?> iVar = b[0];
m mVar = (m) a2;
if (((CharSequence) mVar.a((Object) null, iVar)).length() == 0) {
z2 = true;
}
if (!z2) {
return (String) mVar.a((Object) null, iVar);
}
String uuid = UUID.randomUUID().toString();//random???
z.t.c.i.a((Object) uuid, "UUID.randomUUID().toString()");//验证uuid非空
mVar.a(null, iVar, uuid);//存储uuid->/data/data/com.xxxxxxxx/shared_prefs/com.xxxxxxxx_preferences.xml
return (String) mVar.a((Object) null, iVar);
}
public final String b() {
String a2 = a();//取device_id
if (a2 != null) {
try {
MessageDigest instance = MessageDigest.getInstance("SHA-256");
String str = "jav" + a2 + "jav";//jav<device_id>jav
Charset charset = StandardCharsets.UTF_8;
z.t.c.i.a((Object) charset, "StandardCharsets.UTF_8");
if (str != null) {
byte[] bytes = str.getBytes(charset);
z.t.c.i.a((Object) bytes, "(this as java.lang.String).getBytes(charset)");
byte[] digest = instance.digest(bytes);//sha-256(jav<device_id>jav)
z.t.c.i.a((Object) digest, "hash");//验证摘要信息非空
return f.a(digest);//摘要格式化为小写
}
throw new k("null cannot be cast to non-null type java.lang.String");
} catch (Exception e) {
e.printStackTrace();
return null;
}
} else {
z.t.c.i.a("$this$toHashKey256");
throw null;
}
}
@SuppressLint({"HardwareIds", "DefaultLocale"})
public final String c() {
boolean z2;
String str;
String str2 = Build.SERIAL;//
if (str2 == null) {
z2 = false;
} else {
z2 = str2.equalsIgnoreCase("unknown");
}
if (!z2) {//1.如果Build.SERIAL取到的值不是unknow的话就可以返回
return str2;
}
String str3 = null;
try {//2.好像是从google Advertising Id中取值
AdvertisingIdClient.Info advertisingIdInfo = AdvertisingIdClient.getAdvertisingIdInfo(this.f1363a);
z.t.c.i.a((Object) advertisingIdInfo, "AdvertisingIdClient.getAdvertisingIdInfo(context)");
str = advertisingIdInfo.getId();
} catch (Exception e) {
e.printStackTrace();
str = null;
}
if (str != null) {
String i = f.i(str + "com.xxxxxxxx");
if (i != null) {
String upperCase = i.toUpperCase();
z.t.c.i.a((Object) upperCase, "(this as java.lang.String).toUpperCase()");
return upperCase;
}
throw new k("null cannot be cast to non-null type java.lang.String");
}
try {//3.取Settings.Secure.getString(ContentResolver resolver, "android_id")
str3 = Settings.Secure.getString(this.f1363a.getContentResolver(), com.umeng.commonsdk.statistics.idtracking.b.f7127a);
} catch (Exception e2) {
e2.printStackTrace();
}
if (str3 != null) {
String i2 = f.i(str3 + "com.xxxxxxxx");
if (i2 != null) {
String upperCase2 = i2.toUpperCase();
z.t.c.i.a((Object) upperCase2, "(this as java.lang.String).toUpperCase()");
return upperCase2;
}
throw new k("null cannot be cast to non-null type java.lang.String");
}
String i3 = f.i(a());//取device_id
if (i3 != null) {//4.md5(device_id)
String upperCase3 = i3.toUpperCase();
z.t.c.i.a((Object) upperCase3, "(this as java.lang.String).toUpperCase()");
return upperCase3;
}
throw new k("null cannot be cast to non-null type java.lang.String");
}
看起来uuid(device_id)是获取random值,存储在shared_prefs/com.xxxxxxxx_preferences.xml
中,而key=sha-256(“jav”+device_id+“jav”)
关于universalId的生成,其实有几种不同的可能:
-
android.os.Build.SERIAL:API level 9 添加的,在 Android O 中这个字段被置为"unknown",需要使用
android.os.Build.getSerial()
方法来获取,且需要android.permission.READ_PHONE_STATE
权限 -
AdvertisingIdClient.getAdvertisingIdInfo(this.context).getId()
-
Settings.Secure.getString(ContentResolver resolver, “android_id”)
-
md5(deviceID):应该不是这个如果用这个方法,每次universalId应该随着device_id变化
我通过frida hook主动调用尝试了几种universalId的获取方法
关于1:
function main(){
Java.perform(function(){
var build = Java.use("android.os.Build")
var res = build.getSerial()
console.log(res)
})
}
setImmediate(main);
//return:unknown
关于2:
function getContext() {
return Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
}
function main(){
Java.perform(function(){
var AdvertisingIdCliente = Java.use("com.google.android.gms.ads.identifier.AdvertisingIdClient")
var advertisingIdCliente = AdvertisingIdCliente.getAdvertisingIdInfo(getContext())
var id = advertisingIdCliente.getId()
console.log(id)
})
}
setImmediate(main);
//return:1764c41f-a2b5-4c87-aa34-ed53cc30aac3
关于3:
function getContext() {
return Java.use('android.app.ActivityThread').currentApplication().getApplicationContext().getContentResolver();
}
function logAndroidId() {
console.log('[+]', Java.use('android.provider.Settings$Secure').getString(getContext(), 'android_id'));
}
Java.perform(logAndroidId)
//return:00acc23791dcfe9d
现在我们破案了:
字段 | 说明 | 备注 |
---|---|---|
device_id | random值 | 生成后存储在:shared_prefs/com.xxxxxxxx_preferences.xml |
platform | “Android” | 固定明文 |
key | sha-256(“jav”+device_id+“jav”) | |
universal_id | AdvertisingIdClient.getAdvertisingIdInfo(this.context).getId() | |
lang | “cn” | 固定明文 |
备注e0.a()、e0.b()、e0.c()的frida主动调用代码:
function main(){
Java.perform(function(){
var e0 = Java.use("a.b.h.a.e0")
var inse = e0.$new()
var res = inse.c()
console.log(res)
})
}
setImmediate(main);
//return:884885A46B10364A5AF184395FF4DD93
另外,在注册请求的返回值中的token,这个字符串的中间一段是md5结果,其明文应该是9-10位的数字串,但具体生成方式只有服务端知道。
eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDMxMTg0OX0.1ea8cfc740dc64f1b0a70adc0357be61.234...
md5(394171256) == 1ea8cfc740dc64f1b0a70adc0357be61
eyJ1c2VyX2lkIjo0NDk5NzQ3MjYsImxhc3Rsb2dpbiI6MTYxNDY2MTAzN30.49f3f134c8f1d86478c5dd6cb0fbaeb8.7ca...
md5(1167890769) == 49f3f134c8f1d86478c5dd6cb0fbaeb8
ref:
https://www.52pojie.cn/thread-1375881-1-1.html
https://cloud.tencent.com/developer/news/746067
Android唯一标识:https://daemonyang.blogspot.com/2017/06/android.html