关于4gtv(四季线上) APP 逆向 —— 免费看台湾频道

背景

原因有两

  • 注册吾爱破解需要注册号,如果想免费获得一个,就得拿出一个case. (申请ID: zmhu)
  • 自己想看点台湾节目,丰富一下我的iptv的m3u8列表(这个以后会开源)

打开 4gtv 可以看到,这些频道都是免费的。如果不嫌麻烦,可以直接安装这个APP,也可以通过APP直接看这上面的电视直播。 当然我家电视的都是我整合过的 m3u8 来管理频道列表的,并关联了节目单,所以如果想要把这些台湾频道添加至 m3u8 文件中, 就需要获取直播源地址。

破解

通过抓包,我习惯使用 Charles ,观察到 PC做了不少加密工作,当然比较强大的是有cloudflare的防火墙,动不动就会出现要求验证是human。所以APP是最好的解决方案。

抓包分析

通过 Charles 抓取包 API 调用过程, 因为我手头没有闲置的 Android 手机, 所以这里使用了iPhone, 因为Android 如果要抓取https协议是需要root的,一般root后的手机风险比较高, 所以正常使用的手机我一般不root的。 这里使用iPhone就没有这问题了,接受一下Charles的profile就好了。具体的我就是详细说了。 当然,如果想成为一个专业的逆向高手,还是需要一个root过的Android手机的。

以下是抓包后的数据

调用链路大概是这样

1/APP/GetAPPConfig # 调用了两次,第一次是有用的,参加 fsDevice 是 iOS,至少我是这么认为的
2/Channel/GetAllChannel/mobile/L # 获取频道列表 留着有点用
3/Channel/ChannelProglist/8 # 获取对应频道的节目单
4# ...  中间有很多拿界面数据的,我这里就不一一列举
5
6/App/GetChannelUrl2 # 这是非常关键的,拿直播源地址的

分析直播源地址

