两款学习APP缓存视频解密

记录下之前分析的两款安全学习app加密视频提取方式~

爱春秋

由于VIP快到期了,就想把视频下载下来慢慢看,但是它只能在手机上使用官方app观看,不仅占空间而且还不敢保证到期后缓存的视频还能看,于是想把它提取出来,经查找缓存文件放在/Android/data/com.ni.ichunqiu/videocache目录下,每个视频被分片,即m3u8,且使用AES加密,当然密钥文件也在本地,但是很明显这个密钥文件本身也是被加密的,于是需要解密key->解密分段视频->合成视频这几步,其中后面两步很简单,关键就在第一步,仔细观察只能猜出密钥是base64编码存放的,看不出是什么加密,只能分析app啦。

更新

分析了新版爱屁屁发在52破解,忘记同步到博客了,然后52的帖子涉及版权问题被删啦,我也就忘了新版是啥样子了,还好蓝奏上还有当时的代码:https://www.lanzous.com/b345136/ 密码:80o4

M3U8

好像是APPLE弄出来的一种流媒体格式,索引文件为.m3u8,里面内容如下,真正的视频存储在.ts文件里,ts可以被加密,加密密钥存放在如下描述处:

1
2
3
4
5
6
7
8
9
10
11
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:27
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="key",IV=0x99b74007b6254e4bd1c6e03631cad15b #加密密钥位置
#EXTINF:26.250000,
585730.ts #一个个小的碎片的位置
#EXTINF:16.875000,
585731.ts
...............
#EXT-X-ENDLIST

现在有了ts和m3u8文件啦,还需要把key解密。

脱壳

直接在官方下载发现文件使用了360加固,惹不起躲得起,在豌豆荚找到旧版发现依然能用,就从旧版入手,虽然还是有壳,但是应该会好脱很多了。

  1. 下载drizzleDumper
  2. 自动脱壳:
    1
    2
    3
    4
    5
    6
    adb push x86/drizzleDumper /storage/sdcard/
    chmod +x drizzleDumper
    adb shell
    /storage/sdcard/drizzleDumper com.ni.ichunqiu
    exit
    adb pull xxx.dex E:\\
    很顺利的得到两个dex

分析

在使用本地缓存时应该需要读取文件,其中部分路径应该是硬编码的,使用/key.m3u8作为关键字搜索字符串直接找到了关键点:

下载加密

分析

经分析发现本类为下载处,也好,看看加密过程也就知道解密啦!
1.首先它通过video.key.get方法获取到服务端返回的base64编码的key,然后调用a.getKey(key)处理它
2.a.getKey(key)做如下处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static String getKey(String key1) {  
String v0 = null;
if(!TextUtils.isEmpty(((CharSequence)key1))) {
String uid = key.getUserId(); //为一串数字哈
String realKey = key.rc4decrypto(key1, key.stringAppVideoKey); //使用AppVideoKey解密key1
if(TextUtils.isEmpty(((CharSequence)uid))) {
return v0;
}

v0 = key.rc4encrypto(realKey + "____" + uid, key.stringAppVideoKey);
}
return v0;
}

其中keycom.ichunqiu.libglobal.tool.f这个类,它是一个很重要的解密类,里面主要包含两个RC4加解密函数,加解密用的密钥是AppVideoKey这个字符串,它来自flytv.run.monitor.MyApplication匿名内部类的run方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.method public run()V
.registers 5
00000000 const/4 v3, 0
00000002 iget-object v0, p0, MyApplication$1->a:MyApplication
00000006 const-string v1, "PUSH_APPID"
0000000A const/4 v2, 0
0000000C invoke-virtual MyApplication->a(String, String)String, v0, v1, v2
00000012 move-result-object v0
00000014 new-instance v1, IChunqiuJni
00000018 invoke-direct IChunqiuJni-><init>()V, v1
0000001E invoke-virtual IChunqiuJni->stringAppVideoKeyJNI()String, v1
00000024 move-result-object v2
00000026 sput-object v2, key->stringAppVideoKey:String
0000002A invoke-virtual IChunqiuJni->stringAppSecretKeyJNI()String, v1
00000030 move-result-object v1
00000032 sput-object v1, key->stringAppSecretKey:String
...............
.end method

看到它其实IChunqiuJni这个native层的动态库,解压文件即可得到它,用阿达打开即可得到密钥:

