记录下之前分析的两款安全学习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加固,惹不起躲得起,在豌豆荚找到旧版发现依然能用,就从旧版入手,虽然还是有壳,但是应该会好脱很多了。
- 下载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
方法获取到服务端返回的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); 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); }
|
结果
关于文件名
文件名信息被保存在/data/data/com.ni.ichunqiu/databases/alldata.db
数据库里,写个脚本批量改就好了~
安全牛
注
- 本篇只是技术分享,请勿用于盗版侵权
(老大人那么好,盗版良心会痛啊)。
- 复习要紧,没什么时间了,下面主要贴代码,长话短说!
- 我没有vip买的课也都过期了,不要问我要资源,有也不给(ε=ε=ε=┏(゜ロ゜;)┛)
- 以下代码不够晚上,自动化程度不高,有兴趣可以改,反正希望保存的视频自己珍藏就好了,不要传播。
分析
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的方法,写了个小脚本可以直接从网页下载~