手痒了忍不住肝了下某春秋的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加固,惹不起躲得起,在豌豆荚找到旧版发现依然能用,就从旧版入手,虽然还是有壳,但是应该会好脱很多了。
- 下载drizzleDumper
- 自动脱壳:
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"); ((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); if(TextUtils.isEmpty(((CharSequence)uid))) { return v0; }
v0 = key.rc4encrypto(realKey + "____" + uid, key.stringAppVideoKey); } return v0; }
|
其中key是com.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
PUSH {R3,LR} LDR R1, =(a00dfafa4ed6b64 - 0xCA4) LDR R2, [R0] MOVS R3, #0x29C LDR R3, [R2,R3] ADD R1, PC BLX R3 POP {R3,PC}
|
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"); String uid = key.getUserId(); String realKey = key.rc4decrypto(key1, key.stringAppVideoKey); localKey = key.rc4encrypto(realKey + "____" + uid, key.stringAppVideoKey);
|
播放解密
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) { 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); if(!key.contains("____")) { goto label_117; } String[] v3 = key.split("____"); label_62: uid = v3[1]; if(!uid.equals(this.uid)) { goto label_69; }
keykey = this.a(v3[0]); } try { label_117: mylog.print("Aes128DataSourceKey 旧的的解析播放"); keykey = this.a(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"); 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); } 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: 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); String[] data1 = data.split("____");
key = hls.hashmap.get(data[0]); if(key==null){ String v0_3 = hls.generaKey(s); }
|
解密脚本
这里列出最关键部分代码,可用于解密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"); MUtils.saveFile(MUtils.readFile(keyfile), keyfile+".bak"); String key = DecryptoKey.decrypt(chpier); MUtils.saveFile(MUtils.hexS2bytes(key), keyfile); 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 = ""; public static String AppSecretKey = "";
public static String decrypt(String chpier) throws IOException { String text = rc4decrypto(chpier, AppVideoKey); String keypro = text.split("____")[0]; return generaKey(keypro); } 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 = Integer.toHexString(v5); if (!s.contains(((CharSequence) v3_1))) { 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; rgb[1] = (v9 & 0xff00) >> 8; rgb[2] = (v9 & 0xff); 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) { 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 {
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()); } } } } } }
|
结束
上面省去了很多细节只留下最关键的部分,其实分析是倒过来分析的,还挺有意思~(ε=ε=ε=┏(゜ロ゜;)┛