源碼地址:https://github.com/Myron1024/m3u8_download
今年上網課很流行,有些在線的課程視頻想下載下來到本地看,發現視頻的鏈接是m3u8格式的,下載下來后,提取出視頻切片的各個.ts文件的鏈接,把這些視頻片段下載到本地后,卻播放不了。於是就花點時間研究研究。網上了解了一下情況,知道視頻是加密的, 不過搜了一大圈,都是講的加密方式為 METHOD=AES-128 的解密方法,可我下載的m3u8文件打開看是 METHOD=QINIU-PROTECTION-10
了解到解密視頻需要key和IV, 我們可以看到 IV在m3u8文件里有,每一個.ts文件都有一個對應的IV,#EXT-X-KEY:后面的 IV=**** 就是我們需要用到的 IV了, 可是key卻沒有,那就只能從網頁上找找了,打開控制台,重新加載頁面,發現一個 qiniu-web-player.js 在控制台輸出了一些配置信息和日志記錄,其中 hls.DRMKey 引起了我的注意
數組長度也是16位,剛好加解密用到的key的長度也是16位,, 所以這個應該就是AES加解密要用到的key了,不過需要先轉換一下。。
網上的方法 轉換步驟為:把數組里每一位數字轉換成16進制字符串,然后把16進制字符串轉為ASCII碼,最終拼接出來的結果就是AES的key了。
C#代碼:
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
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版請看這里
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
參考資料: