【实战】某哩视频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

视频请求流量解密

image-20210226151240389

  1. 请求视频信息,返回json格式数据中video_url字段包含m3u8视频地址

  2. 演员信息

  3. 向服务器请求视频信息,返回数据是加密的,估计这就是m3u8的信息

  4. 5.都是本地请求,猜测是请求解析具体m3u8视频的数据

    image-20210226234651298上图是具体视频数据信息可以看到是请求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);

image-20210226163204315

通过上述方式我们可以把每个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

image-20210226165417396

解密成功。

到这里视频请求信息的解密就完成了,我们来复盘一下思考下开发者是怎么考虑视频信息加密的:

每个视频都有独特的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()中:

image-20210226235912062

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的生成,其实有几种不同的可能:

  1. android.os.Build.SERIAL:API level 9 添加的,在 Android O 中这个字段被置为"unknown",需要使用android.os.Build.getSerial()方法来获取,且需要android.permission.READ_PHONE_STATE权限

  2. AdvertisingIdClient.getAdvertisingIdInfo(this.context).getId()

  3. Settings.Secure.getString(ContentResolver resolver, “android_id”)

  4. 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

image-20210302160002678

另外,在注册请求的返回值中的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