仅供信息安全学习

起因是买的头条某圈子视频总会不定期清除,似乎只能看三个月内的内容,就想着下下来存着,以免之后找不到。一开始想着电脑开个模拟器录屏算了,但想想还是有点麻烦,干脆直接分析。

0x00 准备工作

  1. 雷电模拟器 4.0.43 32 位版本,64 位版本在我这里能运行,但登录头条失败
  2. 安装今日头条(非极速版,极速版没有圈子)
  3. 安装 Xposed 框架和 JustTrustMe 模块并开启
  4. 安装反射大师
  5. 安装终端模拟器
  6. 电脑安装 Fiddler4/Proxifier,配置 Fiddler4 Capture HTTPS CONNECTs;Proxifier 新增 Proxy 为 Fiddler4 的 Proxy,并新增 Rule:Applications 为 ld*.exe
  7. 安装 Frida,并将对应的 frida-server 放到模拟器上
  8. FFMpeg

0x01 抓视频相关 HTTP 请求

开启已经配置好了的 Fiddler4 和 Proxifier,为防止其他请求干扰,可以拖动 Fiddler4 工具栏的瞄准镜一样的小光标到 Proxifier 上,意味着仅显示从 Proxifier 来的请求;另外,在 Rules 菜单下勾选「Hide Image Request」和「Hide CONNECTs」排除干扰。

Fiddler Settings

打开雷电模拟器上的今日头条,点开想要分析的圈子,在 Fiddler 内找到 Host 为 learning.snssdk.cn 的请求

# 哪怕没有购买圈子也可以试看,也可以获取到这条 URL 请求
https://learning.snssdk.com/toutiao/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.snssdk.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",
            // 视频类型
            "vtype": "mp4",
            "vwidth": 1920,
            "vheight": 1080,
            "bitrate": 478583,
            "fps": 15,
            "codec_type": "h265",
            "size": 99655378,
            // 视频 URL
            "main_url": "aHR0cDovL3YzLnRvdXRpYW92b2QuY29tLzRkMWVjYmFiMWU2OTEyYTJjNzUzOTFjNTBmNDBmM2I5LzYxMzFhYzQxL3ZpZGVvL3Rvcy9jbi90b3MtY24tdmUtNDkvYzRmZGNlZjM2MWYxNGRjYTgzZTU5MjA0ODZmZDVmOWYvP2E9MTMmYnI9NDY3JmJ0PTQ2NyZjZD0wJTdDMCU3QzAmY2g9MCZjcj0xJmNzPTEmY3Y9MSZkcj0wJmRzPTQmZXI9MSZmdD1YRUdKWXFxM213OVBTTklUejdWRkFZaVVmVHVzTUo4TVd5Jmw9MjAyMTA5MDMxMTM0MjAwMTAyMTIxNDcwOTM0MDA0N0I0QiZscj0mbWltZV90eXBlPXZpZGVvX21wNCZuZXQ9MCZwbD0wJnFzPTAmcmM9YWpSMmNtazZaanR1TnpNek5EUXpNMEFwYURRM1p6TTRObVZwTnpVN1pETm9aMmNwYUdSemNtZDVhbXgxYURGa2QzSkFjV2N6TkhJMGJ5OXJZQzB0WkRBd2MzTXlMell6TkY0MVkxOHROQzh2Tm1BeE9tTnZkbWxjWW1ZcmEydGVhV3htY25GZyZ2bD0mdnI9",
            "backup_url_1": "aHR0cDovL3YyOS50b3V0aWFvdm9kLmNvbS9hMDQ2MzhlNDFhMGVhMzJjYzVhN2UwZjY2Mzg5YzBkNC82MTMxYWM0MS92aWRlby90b3MvY24vdG9zLWNuLXZlLTQ5L2M0ZmRjZWYzNjFmMTRkY2E4M2U1OTIwNDg2ZmQ1ZjlmLz9hPTEzJmJyPTQ2NyZidD00NjcmY2Q9MCU3QzAlN0MwJmNoPTAmY3I9MSZjcz0xJmN2PTEmZHI9MCZkcz00JmVyPTEmZnQ9WEVHSllxcTNtdzlQU05JVHo3VkZBWWlVZlR1c01KOE1XeSZsPTIwMjEwOTAzMTEzNDIwMDEwMjEyMTQ3MDkzNDAwNDdCNEImbHI9Jm1pbWVfdHlwZT12aWRlb19tcDQmbmV0PTAmcGw9MCZxcz0wJnJjPWFqUjJjbWs2Wmp0dU56TXpORFF6TTBBcGFEUTNaek00Tm1WcE56VTdaRE5vWjJjcGFHUnpjbWQ1YW14MWFERmtkM0pBY1djek5ISTBieTlyWUMwdFpEQXdjM015THpZek5GNDFZMTh0TkM4dk5tQXhPbU52ZG1sY1ltWXJhMnRlYVd4bWNuRmcmdmw9JnZyPQ==",
            "url_expire": 1630645313,
            "preload_size": 327680,
            "preload_interval": 60,
            "preload_min_step": 5,
            "preload_max_step": 10,
            // 黑桃A?
            "spade_a": "krwc9lCOAPZRvh35TYAByE2zHchRtBv6VYMc0maoGuRknyyCgg==",
            "file_hash": "686099bb24f9dc7a18a76f99c3fdc204",
            "file_id": "d4ec09a2822740fbbf385a07d64912e5",
            "p2p_verify_url": "aHR0cDovL3YzLnRvdXRpYW92b2QuY29tLzU0MWZmODgyMWEzYTk1NTc0NDAxMDVmMDljODIyZWI0LzYxMzFhYzQxL3ZpZGVvL3Rvcy9jbi90b3MtY24tdmUtNDkvYTE1ZDI5Njk1Yjc3NDQ2OTliNzliNjM3OWI5ZDFkZDIv",
            // 是否加密
            "encrypt": true,
            // 加密 KID
            "kid": "612f69cec2e1f7b1eb9e729d0046e5da",
            "fitter_info": null,
            "quality_type": 0,
            // 加密方法
            "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.snssdk.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 —— 获取解密密钥

