i春秋-视频解密分析

手痒了忍不住肝了下某春秋的APK分析了下它视频解密方法~

由于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方法获取到服务端返回的key,然后调用a.getKey(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
public static String getKeyFromServer(String token, String url, CourseM3u8Info courseinfo) {
InputStream httpIStream;
URLConnection conn;
String timestamp;
if(url.startsWith("http://")) {
try {
String vid = url.substring(url.lastIndexOf("=") + 1);
timestamp = URLEncoder.encode(new Date().getTime() + "", "UTF-8");
HashMap v2 = new HashMap();
v2.put("app_key", "100001");
v2.put("ver", "1");
v2.put("timestamp", timestamp);
v2.put("method", "video.key.get");
v2.put("os", "android");
v2.put("mac", "00000");
v2.put("from", "app.android");
v2.put("token", token);
v2.put("vid", vid);
LLLLLLLLLLl.sign(v2);
StringBuilder urlParas = new StringBuilder();
Iterator v2_1 = v2.entrySet().iterator();
while(v2_1.hasNext()) {
Object v0_4 = v2_1.next();
urlParas.append(((Map$Entry)v0_4).getKey()).append("=").append(((Map$Entry)v0_4).getValue()).append("&");
}

timestamp = String.format("%s?%s", userInfo.a().b(), urlParas.toString());
d.LOG("newUrl =" + timestamp);
conn = new URL(timestamp).openConnection();
((HttpURLConnection)conn).setConnectTimeout(5000);
((HttpURLConnection)conn).setRequestMethod("GET");
if(((HttpURLConnection)conn).getResponseCode() != 200) {
d.LOG("请求url失败 url 是 =" + timestamp);
CacheFileEvent.sendTsAnalysisError(courseinfo.getChapter_id(), courseinfo.getSection_id(), 0);
return "";
}

httpIStream = ((HttpURLConnection)conn).getInputStream();
}
catch(Exception v0) {
....
}
try {
timestamp = LLLLLLLLLLl.ISGetString(httpIStream, "UTF-8"); // 从服务端得到base64编码的key
((HttpURLConnection)conn).disconnect();
return a.getKey(timestamp);
}
catch(Exception v0_1) {
....
}
}

return null;
}

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
}

解密脚本

这里列出最关键部分代码,可用于解密1.4版本下载的视频:

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
	public static void main(String[] args) throws Exception {

String parentDirpath = "E:\\Video";//获取目录
File parentDirFile = new File(parentDirpath);//循环获取目录下所有视频,解密
if (parentDirFile.exists() && parentDirFile.isDirectory()) {
File[] dirs = parentDirFile.listFiles();
for (File file : dirs) {
if(file.isDirectory()) {
String keyfile = file.getAbsolutePath() + File.separator + "key";
String chpier = new String(MUtils.readFile(keyfile), "UTF-8"); //读取key
MUtils.saveFile(MUtils.readFile(keyfile), keyfile+".bak"); //备份key
String key = DecryptoKey.decrypt(chpier); //解密key
MUtils.saveFile(MUtils.hexS2bytes(key), keyfile); //替换key
String fullpath = file.getAbsolutePath() + File.separator;
String path = file.getName();
String cmd = String.format("powershell ffmpeg.exe -allowed_extensions ALL -i %s.m3u8 -c copy -bsf:a aac_adtstoasc %s.mp4", path,"../"+path);
System.out.println(execCmd(cmd, new File(fullpath))); //指定工作目录,执行解密合成命令
}
}
}
}

