关于4gtv(四季线上) APP 逆向 —— 免费看台湾频道
背景
原因有两
- 注册吾爱破解需要注册号,如果想免费获得一个,就得拿出一个case. (申请ID: ****)
- 自己想看点台湾节目,丰富一下我的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的代码,这里我就不贴了,等整理好再开源吧。
随便看一个台
似乎也没有特别香,不过破都破了,就看看吧~!