模拟器上打开反射大师,选择今日头条并打开,点击六芒星后选择「当前ACTIVITY」,弹出窗内长按「写出DEX」按钮将当前 APP 所有 DEX 导出,我用的这个头条版本导出了十个 DEX 文件。

导出的 DEX 要反编译成 JAVA 源文件。但我试了一下,dex2jar,JByteMod 和 bytecode-viewer 都不好用,最后用 JADX 来反编译成 java 源文件,虽然部分方法反编译出错导致可读性较差,但不影响分析。BTW,可以试一下 GDA。

在源文件中查找所有和 encrypt 有关的项,最后在 com.ss.ttvideoengine.TTVideoEngine 类中找到如下的关键代码:

L_0x0ced:
    boolean r0 = r1.mUsePlayerSpade
    if (r0 != 0) goto L_0x0d2c
    // 拿 r7 来解密
    byte[] r0 = com.ss.ttvideoengine.utils.TTHelper.base64DecodeToBytes(r7)
    java.lang.String r5 = "encryption null"
    if (r0 == 0) goto L_0x0d04
    // 看得出来,这个 getEncryptionKeyWithCheck 就是获取密钥的方法了
    java.lang.String r7 = com.ss.ttvideoengine.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.ss.ttvideoengine.log.IVideoEventLogger r0 = r1.mLogger
    // r7 是 EncryptKey
    r0.setEncryptKey(r7)
    int r0 = r1.mDataLoaderEnable
    if (r0 == 0) goto L_0x0cd2
    com.ss.ttvideoengine.DataLoaderHelper r0 = com.ss.ttvideoengine.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.ss.ttvideoengine.model.IVideoModel r0 = r1.mVideoModel
    if (r0 == 0) goto L_0x0cb6
    // 这里有一个 r7 跳转 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.ss.ttvideoengine.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.ss.ttvideoengine.model.VideoInfo r0 = r1.currentVideoInfo
    r5 = 5
    // 这里有一个 r7 跳转 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
    // 这里有一个 r7 跳转 L_0x0cb7
    java.lang.String r7 = r1.mSpadea
    goto L_0x0cb7

三个 Label 内的内容有两个都指向了这个 Spadea,另外一个是 getValueStr("5") 来的,搜一下相关代码:

// com.ss.ttvideoengine.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.ss.ttvideoengine.JniUtils');
let TTHelper = Java.use('com.ss.ttvideoengine.utils.TTHelper');
// spadeA 取自 video_model 内视频对象的 spade_a
let spadeA = 'l7wZxla8K/VXvS7EYpQuxWONG8BQkCnreJEx2WCSKd56qTKqqg==';

// 手动解密
console.log('SpadeA decryption: ' 
    + JniUtils.getEncryptionKeyWithCheck(TTHelper.base64DecodeToBytes(spadeA)));

// 程序解密
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 里看一下:

libvideodec

没有类似于 Java_xxx_xxx... 之类的方法,那就是动态加载了。进到 JNI_OnLoad 里一看,啊这?怀疑是混淆过了,但是用 Frida 打印 Module,却提示没有找到 libvideodec 这个库。

frida-unable-to-find-module

心想着要不就继续用 frida 算了。突然想到,会不会头条极速版也有这个库?赶紧下载极速版下来看一下,乖乖,还真有!试着把极速版的库换到正式版上,也能用!赶紧拖到 IDA 里面看看:

libvideodec-2

一切正常,修正一下变量类型:

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/ss/ttvideoengine/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 方法:

off_4004

getEncryption([B)Ljava/lang/String;

点开 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== 解密的结果:

out

运气好,居然一次就成功了

和上面 Frida 调用的结果一模一样。优化一下程序,做成自动获取圈子视频列表 + 视频地址 + 密钥解密,即可批量下载并解密。

App

还真像那么回事儿