1
2
3
4
5
6
7
8
9
10
11
12
EXPORT Java_com_ni_ichunqiu_IChunqiuJni_stringAppSecretKeyJNI
Java_com_ni_ichunqiu_IChunqiuJni_stringAppSecretKeyJNI
; __unwind {
PUSH {R3,LR}
LDR R1, =(a00dfafa4ed6b64 - 0xCA4)
LDR R2, [R0]
MOVS R3, #0x29C
LDR R3, [R2,R3]
ADD R1, PC ; "32dfafa4ed6b64f7644172c1ee9ad2f4"
BLX R3
POP {R3,PC}
; End of function Java_com_ni_ichunqiu_IChunqiuJni_stringAppSecretKeyJNI

3.当把密钥处理好以后,会存储在本地的/key文件下,并且更改m3u8的key uri为key

1
2
3
user = LLLLLLLLLLl.getRealKey(user, keyuri, this.courseinfo);
LLLLLLLLLLl.string2file(this.a.substring(0, this.a.lastIndexOf("/")) + "/key", user, false); //存储加密后的key
sb.append(aline.replace(((CharSequence)keyuri), "key") + "\n"); //.M3U8文件
小结

其实内部还有很多处理与验证逻辑但是和解密key无关就省去了,通过分析发现:

1
2
3
4
5
key = LLLLLLLLLLl.ISGetString(httpIStream, "UTF-8");        // 1.从服务端得到base64编码的key
String uid = key.getUserId(); // 2.得到用户id
String realKey = key.rc4decrypto(key1, key.stringAppVideoKey); // 3.使用从APP里得到的VideoKey解密服务端返回的key,里面会校验解密是否正确
localKey = key.rc4encrypto(realKey + "____" + uid, key.stringAppVideoKey);// 4.再使用VideoKey加密realKey + "____" + uid,之后存储在本地
//也就是说本地存储的key其实适合用户绑定的

播放解密

1.根据上面分析发现key这个类很关键,加解密都在这里,于是查看交叉引用发现另一个类com.google.android.exoplayer.b\nnnn自命名,忘了原名啦调用了它,其内部就是解密代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public nnnn(d arg7, byte[] key1, byte[] arg9) {      //经分析,后两个参数分别是本地存储的key和iv   
String uid;
String key;
byte[] keykey;
String keey1;
int v0 = 0;
super();
if(key1 != null) {
try {
this.uid = key.getUserId();
keey1 = new String(key1, "ASCII");
key = key.rc4decrypto(keey1, key.stringAppVideoKey); //完全的逆过程,解密key
if(!key.contains("____")) { //_____分割,第一部分为key第二部分为uid
goto label_117;
}
String[] v3 = key.split("____");
label_62:
uid = v3[1];
if(!uid.equals(this.uid)) { //判断uid是否相符,只有下载的人能够播放它
goto label_69;
}

keykey = this.a(v3[0]);
}
try {
label_117:
mylog.print("Aes128DataSourceKey 旧的的解析播放");
keykey = this.a(key); } //处理解密后的key
this.a = arg7;
this.midKey = keykey;
this.IV = arg9;
}

2.转到a()函数,在其内部先尝试以此为键取,失败就去生成它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public byte[] a(String s) {
byte[] v0_4;
Object v0_2;
try {
v0_2 = hls.hashmap.get(s);
if(v0_2 != null) {
goto label_13;
}

if(s == null) {
goto label_16;
}

String v0_3 = hls.generaKey(s);
v0_4 = hls.hexS2bytes(v0_3);
goto label_13;
}
label_16:
v0_4 = null;
label_13:
return ((byte[])v0_2);
}

3.又转到hls.generaKey(key)函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public static String generaKey(String s) throws IOException, HLsParserException {
String v2_2;
int hex = 16;
String v0 = null;
int v1 = 0;
InputStream v2 = con.context.getAssets().open("dict.png"); //首先打开资源文件里面的一张图,可以解压apk得到
if(s != null && !s.equals("")) {
System.currentTimeMillis();
new BitmapFactory$Options().inPreferredConfig = Bitmap$Config.ARGB_8888;
Bitmap bitmap = BitmapFactory.decodeStream(v2);
int i = 4;
try {
v2_2 = s.substring(0, i); //取出前缀2字节,还剩16字节,目标就在前方啊
}
catch(Exception v2_1) {
v2_1.printStackTrace();
v2_2 = v0;
}

if(v2_2 == null) {
return v0;
}

int v5 = Integer.parseInt(v2_2, hex);
String v3_1 = hls.int2Hex(v5);
if(bitmap == null) {
new HLsParserException("请将工程目录下添加 dict资源");
}

if(s == null || (s.equals(""))) {
new HLsParserException("请求生成原始的解密 key 不能为空!");
}

if(!s.contains(((CharSequence)v3_1))) { //简单校验一下前缀
return v0;
}

String v6 = s.replaceAll(v2_2, ""); //去除前缀
if(bitmap == null) {
return v0;
}

int height = bitmap.getHeight();
int width = bitmap.getWidth();
Object intmap = new int[height][width];
i = 0;
label_46: //以RGB方式处理位图
if(i < height) {
int j = 0;
label_48:
if(j < width) {
int v9 = bitmap.getPixel(j, i);
intmap[i][j] = Color.blue(v9) / 85 + ((Color.red(v9) / 36 << 5) + (Color.green(v9) / 36 << 2));
++j;
goto label_48;
}

++i;
goto label_46;
}

Object v2_4 = intmap[v5]; //以前缀为下标取行
StringBuffer v3_2 = new StringBuffer("");
int k = 0;
label_76:
if(k < v6.length()) {
String v4_1 = v6.substring(v1, v1 + 2);
v1 += 2;
v3_2.append(hls.int2hex(v2_4[Integer.parseInt(v4_1, hex)])); //映射出密码
k += 2;
goto label_76;
}

System.currentTimeMillis();
v0 = v3_2.toString();
}

return v0;
}

小结

服务端返回的key并非最终的解密密钥,可以防止抓包获取,它其实是根据app版本生成,在本地使用VideoKey解密后得到的依然不是最终的key,还要以此为输入做一个查表映射,最终得道的才是真正的key:

1
2
3
4
5
6
7
data = key.rc4decrypto(localKey, key.stringAppVideoKey);	//解密本地存储的key
String[] data1 = data.split("____"); //分割,校验uid,通过后前部用去查表
//data[1]=?uid
key = hls.hashmap.get(data[0]); //先尝试从内存中获取
if(key==null){
String v0_3 = hls.generaKey(s); //查表获取真实的key
}

结果

关于文件名

文件名信息被保存在/data/data/com.ni.ichunqiu/databases/alldata.db数据库里,写个脚本批量改就好了~

安全牛

  1. 本篇只是技术分享,请勿用于盗版侵权(老大人那么好,盗版良心会痛啊)。
  2. 复习要紧,没什么时间了,下面主要贴代码,长话短说!
  3. 我没有vip买的课也都过期了,不要问我要资源,有也不给(ε=ε=ε=┏(゜ロ゜;)┛)
  4. 以下代码不够晚上,自动化程度不高,有兴趣可以改,反正希望保存的视频自己珍藏就好了,不要传播。

分析

1.下载的视频被保存在/sdcard/edusoho目录下,下级目录根据用户id与课程,网校名分类。
2.下载的视频为加密分段存储,每段几百kb:

3.此目录下只会保留视频文件,m3u8的列表信息和key保存在数据库/data/data/com.edusoho.kuozhi/databases/edusoho里:

4.密钥没有加密,分段的视频文件被加了两次密:

才开始看到密钥文件刚好16字节还很开心,但是无法解密视频,接着看密钥数据格式有点奇怪,莫非两个连在一起用?结果猜错了,就只好乖乖逆算法,才开始看得脑壳疼,发现它是本地搭建一台web服务器,返回用户请求的数据,很明显就是m3u8列表文件(在数据库的data_m3u8表中)那种样子的请求:

那么猜测要么服务器返回没有任何问题,再由客户端解密,要么服务端返回解密后的数据,于是就用tcpdump -i lo抓包:

经对比发现服务端返回的m3u8list是无变化的,但是视频片段却和本地保存的很不一样啦,有点小激动,使用AES/CBC/PKCS5Padding方式成功解密数据!于是关键就在搭建的服务端处了,经过分析发现它的运算方式也很简单,直接用了摘要流,有兴趣自己看看啪,最终效果如下:

总结

阔知学堂的也弄得很简单,另外网页版js基本没混淆,可以直接发现解密key的方法,写了个小脚本可以直接从网页下载~