public class DecryptoKey {
private static final String BITMAPPATH = "dict.png"; //提取出的位图位置
public static String AppVideoKey = ""; //so文件中拿到的app key
public static String AppSecretKey = "";

public static String decrypt(String chpier) throws IOException {
String text = rc4decrypto(chpier, AppVideoKey); //解密key
String keypro = text.split("____")[0]; //取第一部分,它的长度为18字节
//System.out.println(keypro.length());
return generaKey(keypro); //生成解密的key
}
private static String generaKey(String s) throws IOException {
String prefix;
int hex = 16;
String v0 = null;
int v1 = 0;
if (s != null && !s.equals("")) {
System.currentTimeMillis();
InputStream v2 = new FileInputStream(BITMAPPATH); //读取位图
BufferedImage bi = ImageIO.read(v2);
int i = 4;
try {
prefix = s.substring(0, i);
} catch (Exception v2_1) {
v2_1.printStackTrace();
prefix = v0;
}

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

int v5 = Integer.parseInt(prefix, hex);
//String v3_1 = hls.int2Hex(v5);
String v3_1 = Integer.toHexString(v5);
if (!s.contains(((CharSequence) v3_1))) { //校验s的前缀
return v0;
}

String v6 = s.replaceAll(prefix, ""); //去前缀

int height = bi.getHeight();
int width = bi.getWidth();
int[][] intmap = new int[height][width];
for (i = 0; i < height; i++) { //处理位图
for (int j = 0; j < width; j++) {
int v9 = bi.getRGB(j, i);
int[] rgb = new int[3];
rgb[0] = (v9 & 0xff0000) >> 16; // r
rgb[1] = (v9 & 0xff00) >> 8; // g
rgb[2] = (v9 & 0xff); // b
intmap[i][j] = rgb[2] / 85 + ((rgb[0] / 36 << 5) + (rgb[1] / 36 << 2));
}
}

int[] v2_4 = intmap[v5];
StringBuffer v3_2 = new StringBuffer("");
for (int k = 0; k < v6.length(); k += 2) { //映射密码
String v4_1 = v6.substring(v1, v1 + 2);
v1 += 2;
String tmp = Integer.toHexString(v2_4[Integer.parseInt(v4_1, hex)]);
System.err.println(tmp);
v3_2.append(tmp.length()!=2?"0"+tmp:tmp);
System.err.println(v3_2);
}

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

return v0;
}

private static String rc4decrypto(String arg13, String passwd) { //RC4加密函数2
int v8;
String v0;
int IntArrLen = 0x100;
int v12 = 8;
if (arg13.isEmpty()) {
v0 = "";
} else {
String md5S = md5class.strToMd5(passwd);
int len1 = md5S.length();
byte[] v5 = MUtils.base64Decode(arg13);
int len2 = v5.length;
int[] v3 = new int[IntArrLen];
int[] v7 = new int[IntArrLen];
int i;
for (i = 0; i <= 0xFF; ++i) {
v8 = i % len1;
v3[i] = md5S.substring(v8, v8 + 1).toCharArray()[0];
v7[i] = i;
}

i = 0;
len1 = 0;
while (i < IntArrLen) {
len1 = (len1 + v7[i] + v3[i]) % 0x100;
v8 = v7[i];
v7[i] = v7[len1];
v7[len1] = v8;
++i;
}

byte[] v8_1 = new byte[len2];
i = 0;
len1 = 0;
int v3_1 = 0;
while (i < len2) {
len1 = (len1 + 1) % 0x100;
v3_1 = (v3_1 + v7[len1]) % 0x100;
int v9 = v7[len1];
v7[len1] = v7[v3_1];
v7[v3_1] = v9;
v8_1[i] = ((byte) (((char) (v5[i] ^ v7[(v7[len1] + v7[v3_1]) % 0x100]))));
++i;
}

v0 = new String(v8_1);
v0 = v0.substring(0, v12)
.equals(md5class.strToMd5(v0.substring(v12, v0.length()).concat(md5S)).substring(0, v12))
? v0.substring(v12)
: "";
}

return v0;
}

}

结果

关于文件名

文件名信息被保存在/data/data/com.ni.ichunqiu/databases/alldata.db数据库里,写个脚本批量改就好了下面代码大部分情况下都能用,有问题自己改吧

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
package ichunqiu;

import java.io.File;
import java.sql.SQLException;
import java.util.Map;

import ichunqiu.sqliteutils.SqliteHelper;

public class Rename {
/*
cp /data/data/com.ni.ichunqiu/databases/alldata.db /sdcard/windows/Pictures/
cp -r /sdcard/Android/data/com.ni.ichunqiu/VideoCache/ /sdcard/windows/Pictures/
*/
public static void main(String[] args) throws ClassNotFoundException, SQLException
{
String path = "J:\\拉拉\\31304\\"; //我会暴露自己的用户名?
SqliteHelper h = new SqliteHelper(path + "alldata.db");
Map<String,String> map= h.eq("select chapter_title,course_title,section_id,section_index from course_m3u8_info");
File root = new File(path);
if (root.exists() && root.isDirectory()) {
File[] files = root.listFiles();
for (int i = 0; i < files.length; i++) {
if (files[i].getName().contains(".mp4")) {
int id = files[i].getName().lastIndexOf(".mp4");
String name = files[i].getName().substring(0, id);
String tmp = map.get(name);
if(tmp==null||tmp.isEmpty()) {
System.out.println("未找到相应数据");
}else{
String dirname = tmp.split("\\|")[0];
String title = tmp.split("\\|")[1];
String index = tmp.split("\\|")[2];
File dir = new File(path+dirname);
if(!dir.exists()) {
dir.mkdirs();
}
String newname = String.format("第%s节-%s.mp4", index,title);
if(!files[i].renameTo(new File(dir.getAbsolutePath()+"/"+newname)))
System.out.println("出错");

System.out.println(new File(dir.getAbsolutePath()+"/"+newname).getAbsolutePath());
}
}
}
}
}
}

结束

上面省去了很多细节只留下最关键的部分,其实分析是倒过来分析的,还挺有意思~(ε=ε=ε=┏(゜ロ゜;)┛