分析 request

 1content-type: application/json
 2fsenc_key: 55E9B326-21FA-45E8-8FA8-5C361729A888
 3accept: */*
 4fsdevice: iOS
 5fsvalue:
 6accept-language: zh-CN,zh-Hans;q=0.9
 74gtv_auth: Wgs3Bcoojp48k+YJ6jXL/lEP/BkLh5/EFWcllEcKlUo0rD7cmY63IG62Am5reahCQZp9tpCnYGpOAyn3C2LZAg==
 8accept-encoding: gzip, deflate, br
 9content-length: 171
10user-agent: %E5%9B%9B%E5%AD%A3%E7%B7%9A%E4%B8%8A/1 CFNetwork/1399 Darwin/22.1.0
11cookie: AWSALB=h5/pd5d4Jzu9px81xx531QxE0kJ+JGIxL3hmuXnZYLuiwzUcP6OUvNoAe69hZPgGHIZaTEdxueL7TacEerDuUe5Ixk4oOZ5UsrtMFey+KE2CoaGcnH3ZLID8f1rR
12cookie: AWSALBCORS=h5/pd5d4Jzu9px81xx531QxE0kJ+JGIxL3hmuXnZYLuiwzUcP6OUvNoAe69hZPgGHIZaTEdxueL7TacEerDuUe5Ixk4oOZ5UsrtMFey+KE2CoaGcnH3ZLID8f1rR
13cookie: _cfuvid=ZJvrSyHr6FkDmWLdarPeISYPvUL02BrwjO463SDBP8s-1725608001217-0.0.1.1-604800000
14cookie: __cf_bm=c4yqGArJe4ldgQpHGLQCy4rVH.95D94RUVkIXOF8As4-1725607983-1.0.1.1-.jHMluYAo7W28gRLuQiafSTAZ8zfba8gC9msSCDLj0cjazwaPzYQEuhzkrCq3PjwwGACPtTn_v9IwnFXt_jDUA
15fsversion: 3.1.0
16
17{"fsASSET_ID":"4gtv-4gtv003","fnCHANNEL_ID":"1","clsAPP_IDENTITY_VALIDATE_ARUS":{"fsVALUE":"","fsENC_KEY":"55E9B326-21FA-45E8-8FA8-5C361729A888"},"fsDEVICE_TYPE":"mobile"}

这里有几个关键的参数:

1fsenc_key # 一个uuid
2fsdevice # 设备
3fsvalue # 不知道是个啥,value是空就行了
44gtv_auth # 看起来是一个密钥,下面详细说一下
5user-agent # 他们自己定义的一个ua,直接抄是一个比较好的办法
6cookie # 请求API的时候,如果没有cookie的话,他们下发,收集起来就好了

按这个思路伪造 request 去请求 GetChannelUrl2 是可以正常获取直播源地址的, 只是这个 4gtv_auth 每天都变化,如果今天用昨天的,是验证不通过的。所以需要搞清楚 4gtv_auth 是生成规则,自己实现一下才行。

分析关键加密方案

前面说过 4gtv_auth 的生成规则是关键,那么要搞清楚这个生成规则,只能APP逆向了。 我这里不详细介绍如何逆向了,因为这个app比较简单, 所以直接使用jadx-gui 打开就好了。

这里有一段关键的加密代码

 1 @Override // p268f.InterfaceC10299u
 2    public C10245c0 intercept(InterfaceC10299u.a aVar) {
 3        String str;
 4        C11528h.a aVar2;
 5        Charset charset;
 6        C10540j.m36461f(aVar, "chain");
 7        C10241a0 mo35551f = aVar.mo35551f();
 8        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd", Locale.getDefault());
 9        simpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
10        String format = simpleDateFormat.format(new Date());
11        String m38262o = this.f40041a.m38262o();
12        try {
13            aVar2 = C11528h.f42247a;
14            charset = C10403c.f37643a;
15        } catch (Exception unused) {
16            str = "";
17        }
18        if (m38262o != null) {
19            byte[] bytes = m38262o.getBytes(charset);
20            C10540j.m36460e(bytes, "(this as java.lang.String).getBytes(charset)");
21            String str2 = this.f40043c;
22            if (str2 != null) {
23                byte[] bytes2 = str2.getBytes(charset);
24                C10540j.m36460e(bytes2, "(this as java.lang.String).getBytes(charset)");
25                String str3 = this.f40044d;
26                if (str3 != null) {
27                    byte[] bytes3 = str3.getBytes(charset);
28                    C10540j.m36460e(bytes3, "(this as java.lang.String).getBytes(charset)");
29                    str = new String(aVar2.m40049c(bytes, bytes2, "AES/CBC/PKCS5Padding", bytes3), charset);
30                    C11533m c11533m = C11533m.f42259a;
31                    c11533m.m40058c("4GTV_AUTH", str);
32                    String m37945d = C11089f.m37945d(format + str);
33                    c11533m.m40058c("4GTV_AUTH", m37945d);
34                    C10241a0.a m35364g = mo35551f.m35364g();
35                    m35364g.m35368c("Content-Type", "application/json; charset=UTF-8");
36                    m35364g.m35368c("4GTV_AUTH", m37945d);
37                    m35364g.m35368c("fsDEVICE", "Android");
38                    m35364g.m35368c("fsVERSION", "2.3.8");
39                    m35364g.m35368c("fsENC_KEY", this.f40041a.m38259l());
40                    m35364g.m35368c("fsVALUE", this.f40042b.m38305d());
41                    m35364g.m35370e(mo35551f.m35363f(), mo35551f.m35358a());
42                    C10245c0 mo35548c = aVar.mo35548c(m35364g.m35367b());
43                    C10540j.m36460e(mo35548c, "chain.proceed(request)");
44                    return mo35548c;
45                }
46                throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
47            }
48            throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
49        }
50        throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
51    }

根据这段关键的入口代码,了解到加密需要的几个参数

1head_key # 这个不知道哪里来的
2KEY # 配置文件里有
3IV # 配置文件里有,找到就行了

寻找 head_key 的来路, 这里直接说结论,在AppConfig对象中封装的,来至于 /APP/GetAPPConfig , 转到 Charles 看一下这个 API 返回是个啥

 1{
 2	"Success": true,
 3	"ErrMessage": null,
 4	"Data": {
 5		"upgrade": 0,
 6		"launch_screen": "https://m.4gtv.tv/app/bulletin/img_welcome_20200319.jpg",
 7		"news_image": "",
 8		"news_link": "",
 9		"prog_active": true,
10		"allowVideoFrom": ["1", "2"],
11		"ad_delay": 2,
12		"promo_time": 5,
13		"promo_channel": ["https://4gtvimg.4gtv.tv/4gtv-Image/Production/Midroll/4gTVPlayerADMask/20180925_ad_default.jpg", "https://4gtvimg.4gtv.tv/4gtv-Image/Production/Midroll/4gTVPlayerADMask/20200317_player_channel_mask_phone_buy_ad.jpg"],
14		///... 中间删了,没啥用. 下面的header_key是关键
15		"header_key": "PyPJU25iI2IQCMWq7kblwh9sGCypqsxMp4sKjJo95SK43h08ff+j1nbWliTySSB+N67BnXrYv9DfwK+ue5wWkg==",
16		"hub_url": "https://airmsg.4gtv.tv",
17		"api_chat_domain": "https://airmsg-api.4gtv.tv/AppChat/",
18		"article_path": "app/webview/article/",
19		"live_view_sec": 3
20	}
21}

顺便说一下,为啥天天都变

1String format = simpleDateFormat.format(new Date());
2// ....
3String m37945d = C11089f.m37945d(format + str);
4c11533m.m40058c("4GTV_AUTH", m37945d);

这个 4GTV_AUTH 的生成规则里加了一个 format 变量 就是当前的日期。

根据加密的参数,以及 jadx 逆向后的代码,写出对应的 Java 代码. (这里的代码可以优化一下,有一些没啥用的,我是抄逻辑的,没考虑过逻辑是否能走的到)

 1import java.nio.charset.Charset;
 2
 3import java.security.MessageDigest;
 4import java.util.Arrays;
 5import java.util.Base64;
 6
 7import java.text.SimpleDateFormat;
 8import java.util.Date;
 9import java.util.Locale;
10import java.util.TimeZone;
11
12import javax.crypto.Cipher;
13import javax.crypto.SecretKey;
14import javax.crypto.SecretKeyFactory;
15import javax.crypto.spec.DESKeySpec;
16import javax.crypto.spec.IvParameterSpec;
17import javax.crypto.spec.SecretKeySpec;
18
19public class Main {
20    public static void main(String[] args) throws Exception {
21        String headKey = "PyPJU25iI2IQCMWq7kblwh9sGCypqsxMp4sKjJo95SK43h08ff+j1nbWliTySSB+N67BnXrYv9DfwK+ue5wWkg==";
22        String KEY = "ilyB29ZdruuQjC45JhBBR7o2Z8WJ26Vg";
23        String IV = "JUMxvVMmszqUTeKn";
24
25        Charset forName = Charset.forName("UTF-8");
26
27        byte [] bytes = headKey.getBytes(forName); //PREF_KEY_HEADER_KEY
28        byte [] bytes2 = KEY.getBytes(forName); // KEY
29        byte [] bytes3 = IV.getBytes(forName); //IV
30
31        byte [] decode = Base64.getDecoder().decode(bytes);
32
33        System.out.println("decode1: " + Arrays.toString(decode));
34
35
36        String format = getFormatString();
37
38        System.out.println("format: " + format);
39
40        String str = "AES";
41
42        SecretKey secretKeySpec;
43        String str2 = "AES/CBC/PKCS5Padding";
44        if (str == "DES") {
45            secretKeySpec = SecretKeyFactory.getInstance(str).generateSecret(new DESKeySpec(bytes2));
46        } else {
47            secretKeySpec = new SecretKeySpec(bytes2, str);
48        }
49        System.out.println("secretKeySpec: "+ secretKeySpec);
50        Cipher cipher = Cipher.getInstance(str2);
51
52        int i = 1;
53        byte [] result;
54        if (bytes3 != null && bytes3.length != 0) {
55            IvParameterSpec ivParameterSpec = new IvParameterSpec(bytes3);
56            i = 2;
57            cipher.init(i, secretKeySpec, ivParameterSpec);
58            result = cipher.doFinal(decode);
59//            System.out.println();
60        } else {
61            i = 2;
62            cipher.init(i, secretKeySpec);
63            result = cipher.doFinal(bytes);
64        }
65        System.out.println("result="+Arrays.toString(result));
66        String resultString = new String(result, forName);
67        System.out.println("result string: " + resultString);
68        String encodeString = getResultString(format + resultString, forName);
69        System.out.println("final result string: " + encodeString);
70
71    }
72
73    public static String getResultString(String str, Charset forName) throws Exception {
74        MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
75        byte [] bytes = str.getBytes(forName);
76        String encode2String = Base64.getEncoder().encodeToString(messageDigest.digest(bytes));
77        return encode2String;
78    }
79
80    public static String getFormatString() {
81        String format = "";
82        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd", Locale.getDefault());
83        simpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
84        System.out.println("TimeZone: " + TimeZone.getTimeZone("GMT"));
85        format = simpleDateFormat.format(new Date());
86        return format;
87    }
88}

当然我管理直播源的程序一个python的程序,所以还需要把这个代码翻译成python的代码,这里我就不贴了,等整理好再开源吧。

随便看一个台

似乎也没有特别香,不过破都破了,就看看吧~!