\[C#/Java\] 针对 QINIU-PROTECTION-10 的m3u8视频文件解密

源码地址:https://github.com/RyanFeng1024/m3u8_download


今年上网课很流行,有些在线的课程视频想下载下来到本地看,发现视频的链接是m3u8格式的,下载下来后,提取出视频切片的各个.ts文件的链接,把这些视频片段下载到本地后,却播放不了。于是就花点时间研究研究。网上了解了一下情况,知道视频是加密的, 不过搜了一大圈,都是讲的加密方式为 METHOD=AES-128 的解密方法,可我下载的m3u8文件打开看是 METHOD=QINIU-PROTECTION-10

Alt text


了解到解密视频需要key和IV, 我们可以看到 IV在m3u8文件里有,每一个.ts文件都有一个对应的IV,#EXT-X-KEY:后面的 IV=**** 就是我们需要用到的 IV了, 可是key却没有,那就只能从网页上找找了,打开控制台,重新加载页面,发现一个 qiniu-web-player.js 在控制台输出了一些配置信息和日志记录,其中 hls.DRMKey 引起了我的注意

Alt text


数组长度也是16位,刚好加解密用到的key的长度也是16位,, 所以这个应该就是AES加解密要用到的key了,不过需要先转换一下。。

网上的方法 转换步骤为:把数组里每一位数字转换成16进制字符串,然后把16进制字符串转为ASCII码,最终拼接出来的结果就是AES的key了。


C#代码:

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
private static string getAESKey(string key)
{
string[] arr = key.Split(",");
string aesKey = "";
for (int i = 0; i < arr.Length; i++)
{
string tmp = int.Parse(arr[i].Trim()).ToString("X"); //10进制转16进制
tmp = HexStringToASCII(tmp);
aesKey += tmp;
}
return aesKey;
}

/// <summary>
/// 十六进制字符串转换为ASCII
/// </summary>
/// <param name="hexstring">一条十六进制字符串</param>
/// <returns>返回一条ASCII码</returns>
public static string HexStringToASCII(string hexstring)
{
byte[] bt = HexStringToBinary(hexstring);
string lin = "";
for (int i = 0; i < bt.Length; i++)
{
lin = lin + bt[i] + " ";
}
string[] ss = lin.Trim().Split(new char[] { ' ' });
char[] c = new char[ss.Length];
int a;
for (int i = 0; i < c.Length; i++)
{
a = Convert.ToInt32(ss[i]);
c[i] = Convert.ToChar(a);
}
string b = new string(c);
return b;
}

把js获取的DRMKey数组内容当做字符串传入,获取AES的key

1
2
3
string DRMKey = "11, 22, 33, 44, 55, 66, 77, 88, 99, 00, 111, 111, 111, 111, 111, 111";
string aesKey = getAESKey(DRMKey);
Console.WriteLine("aesKey:" + aesKey);

现在AES_KEY和IV都有了,可以加解密了,不过这个IV有点特殊,是32位的,我们需要进行切片取前16位,16位是固定位数,必须这么取。

通过分析页面js代码得知这种AES的加密模式为CBC模式,PaddingMode采用PKCS7.

加密模式、补码方式、key、IV都有了,剩下的就是编码测试了。


下面是C#版的完整代码, Java版请看这里

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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

namespace VideoDownload
{
class Program
{
private static List<string> error_arr = new List<string>();

static void Main(string[] args)
{
string DRMKey = "11, 22, 33, 44, 55, 66, 77, 88, 99, 00, 111, 111, 111, 111, 111, 111"; //DRMKey
string m3u8Url = "https://XXXXXXX/123.m3u8"; //m3u8在线地址
string savePath = "D:\\VIDEO\\"; //保存的本地路径
string saveFileName = "VIDEO_FILE_NAME"; //保存的文件(夹)名称,如果为空 则使用默认m3u8文件名

try
{
// 创建本地保存目录
int index = m3u8Url.LastIndexOf("/");
string dirName = string.IsNullOrEmpty(saveFileName) ? m3u8Url.Substring(index + 1) : saveFileName;
string finalSavePath = savePath + dirName + "\\";
if (!Directory.Exists(finalSavePath))
{
Directory.CreateDirectory(finalSavePath);
}

// 读取m3u8文件内容
string m3u8Content = HttpGet(m3u8Url);
//string m3u8Content = File.ReadAllText("D:/test.m3u8");

string aesKey = getAESKey(DRMKey);
//Console.WriteLine("aesKey:" + aesKey);

Uri uri = new Uri(m3u8Url);
string domain = uri.Scheme + "://" + uri.Authority;
//Console.WriteLine("m3u8域名为:" + domain);

List<string> tsList = Regex.Matches(m3u8Content, @"\n(.*?.ts)").Select(m => m.Value).ToList();
List<string> ivList = Regex.Matches(m3u8Content, @"IV=(.*?)\n").Select(m => m.Value).ToList();
if (tsList.Count != ivList.Count || tsList.Count == 0)
{
Console.WriteLine("m3u8Content 解析失败");
}
else
{
Console.WriteLine("m3u8Content 解析完成,共有 " + ivList.Count + " 个ts文件");

for (int i = 0; i < tsList.Count; i++)
{
string ts = tsList[i].Replace("\n", "");
string iv = ivList[i].Replace("\n", "");
iv = iv.Replace("IV=0x", "");
iv = iv.Substring(0, 16); //去除前缀,取IV前16位

int idx = ts.LastIndexOf("/");
string tsFileName = ts.Substring(idx + 1);

try
{
string saveFilepath = finalSavePath + tsFileName;
if (!File.Exists(saveFilepath))
{
Console.WriteLine("开始下载ts: " + domain + ts);
byte[] encByte = HttpGetByte(domain + ts);
if (encByte != null)
{
Console.WriteLine("开始解密, IV -> " + iv);
byte[] decByte = null;
try
{
decByte = AESDecrypt(encByte, aesKey, iv);
}
catch (Exception e1)
{
error_arr.Add(tsFileName);
Console.WriteLine("解密ts文件异常。" + e1.Message);
}
if (decByte != null)
{
//保存视频文件
File.WriteAllBytes(saveFilepath, decByte);
Console.WriteLine(tsFileName + " 下载完成");
}
}
else
{
error_arr.Add(tsFileName);
Console.WriteLine("HttpGetByte 结果返回null");
}
}
else
{
Console.WriteLine($"文件 {saveFilepath} 已存在");
}
}
catch (Exception ee)
{
error_arr.Add(tsFileName);
Console.WriteLine("发生异常。" + ee);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine("发生异常。" + ex);
}

Console.WriteLine("所有操作已完成. 保存目录 " + savePath);
if (error_arr.Count > 0)
{
List<string> list = error_arr.Distinct().ToList();
Console.WriteLine($"其中 共有{error_arr.Count}个文件下载失败:");
list.ForEach(x =>
{
Console.WriteLine(x);
});
}
Console.ReadKey();
}


private static string getAESKey(string key)
{
string[] arr = key.Split(",");
string aesKey = "";
for (int i = 0; i < arr.Length; i++)
{
string tmp = int.Parse(arr[i].Trim()).ToString("X"); //10进制转16进制
tmp = HexStringToASCII(tmp);
aesKey += tmp;
}
return aesKey;
}

/// <summary>
/// 十六进制字符串转换为ASCII
/// </summary>
/// <param name="hexstring">一条十六进制字符串</param>
/// <returns>返回一条ASCII码</returns>
public static string HexStringToASCII(string hexstring)
{
byte[] bt = HexStringToBinary(hexstring);
string lin = "";
for (int i = 0; i < bt.Length; i++)
{
lin = lin + bt[i] + " ";
}
string[] ss = lin.Trim().Split(new char[] { ' ' });
char[] c = new char[ss.Length];
int a;
for (int i = 0; i < c.Length; i++)
{
a = Convert.ToInt32(ss[i]);
c[i] = Convert.ToChar(a);
}
string b = new string(c);
return b;
}

/// <summary>
/// 16进制字符串转换为二进制数组
/// </summary>
/// <param name="hexstring">用空格切割字符串</param>
/// <returns>返回一个二进制字符串</returns>
public static byte[] HexStringToBinary(string hexstring)
{
string[] tmpary = hexstring.Trim().Split(' ');
byte[] buff = new byte[tmpary.Length];
for (int i = 0; i < buff.Length; i++)
{
buff[i] = Convert.ToByte(tmpary[i], 16);
}
return buff;
}

/// <summary>
/// AES解密
/// </summary>
/// <param name="cipherText"></param>
/// <param name="Key"></param>
/// <param name="IV"></param>
/// <returns></returns>
public static byte[] AESDecrypt(byte[] cipherText, string Key, string IV)
{
// Check arguments.
if (cipherText == null || cipherText.Length <= 0)
throw new ArgumentNullException("cipherText");
if (Key == null || Key.Length <= 0)
throw new ArgumentNullException("Key");
if (IV == null || IV.Length <= 0)
throw new ArgumentNullException("IV");

// Declare the string used to hold
// the decrypted text.
byte[] res = null;

// Create an AesManaged object
// with the specified key and IV.
using (AesManaged aesAlg = new AesManaged())
{
aesAlg.Key = Encoding.ASCII.GetBytes(Key);
aesAlg.IV = Encoding.ASCII.GetBytes(IV);
aesAlg.Mode = CipherMode.CBC;
aesAlg.Padding = PaddingMode.PKCS7;

// Create a decrytor to perform the stream transform.
ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

// Create the streams used for decryption.
using (MemoryStream msDecrypt = new MemoryStream(cipherText))
{
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
{
byte[] tmp = new byte[cipherText.Length + 32];
int len = csDecrypt.Read(tmp, 0, cipherText.Length + 32);
byte[] ret = new byte[len];
Array.Copy(tmp, 0, ret, 0, len);
res = ret;
}
}
}
return res;
}


public static string HttpGet(string url)
{
try
{
HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);
request.Timeout = 20000;
var response = (HttpWebResponse)request.GetResponse();
using (StreamReader reader = new StreamReader(response.GetResponseStream(), Encoding.UTF8))
{
return reader.ReadToEnd();
}
}
catch (Exception ex)
{
Console.Write("HttpGet 异常," + ex.Message);
Console.Write(ex);
return "";
}
}

public static byte[] HttpGetByte(string url)
{
try
{
byte[] arraryByte = null;

HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);
request.Timeout = 20000;
request.Method = "GET";
using (WebResponse wr = request.GetResponse())
{
int length = (int)wr.ContentLength;
using (StreamReader reader = new StreamReader(wr.GetResponseStream(), Encoding.UTF8))
{
HttpWebResponse response = wr as HttpWebResponse;
Stream stream = response.GetResponseStream();
//读取到内存
MemoryStream stmMemory = new MemoryStream();
byte[] buffer1 = new byte[length];
int i;
//将字节逐个放入到Byte 中
while ((i = stream.Read(buffer1, 0, buffer1.Length)) > 0)
{
stmMemory.Write(buffer1, 0, i);
}
arraryByte = stmMemory.ToArray();
stmMemory.Close();
}
}
return arraryByte;
}
catch (Exception ex)
{
Console.Write("HttpGetByte 异常," + ex.Message);
Console.Write(ex);
return null;
}
}
}
}

新建个控制台应用,代码复制过去,改一下最上面的四个参数值就可以运行。本来想做个桌面应用程序的,结果嫌麻烦,费时间就没做了。哪位看官要是有时间可以做个桌面程序方便操作,另外可以加上多线程去下载会快一些。下载解密完之后的ts文件后,使用其他工具合并ts文件或者用windows自带cmd执行以下命令也可以合并文件

copy /b D:\VIDEO\*.ts D:\VIDEO\newFile.ts


参考资料:

python爬虫—破解m3u8 加密