仅供信息安全学习,部分内容做了 * 替换
起因是在这个 APP 买的付费视频总会不定期清除,就想着下下来存着,以免之后找不到。一开始想着电脑开个模拟器录屏算了,但想想还是有点麻烦,干脆直接分析。
0x00 准备工作
- 雷电模拟器 4.0.43 32 位版本,64 位版本在我这里能运行,但登录 APP 失败
- 安装要破解的 APP
- 安装 Xposed 框架和 JustTrustMe 模块并开启
- 安装反射大师
- 安装终端模拟器
- 电脑安装 Fiddler4/Proxifier,配置 Fiddler4 Capture HTTPS CONNECTs;Proxifier 新增 Proxy 为 Fiddler4 的 Proxy,并新增 Rule:Applications 为 ld*.exe
- 安装 Frida,并将对应的 frida-server 放到模拟器上
- FFMpeg
0x01 抓视频相关 HTTP 请求
开启已经配置好了的 Fiddler4 和 Proxifier,为防止其他请求干扰,可以拖动 Fiddler4 工具栏的瞄准镜一样的小光标到 Proxifier 上,意味着仅显示从 Proxifier 来的请求;另外,在 Rules 菜单下勾选「Hide Image Request」和「Hide CONNECTs」排除干扰。
打开雷电模拟器上的 APP,点开视频列表,在 Fiddler 内找到 Host 为 learning.xxx.cn 的请求
# 哪怕没有购买也可以试看,也可以获取到这条 URL 请求
https://learning.*****.com/*******/v1/item_list/?content_id=xxxx&其他参数
该请求是获取视频列表的请求,请求返回的 JSON 中,JSONArray ["data"]["item_list"]
中的每一个对象就是一个视频课程。选择其中一个课程,记为 课程1,在课程中找到 ["preload_info"]["url"]
,将其中的 \u0026
替换为 &
并访问,得到该条视频课程的预加载信息:
{
"data": {
"video_model": "Stringify JSONObject"
},
"err_msg": "success",
"err_no": 0
}
其中 video_model
是字符串化的 JSON,将其转换成 JSONObject:
{
"status": 10,
"message": "success",
"enable_ssl": false,
"enable_adaptive": false,
"video_id": "v03031g10000c4nk28rc77uf7o6sj1tg",
"video_duration": 1653,
"media_type": "video",
"fallback_api": "https://vas-lf-x.*****.com/video/fplay/1/39ccb0995ba19c92b601e4ba03b76f04/v03031g10000c4nk28rc77uf7o6sj1tg?aid=13\u0026codec_type=1\u0026device_platform=unknown\u0026key_seed=cIKh0wFc%2Fbe4Tpqo2Ty5XaQN97aT3k%2BnO%2FMeQI%2BmTGQ%3D\u0026ptoken=pgc_learning_encrypt\u0026stream_type=encrypt",
"key_seed": "cIKh0wFc/be4Tpqo2Ty5XaQN97aT3k+nO/MeQI+mTGQ=",
"video_list": {
"video_4": {
"definition": "1080p",
"quality": "normal",
// Video type
"vtype": "mp4",
"vwidth": 1920,
"vheight": 1080,
"bitrate": 478583,
"fps": 15,
"codec_type": "h265",
"size": 99655378,
// Video URL
"main_url": "[Base64 string of main url]",
"backup_url_1": "[Base64 string of backup url]",
"url_expire": 1630645313,
"preload_size": 327680,
"preload_interval": 60,
"preload_min_step": 5,
"preload_max_step": 10,
// Spade A?
"spade_a": "krwc9lCOAPZRvh35TYAByE2zHchRtBv6VYMc0maoGuRknyyCgg==",
"file_hash": "686099bb24f9dc7a18a76f99c3fdc204",
"file_id": "d4ec09a2822740fbbf385a07d64912e5",
"p2p_verify_url": "[Base64 string of p2p verify url]",
// Encrypted or not
"encrypt": true,
// KID
"kid": "612f69cec2e1f7b1eb9e729d0046e5da",
"fitter_info": null,
"quality_type": 0,
// Encryption method
"encryption_method": "cenc-aes-ctr",
"language_id": 0
},
"video_1": {
// Same structure as video_4
},
"video_2": {
// Same structure as video_4
},
"video_3": {
// Same structure as video_4
}
},
"popularity_level": 0,
"has_embedded_subtitle": false
}}
可以看到视频是被加密的,且加密方法是 cenc-aes-ctr
,并且提供了 kid
,让我想到了 FFMpeg 的 encryption,但没有直接看到对应的解密秘钥。
在视频列表点击上面选择的 课程1,找到下面另一个请求
https://learning.*****.com/learning_column/api/v1/video_detail_page/?item_id=xxxx&其他参数
并在其中也找到字符串化的 video_model
字段,将其转换成 JSONObject,其结构和上面的 video_model
类似,但格式为 dash
,且音视频分离,而上面的格式直接就是 mp4
。为方便解密,最终解密我们会直接选择上面的 video_model
获取 main_url
后下载解密,但在此之前,我们仍需要 dash
格式的 video_model
来探索其加解密。
0x02 黑桃A —— 获取解密密钥
模拟器上打开反射大师,选择 APP 并打开,点击六芒星后选择「当前ACTIVITY」,弹出窗内长按「写出DEX」按钮将当前 APP 所有 DEX 导出,我用的这个 APP 版本导出了十个 DEX 文件。
导出的 DEX 要反编译成 JAVA 源文件。但我试了一下,dex2jar,JByteMod 和 bytecode-viewer 都不好用,最后用 JADX 来反编译成 java 源文件,虽然部分方法反编译出错导致可读性较差,但不影响分析。BTW,可以试一下 GDA。
在源文件中查找所有和 encrypt
有关的项,最后在 com.**.**videoengine.**VideoEngine
类中找到如下的关键代码:
L_0x0ced:
boolean r0 = r1.mUsePlayerSpade
if (r0 != 0) goto L_0x0d2c
// r7 for decryption
byte[] r0 = com.**.**videoengine.utils.**Helper.base64DecodeToBytes(r7)
java.lang.String r5 = "encryption null"
if (r0 == 0) goto L_0x0d04
// We can see the getEncryptionKeyWithCheck is the method to get decryption key
// and the r0 parameter is decoded from r7 so we need to find out where comes the r7
java.lang.String r7 = com.**.**videoengine.JniUtils.getEncryptionKeyWithCheck(r0) // Catch:{ Throwable -> 0x0cfe }
goto L_0x0d05
根据 Label 名 L_0X0ced
向上追溯,找到关键代码,看看这个 r7
是怎么来的:
L_0x0cb7:
boolean r0 = android.text.TextUtils.isEmpty(r7)
if (r0 != 0) goto L_0x0d33
com.**.**videoengine.log.IVideoEventLogger r0 = r1.mLogger
// r7 is encryptKey, hoo-ha
r0.setEncryptKey(r7)
int r0 = r1.mDataLoaderEnable
if (r0 == 0) goto L_0x0cd2
com.**.**videoengine.DataLoaderHelper r0 = com.**.**videoengine.DataLoaderHelper.getDataLoader()
r5 = 9009(0x2331, float:1.2624E-41)
int r0 = r0.getIntValue(r5)
if (r0 != 0) goto L_0x0ced
继续向上追溯:
L_0x0c77:
boolean r0 = r1.mDashEnabled
if (r0 == 0) goto L_0x0c84
com.**.**videoengine.model.IVideoModel r0 = r1.mVideoModel
if (r0 == 0) goto L_0x0cb6
// There's an r7 goto L_0x0cb7
java.lang.String r7 = r0.getSpadea()
goto L_0x0cb7
L_0x0c84:
boolean r0 = r1.mIsLocal
if (r0 != 0) goto L_0x0cab
boolean r0 = r1.mIsPlayItem
if (r0 != 0) goto L_0x0cab
boolean r0 = r1.mIsPreloaderItem
if (r0 != 0) goto L_0x0cab
boolean r0 = r1.mIsDirectURL
if (r0 != 0) goto L_0x0cab
com.**.**videoengine.model.VideoInfo r0 = r1.currentVideoInfo
if (r0 == 0) goto L_0x0cab
r5 = 5
java.lang.String r0 = r0.getValueStr(r5)
boolean r0 = android.text.TextUtils.isEmpty(r0)
if (r0 != 0) goto L_0x0cab
com.**.**videoengine.model.VideoInfo r0 = r1.currentVideoInfo
r5 = 5
// There's an r7 goto L_0x0cb7
java.lang.String r7 = r0.getValueStr(r5)
goto L_0x0cb7
L_0x0cab:
java.lang.String r0 = r1.mSpadea
boolean r0 = android.text.TextUtils.isEmpty(r0)
if (r0 != 0) goto L_0x0cb6
// There's an r7 goto L_0x0cb7
java.lang.String r7 = r1.mSpadea
goto L_0x0cb7
三个 Label 内的内容有两个都指向了这个 Spadea,另外一个是 getValueStr(5)
来的,搜一下相关代码:
// com.**.**videoengine.model.VideoInfo::getValueStr
if (i == 5) {
return this.mSpadeaVer2;
}
至此,确认 r7
的确都和 Spadea 逃不了干系了。回去看看 video_model
JSONObject,里面就有一个 spade_a
。验证一下 spade_a
是不是与解密密钥有关。使用 Frida Hook JniUtils.getEncryptionKeyWithCheck
,并取上面的 spade_a
来直接调用解密方法,对比最终密钥:
let JniUtils = Java.use('com.**.**videoengine.JniUtils');
let **Helper = Java.use('com.**.**videoengine.utils.**Helper');
// spadeA from video_model json field spade_a
let spadeA = 'l7wZxla8K/VXvS7EYpQuxWONG8BQkCnreJEx2WCSKd56qTKqqg==';
// Decrypt over Jni function accessing
console.log('SpadeA decryption: '
+ JniUtils.getEncryptionKeyWithCheck(**Helper.base64DecodeToBytes(spadeA)));
// Decrypt over android app method hook
JniUtils.getEncryptionKeyWithCheck.overload('[B').implementation =
function(bytes) {
let key = this.getEncryptionKeyWithCheck(bytes);
console.log('finalKey: ' + key);
return key;
}
在视频列表里点击上面的 视频1 后查看输出:
SpadeA decryption: 6d8df2d2bb494942a639ac8c1083039a
finalKey: 6d8df2d2bb494942a639ac8c1083039a
证实了 spade_a
经过解密就是密钥。
0x04 解密视频
encryption_key 有了,接下来可以尝试解密了。从上面 mp4 格式的 video_model 中取 main_url 进行 Base64 decode,直接把加密过后的完整 mp4 文件下载下来。同时利用 Frida 调用上面的解密方法,将对应的 spade_a 解密成 decryption_key ,而后调用如下的方法解密视频:
ffmpeg.exe -decryption_key {decryption_key} -i {input_file} {out_file}
不出意外的话,将会解密成功。至此,粗略的解密流程完成。
0x05 扩展
其实到这里差不多就完成了,可是要每次都这么大动干戈才能获取视频和密钥未免有些麻烦。既然已经能够获取了视频列表、视频地址和黑桃A,那唯一的难点其实是根据黑桃A获取解密后的密钥。
按照上面的结论,知道关键代码其实是 JniUtils.getEncryptionKeyWithCheck
,其最终调用了 native 层的 libvideodec.so 库的 getEncryptionKey
方法。将其直接复制出来拖到 IDA 里看一下:
没有类似于 Java_xxx_xxx... 之类的方法,那就是动态加载了。进到 JNI_OnLoad 里一看,啊这?怀疑是混淆过了,但是用 Frida 打印 Module,却提示没有找到 libvideodec 这个库。
心想着要不就继续用 frida 算了。突然想到,会不会这个 APP 的极速版也有这个库?赶紧下载极速版下来看一下,乖乖,还真有!试着把极速版的库换到正式版上,也能用!赶紧拖到 IDA 里面看看:
一切正常,修正一下变量类型:
signed int __fastcall JNI_OnLoad(JNIEnv *a1)
{
JNIEnv *v1; // r4
jclass v2; // r5
JNIEnv *v4; // [sp+4h] [bp-14h]
v4 = 0;
if ( !(*a1)->FindClass(a1, (const char *)&v4) )
{
v1 = v4;
v2 = (*v4)->FindClass(v4, "com/**/**videoengine/JniUtils");
if ( v2 )
{
if ( (*v1)->RegisterNatives(v1, v2, (const JNINativeMethod *)off_4004, 1) >= 0 )
return 65540;
(*v1)->DeleteLocalRef(v1, v2);
}
}
return -1;
}
双击 off_4004
进去看看,确认是 getEncryptionKey 方法:
点开 getEncryptionKey 方法,同样修正一下变量类型和调用
jstring __fastcall getEncryptionKey(JNIEnv *a1, int a2, jbyteArray a3)
{
jbyteArray v3; // r5
JNIEnv *v4; // r4
jbyte *v5; // r6
int v6; // r0
jstring v7; // r4
JNIEnv *v9; // [sp+0h] [bp-18h]
int v10; // [sp+4h] [bp-14h]
v9 = a1;
v10 = a2;
v3 = a3;
v4 = a1;
v5 = (*a1)->GetByteArrayElements(a1, a3, 0);
v6 = (*v4)->GetArrayLength(v4, v3);
v9 = 0;
v10 = 0;
decodeMethodAndKey((int)v5, v6, &v9, &v10);
v7 = (*v4)->NewStringUTF(v4, (const char *)v9);
j_free(v9);
j_free(v10);
return v7;
}
剩下几个函数看着都没有大问题,直接按照伪代码来写代码吧。值得注意的是,查看了几个函数以后返回 getEncryptionKey,发现 decodeMethodAndKey 变成了这样:
decodeMethodAndKey(__PAIR__(v6, (unsigned int)v5), &v9, &v10);
这个是由于 IDA 根据后面几个函数解析以后,把这个函数的传参由 int, int, int, int 变成了 __int64, int, int,为了匹配函数形参,则必须要把两个 int(32) 转成 __int64,实际上原来的传参,就是 v5、v6 按照由低到高的字节顺序排,__PAIR__ 成 __int64 也是这个顺序,影响不大。
最后根据上面的 spade_a:
l7wZxla8K/VXvS7EYpQuxWONG8BQkCnreJEx2WCSKd56qTKqqg== 解密的结果:
和上面 Frida 调用的结果一模一样。优化一下程序,做成自动获取视频列表 + 视频地址 + 密钥解密,即可批量下载并解密。
Very nice. I was looking for this since long time. Thanks a lot man :)
I have one problem. How do you find the function in
line?
I cant find my function. IDA is not recognizing it :(
Sometimes
off_*
blocks cannot be recognized as functions with IDA, but we can still find it with keyword strings and find relative function (or recognized asoff_*
block).If you cannot find it with the way above, maybe the *.so file has been used some ways to be obscured. My IDA skill is poor, at this point I might find an old version of the app and check if it's not obscured. It always works.
Thank you. I found it. Thank you very much :D
Btw to find it you need to find the jni function signature (it's name and data types and return type), then find the cross reference to the string. IDA and ghidra both show function just beneath the signature
Thank you. That's a nice experience.
首先,很抱歉中文说得不好。我使用的是 DeepL。最近,制作了相同视频引擎的人制作了一款音乐应用程序(顺便说一句,它糟透了)
我本想破解浏览器版本的应用程序,但在查找 "spade_a "时看到了这篇文章,它帮了我大忙,您的博客帮助了全球各地的人们,我为此向您表示感谢。
damn bro am i that cool?? thx for letting me know, it's awesome
好奇怎么全部是国外网友的评论🤔。
因为这玩意可是世界闻名的软件🤫
虽然夸张了,但我跟阿婷吹牛逼的时候确是这么说的
想必阿婷一定非常仰慕眼前的这个男人。