抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

今天我要写个大一点的——aliplayer,这货说难也不难,可说简单也不简单,至少是个体力活,下面一点点来。

准备工作

基本信息

目标网站:https://www.ttgame.net/course/video/1
目标的帮助文档:https://www.alibabacloud.com/help/zh/doc-detail/125579.htm
目标JavaScript:
https://g.alicdn.com/de/prismplayer/2.9.7/aliplayer-min.js
https://g.alicdn.com/de/prismplayer/2.9.7/hls/aliplayer-hls-min.js
https://g.alicdn.com/de/prismplayer/2.9.7/hls/aliplayer-vod-min.js
https://g.alicdn.com/de/prismplayer/2.9.7/hls/aliplayer-vod-anti-min.js
目前最新版本的为 2.9.8,但按照经验,为确保兼容各版本差距很小。
本文主要针对私有加密,对于DRM加密还没有涉及。
读完帮助文档后在开始工作,很多东西都是帮助文档直接写的,可要调试获知便浪费了很多时间。

Override

覆盖后有很多优点,比如控制台输出、固定修改,开始的时候也担心新版会有很多差别,其实差别很小。
在本地创建同URL的路径,然后导入到Overrides中。
需要注意,网站使用的是 2.8.2 版本的 aliplayer,替换为 2.9.7 版本并不影响工作。
这里可以创建两个文件夹,一个是 2.8.2 一个是 2.9.7,可两个都保存着 2.9.7 版本的 JavaScript。
当然在aliplayer-min.js直接修改版本号h5Version: 2.9.7更简单。

是谁处理的 key?

未末大佬的启发

看到了有 m3u8 请求,最自然的思路就是:肯定是在 m3u8 请求之后获取到了 key

进入LoadInternal 之后,一步一步的调试,发现没有任何与 key 本身解密相关的内容。
反而是直接在请求完m3u8之后,请求.ts,然后就出现了 key

这让我走了很多弯路,回头看才发现在请求 m3u8 之前就产生了 key

分析调用堆栈

注意上上图中的唯一一个匿名函数,拿出一看,正好就是原文中下断的函数。

其中 i._sce_dlgtqredxx 就是真正的 key。看到函数名initPlay,提醒我可能需要从播放器初始化之前找。
于是把目光放在了调用堆栈上,因为后面找不到谁处理的 key,那一定在 m3u8 请求之前。
然后在每个调用堆栈的点下断,应该能找到线索,这里随便找个下断:

仔细查看调用堆栈的每一次调用,发现:
最开始调用initPlay函数的是loadData这个函数,所以,initPlay 中的变量o,也就是key一定是该函数中的变量。在h.initPlay(l)处下断,代码如下:

上图中选中的函数n = _sce_dlgtqred(u, r.rand, r.plaintext)就是真正的 Key 处理函数了。

如何计算出的 Key?

1
n = _sce_dlgtqred(u, r.rand, r.plaintext)

该函数的请求有三个参数,u 是个未知串。
r.rand, r.plaintext
这两个参数有点眼熟,发现是https://vod.cn-shanghai.aliyuncs.com/?AccessKeyId=...的返回。
它们到底是怎么请求的以及 u 是如何产生的放到后面再看,先来看 Key 是如何计算出来的。

jsvmportal_0_3

按 F11 步入 Key 的计算函数 jsvmportal_0_3

1
2
3
4
5
6
7
var jsvmportal_0_3 = function() {
var inout = arguments, retval;
jsvm_this_run(function() {
return eval(arguments[0])
}, 2);
return retval
};

jsvm_this_run 函数做了严重的混淆,使用了一个 switch 函数将所有步骤分解,使得查看过程异常艰难。
好记性不如烂笔头,将每一步的操作都记录下来,整个过程大概做了三天,下面捡重点说一下。

初识 jsvm_this_run

最开始看到这个函数完全不知所措,不知道每一步做什么,经过单步调试许久之后,发现了几个关键地方:

  1. d 这个参数指示接下来的处理流程,会在每次操作完成后重置
  2. s 这个参数包含了很多固定参数和函数(名),像是一个仓库
  3. o 这个参数包含了将要进行处理的或者刚刚处理过的函数(名)、参数等
  4. h 这个参数包含处理结果,是很多数据的最终存放位置
  5. u 这个参数前面几个参数表示该函数调用做的事情,后面几个参数常常是类似于 o,最后一个参数会用来存放函数调用结果。

尝试总结一下数据和操作的处理过程为:
s 中取出参数和函数名放入 o/u 中,使用 eval() 执行函数得到结果,存入 o/u 中再存入 h 中。
s 也并非单向的,有时候会把 s 中的函数名转为函数后再存入供以后使用。
经过仔细分析后,发现断点应该放在如下位置,第一个断点决定 d,第二个断点做真正的操作:

然后开始 F8 调试,如果不知所云,先看 F8 过一遍大概要进行的操作,做一下大概的记录,然后再来一遍做详细分析。

case 6

函数将在这里面进行真实的操作,这也是 switch 函数,其中有几个重要的 case 拿出来看看:

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
w = jsvm_this_insns[d];
switch (T) {
case 26:
V = w >> 7 & 31;
S = w >> 12 & 31;
L = w >> 17 & 31;
try {
o[V] = o[S][o[L]] // 类似于 eval()
} catch (r) {
...
}
break;
case 37:
...
if (typeof o[V]["jsvmfunc"] == "number") {
...
} else {
v = [];
l = loaddata(S);
for (c = 0; c < L; c++) {
v.push(loaddata(S + L - c))
}
if (typeof o[V] == "function") {
u.push(o[V].apply(l, v)) // 执行函数的调用
}
}
break;
case 42:
V = w >> 7 & 31;
S = w >> 12 & 31;
o[V] = r("" + o[S]); // 这里的函数 r() === eval()
break;

为了方便可以直接条件断点 T==37,下面为了详细了解过程,就是用上面断点,不断按下 F8 F10 连续查看操作。

d 0

这步操作用于产生 s ,具体的细节没有过多了解,不过应该和 jsvm_this_initialization() 有关。

d 450

这步操作计算了未知串的 MD5,计算结果返回的是自定义的数据结构,类似于 Int32Array+SigBytes,并存放到了 h[9] 中。

d 520

把上一步产生的 MD5 转成了 HEX,也就是我们常见的 MD5 格式。

d 592

对上一步的 MD5 做了切片操作,取出了 [8:24],看这个长度,貌似要用来做 key 或者 iv 啊。
在 d == 620 左右的时候,取出了一个关键名字 CryptoJS
在 d == 672 左右把切片转成了 array,不过不是视为 HEX 转的,而是每个字符独立转换的,所以长度是 16B。
在 d == 750 左右把 r.rand, r.plaintext 做了去除 \r\n 的处理,目前没发现过带 \r\n 的。
在 d == 780 左右,得到了 AES 的加密解密函数,并进一步在 d == 787 左右,取得了解密函数。
在该函数的位置下断,以防错过。

在 d == 810 左右取出了切片并赋给了 iv。
在 d == 820 左右取出了 MODE_CBC。

d 896

在这里做了 AES.decrypt() 操作,如下图:

值得注意的是,此处 key == iv,其中的需要解密的字符串经过比对正是 r.rand
解密后返回了一个 array[8] 有效长度为 19B 。
在 d == 942 时,把解密出来的 array 转成了 utf8,截取有效长度,结果是一个有点长的数字串。
在 d == 1022 左右,又取出了 MD5 函数。
在 d == 1040 左右,把未知串和解密后的数字串拼接在一起了。

d 1044

对拼接在一起的字符串做 MD5。
在 d == 1075 时,取出了 toString() 函数,看来又要转为 HEX 了。
在 d == 1102 时,把 MD5 的 array 转成了 HEX。 在 d == 1128
前面的时候,又取出了 subString() 函数,估计又是切片。
在 d == 1174 时,做了切片,还是取出 [8:24] 位。
在 d == 1125 时,又把切片转成了 array。根据上面的经验,估计要用来做 key 或者 iv。
至此关键参数都拿到了,中间过程就就省略了,直接在 case 37 下断,查看 AES 解密过程。

d 1402

其中需要解密的字符串就是 r.plaintext,key 是第二个切片,iv 是第一个切片。
解密出来的内容经过测试,正是 m3u8 文件的真实 key。

小结

这将被不会是惟一一次遇到这坨 switch,后面的就简要描述了,就是比较费体力。

参数都是哪来的?

上面已经把解密的过程搞清楚了,可用来解密的三个参数是怎么来的呢?
通过观察发现,后两个参数是向 ali 服务器请求得到的,那么请求的参数是如何组织的呢?
下面先来看一下上文中屡次提到的未知串是如何产生的。

未知串的生成

把目光聚焦到未知串 u 的调用的地方,往上看:

参数

u 就是这里产生的, 下断后 F11 进去看看:

此时,上次的断点又起作用了,这次调用的是 jsvmportal_0_2(){...}
看来这个未知串也是在这一坨 switch 中产生的。

再探 jsvm_this_run

当 d < 10 时,初始化了 s,然后从 s 中取出了一个固定串 ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz234567890
在 d == 122 之前,取出了 charAt() 函数。
在 d == 133 之前,取出了 floor() 函数。
在 d == 138 ~ 159 之间,取出了 random() 函数。
看这架势貌似未知串是一串随机的字符串,可如果真的是随机的,那如何用这串字符来解密验证服务器返回的内容的呢?
先不考虑这个问题,接着看: 在 d == 159 时,获得了一个随机数。
这里需要格外关注一个 case 50

1
2
3
4
5
6
case 50:
V = w >> 7 & 31;
S = w >> 12 & 31;
L = w >> 17 & 31;
o[V] = o[S] * o[L];
break;

在 d == 202 之前,使用 _maxPos1 == len(‘ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz234567890’) 和随机数做了乘法。
在 d == 202 时,对这个乘积做了 floor()
在 d == 238 时,使用这个截断的乘积作为下标,在固定串中做了 charAt(),返回了一个字符。
接下来的操作便是重复上面的过程,知道字符数量到达 16 个。
由此看出,未知串确实是随机串,那么要想能够用来解密,必然要通过某种方式传递这个随机串到请求服务器,才能下发 rand, plaintext

请求参数都有谁?

因为原请求链接是非常复杂的,很多 % 都被编码为 %25 了。
更详细的操作需要参照源码一点点修改,那么接下来主要是:找到这些参数的位置,并依次生成这些参数。
方便起见,把链接 urldecode(后面再讨论 urlencode 问题)

https://vod.cn-shanghai.aliyuncs.com/?
AccessKeyId=STS.NTQjdSinEj5w48GbKhRKRqnbz&
Action=GetPlayInfo&
AuthInfo={"CI":"zWr678OZfpobCs5lUn/fJJGj6uJwbzzBhRcO73/ef+lSP6mn+cJ94v2lEjpPbpP1","Caller":"bvVtjHFVBjo9sajhvizAu3UfAhXkdP7eXp72anfOvME=","ExpireTime":"2021-08-12T13:16:25Z","MediaId":"e3d536a098784c0fa612815d816ac9ae","PlayDomain":"video.87fun.com","Signature":"uG8uAkGT+aRCemRu/WqZgq/F53g="}&
AuthTimeout=7200&
Channel=HTML5&
Format=JSON&
Formats=&
PlayConfig={"PreviewTime":0}&
PlayerVersion=2.8.2&
Rand=AQi7CV1eVvU1wc3C97wCzXpLlgTpXO3Jck1rF/w94qQeDNw0PwcYNJx3SBG9mV44wxhUai8jddgn5dEfuvl2GA==&
ReAuthInfo={}&
SecurityToken=CAIShwN1q6Ft5B2yfSjIr5fkId7nhLFk3fecNh72hks9XsR+nqvJmDz2IH1EfnloAOkasPwzlWtR6fwelrMqGsIeGRWVPZIhtcgKqFLwJpLFst2J6r8JjsUG6JQv0FmpsvXJasDVEfl2E5XEMiIR/00e6L/+cirYpTXHVbSClZ9gaPkOQwC8dkAoLdxKJwxk2t14UmXWOaSCPwLShmPBLUxmvWgGl2Rzu4uy3vOd5hfZp1r8xO4axeL0PoP2V81lLZplesqp3I4Sc7baghZU4glr8qlx7spB5SyVktyWGUhJ/zaLIoit7NpjfiB0eoQAPopFp/X6jvAawPLUm9bYxgphB8R+Xj7DZYaux7GzeoWTO80+aKzwNlnUz9mLLeOViQ4/Zm8BPw44ELhIaF0IUExzFmqCd/X4ogyQO17yGpLoiv9mjcBHqHzz5sePKlS1RLGU7D0VIJdUbTlzak5MjTS4K/NYK1AdKAo4XeqPMax3bQFDr53vsTbbXzZb0mptuPnzd1QICFfMlEeUGoABbNIXgnHlqfHlVA1TgyZXZgjybrSAwac2kXbomEgSloqUn9x6DRjvX0orokFsSnEd99afVKsEcThWe06Jqowh1CDvkk8NAKwBVFSq/jmap76vkWKmMoix1Rym9Y9IsoRE8t61+RGWa+MiADACbO9Nr2muSNDNy42+BUuoIdtLPOg=&
SignatureMethod=HMAC-SHA1&
SignatureNonce=668f517b-6031-495d-8a70-8a2456a2f563&
SignatureVersion=1.0&
StreamType=video&
Version=2017-03-21&
VideoId=e3d536a098784c0fa612815d816ac9ae&
Signature=V2JtxHFLxalJXyMSoVIkCyty70s=

playauth

根据帮助文档 playauth 是在页面中的,包含了请求的很多参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"SecurityToken": "CAIShwN1q6Ft5B2yfSjIr5fkId7nhLFk3fecNh72hks9XsR+nqvJmDz2IH1EfnloAOkasPwzlWtR6fwelrMqGsIeGRWVPZIhtcgKqFLwJpLFst2J6r8JjsUG6JQv0FmpsvXJasDVEfl2E5XEMiIR/00e6L/+cirYpTXHVbSClZ9gaPkOQwC8dkAoLdxKJwxk2t14UmXWOaSCPwLShmPBLUxmvWgGl2Rzu4uy3vOd5hfZp1r8xO4axeL0PoP2V81lLZplesqp3I4Sc7baghZU4glr8qlx7spB5SyVktyWGUhJ/zaLIoit7NpjfiB0eoQAPopFp/X6jvAawPLUm9bYxgphB8R+Xj7DZYaux7GzeoWTO80+aKzwNlnUz9mLLeOViQ4/Zm8BPw44ELhIaF0IUExzFmqCd/X4ogyQO17yGpLoiv9mjcBHqHzz5sePKlS1RLGU7D0VIJdUbTlzak5MjTS4K/NYK1AdKAo4XeqPMax3bQFDr53vsTbbXzZb0mptuPnzd1QICFfMlEeUGoABbNIXgnHlqfHlVA1TgyZXZgjybrSAwac2kXbomEgSloqUn9x6DRjvX0orokFsSnEd99afVKsEcThWe06Jqowh1CDvkk8NAKwBVFSq/jmap76vkWKmMoix1Rym9Y9IsoRE8t61+RGWa+MiADACbO9Nr2muSNDNy42+BUuoIdtLPOg=",
"AuthInfo": "{\"CI\":\"zWr678OZfpobCs5lUn/fJJGj6uJwbzzBhRcO73/ef+lSP6mn+cJ94v2lEjpPbpP1\",\"Caller\":\"bvVtjHFVBjo9sajhvizAu3UfAhXkdP7eXp72anfOvME=\",\"ExpireTime\":\"2021-08-12T13:16:25Z\",\"MediaId\":\"e3d536a098784c0fa612815d816ac9ae\",\"PlayDomain\":\"video.87fun.com\",\"Signature\":\"uG8uAkGT+aRCemRu/WqZgq/F53g=\"}",
"VideoMeta": {
"Status": "Normal",
"VideoId": "e3d536a098784c0fa612815d816ac9ae",
"Title": "GOM_JS_01_DBC2000.mp4",
"CoverURL": "https://video.87fun.com/e3d536a098784c0fa612815d816ac9ae/snapshots/b114172681bb4d31ad9544c19eae9a33-00005.jpg",
"Duration": 289.066
},
"AccessKeyId": "STS.NTQjdSinEj5w48GbKhRKRqnbz",
"PlayDomain": "video.87fun.com",
"AccessKeySecret": "B198GPkJVhTjn5QNfbaXDTbNt2xBN5k6uy2xha3unNeE",
"Region": "cn-shanghai",
"CustomerId": 1953419819345199
}

根据关键词 send()/2017-03-21/HMAC-SHA1/Signature 之类的定位:

SignatureNonce 对应的是 randomUUID(),是一个很简单的随机 UUID 生成器:

那么至此,就还剩两个参数没有搞定:RandSignature

Rand

这个参数的获取就在随机串的函数之后,也是调用了 switch 混淆的。

这次调用的是 aliplayer-vod-min.js 中的最后一个函数。

这个函数竟然需要调用两次 switch 要疯了。仔细观察,貌似这是一个加密或者解密操作,需要用到 KEY。

jsvm_this_run 四进宫

进入后看到 u 的参数发现这里貌似是在组成 RSA 的公钥。已经后很多个串了,都是从 s 中取出来的。
下面就不跟了,还是利用以前的断点,按几次 F8 就能在 h 中找到公钥了。
在 d == 1935 之前,在 h[22]
中就已经获得完整的公钥,给出的字符串并没有都使用:

-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIcLeIt2wmIyXckgNhCGpMTAZyBGO+nk0/IdOrhIdfRR
gBLHdydsftMVPNHrRuPKQNZRslWE1vvgx80w9lCllIUCAwEAAQ==
-----END PUBLIC KEY-----

得到公钥后把它复制给了 _sce_lgtcaygl 中的几个带 _key 的参数中,然后就返回了。
下面就是四进宫了,这次终于比较简单:
在 d == 343 之前,取得了 RSA 公钥。
在 d == 343 时,使用这个公钥初始化了一个 posdk 类 _vndhsje,使用这个类加密某些内容。
在 d == 374 之前,取得了随机串,这个随机串也被存储到了 s[137] 中。
在 d == 374 时,执行加密。 <WRAP center round important 80%> RSA 加密每次的结果都是不相同的。那如何判断使用了哪种 padding 呢?如果单步执行不难发现是 PKCS1_V1_5,或者可以直接搜索关键词 pkcs/oaep,后者无结果,前者相关的有一个函数正好位于 vnskak 中这就是 posdk 类;此外还可以用请求尝试的方式,不正确的 padding 会提醒 SignatureNonce 失效。

Signature

把其他的断点都勾掉,在上上图中的 下面代码处 下断,因为该处生成了 Signature。

1
e = n.makeUTF8sort(e, "=", "&") + "&Signature=" + n.AliyunEncodeURI(n.makeChangeSiga(e, s.accessSecret)),
e

是请求链接中除最后一个参数外的其余所有参数 urlencode 的字符串,类似于字典的结构,但无论 key,value 都 urlencode,然后使用 =& 拼接起来,这是请求链接前面的部分,后面的就是签名:

其中函数 n() 就是 HMAC-SHA1 算法,o.stringify() 是 base64 加密算法。
n() 的第二个参数是 key,也就是说 accessSecret + '&‘ 就是 HMAC 的 key。
最后把目光放在需要签名的内容上:其实是在 makeUTF8sort() 的基础上又做了一次 urlencode,然后在前面加上 GET&/&

实现请求

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
import requests
import re
import json
import random
import math
import hmac
import hashlib
import base64
import urllib.parse
import uuid
import time
from lxml import etree
from base64 import b64decode, b64encode
from Crypto.Cipher import PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES

db = {}
file = open('data.json', 'w')


def allinone(customurl, num):
print(num)
data = {}
headers = {'Pragma': 'no-cache', 'Cache-Control': 'no-cache',
'sec-ch-ua': '"Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"',
'sec-ch-ua-mobile': '?0',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/92.0.4515.131 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,'
'*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document',
'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', }

r = requests.get(customurl, headers=headers)
page = r.text

htmlpage = etree.HTML(page)
data["Title"] = htmlpage.xpath('//title')[0].text
try:
data["Introduce"] = b64encode(etree.tostring(htmlpage.xpath('//div[@id="introduce"]')[0])).decode('utf8')

playauthmatch = re.search(r"playauth\W+'.*'", page[page.index('new Aliplayer'):])

playauth = playauthmatch.group(0)
playauth = playauth[playauth.index('eyJ'):-1]

b64playauth = b64decode(playauth.encode('utf8'))

playauth = json.loads(b64playauth.decode('utf8'))

playauth["AuthInfo"] = json.loads(playauth["AuthInfo"])
st = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz234567890'
randstr = ''

for ii in range(16):
randstr += st[math.floor(random.random() * 50)]

message = randstr.encode('utf8')
key = RSA.import_key(open("rand.pem").read())
cipher = PKCS1_v1_5.new(key)
ciphertext = cipher.encrypt(message)
rand = b64encode(ciphertext)
rand = rand.decode('utf8')

reqdict = {}
reqdict["AccessKeyId"] = playauth["AccessKeyId"]
reqdict["Action"] = "GetPlayInfo"
playauth["AuthInfo"] = json.loads(b64playauth.decode('utf8'))["AuthInfo"]
reqdict["AuthInfo"] = urllib.parse.quote_plus(playauth["AuthInfo"])
reqdict["AuthTimeout"] = 7200
reqdict["Channel"] = "HTML5"
reqdict["Format"] = "JSON"
reqdict["Formats"] = ""
reqdict["PlayConfig"] = urllib.parse.quote_plus('{"PreviewTime":18000}')
reqdict["PlayerVersion"] = "2.9.7"
reqdict["Rand"] = urllib.parse.quote_plus(rand)
reqdict["ReAuthInfo"] = urllib.parse.quote_plus('{}')
reqdict["SecurityToken"] = urllib.parse.quote_plus(playauth["SecurityToken"])
reqdict["SignatureMethod"] = "HMAC-SHA1"
reqdict["SignatureNonce"] = uuid.uuid4()
reqdict["SignatureVersion"] = 1.0
reqdict["StreamType"] = "video"
reqdict["Version"] = "2017-03-21"
reqdict["VideoId"] = json.loads(playauth["AuthInfo"])["MediaId"]

e = 'GET&%2F&' + urllib.parse.urlencode(reqdict).replace('=', '%3D').replace('&', '%26')

key1 = bytes(playauth["AccessKeySecret"] + '&', 'UTF-8')
message1 = bytes(e, 'utf8')
digester = hmac.new(key1, message1, hashlib.sha1)
signature1 = digester.digest()
signature = str(base64.b64encode(signature1), 'utf8')
reqdict["Signature"] = urllib.parse.quote_plus(signature)

requrl = 'https://vod.' + playauth["Region"] + '.aliyuncs.com/?' + urllib.parse.urlencode(reqdict)
.replace('%25', '%')
aliheaders = {'Pragma': 'no-cache', 'Cache-Control': 'no-cache',
'sec-ch-ua': '"Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"',
'sec-ch-ua-mobile': '?0',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/92.0.4515.131 Safari/537.36',
'Accept': '*/*', 'Origin': '', 'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': '',
'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}
parseurl = urllib.parse.urlparse(customurl)
aliheaders['Origin'] = aliheaders['Referer'] = parseurl.scheme + '://' + parseurl.netloc
req = requests.get(requrl, headers=aliheaders)

aliresp = req.text
aliresp = json.loads(aliresp)
videoinfo = aliresp['PlayInfoList']['PlayInfo'][0]

data["origin"] = aliresp["VideoBase"]["Title"]
data["CoverUrl"] = aliresp["VideoBase"]["CoverURL"]
data['Videoinfo'] = videoinfo

randkey = hashlib.md5()
randkey.update(bytes(randstr, 'utf8'))
iv = bytes(randkey.hexdigest()[8:24], 'utf8')

if videoinfo['Encrypt'] == 1 and videoinfo['EncryptType'] == 'AliyunVoDEncryption':
randnum = AES.new(iv, AES.MODE_CBC, iv=iv).decrypt(
b64decode(bytes(videoinfo['Rand'], 'utf8'))).rstrip(b'\x0c').rstrip(b'\r')
rand = bytes(randstr, 'utf8') + randnum
m = hashlib.md5()
m.update(rand)
key = bytes(m.hexdigest()[8:24], 'utf8')
finalkey = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(
b64decode(bytes(videoinfo['Plaintext'], 'utf8'))).rstrip(b'\x08')
data['Key'] = b64encode(finalkey).decode('utf8')
except Exception:
return
db[num] = data
time.sleep(2)


baseurl = r'https://www.ttgame.net/course/video/'

for i in range(1, 638):
allinone(baseurl + str(i), str(i))

datajson = json.dumps(db)
print(db)
json.dump(datajson, file)
file.close()

请求返回

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
{
"VideoBase": {
"Status": "Normal",
"VideoId": "e3d536a098784c0fa612815d816ac9ae",
"TranscodeMode": "FastTranscode",
"CreationTime": "2020-01-20T01:59:29Z",
"Title": "GOM_JS_01_DBC2000.mp4",
"MediaType": "video",
"CoverURL": "https://video.87fun.com/e3d536a098784c0fa612815d816ac9ae/snapshots/b114172681bb4d31ad9544c19eae9a33-00005.jpg",
"Duration": "289.066",
"OutputType": "cdn"
},
"RequestId": "3E7A2C63-C9D7-5C78-9C38-618FCC8491D3",
"PlayInfoList": {
"PlayInfo": [
{
"Status": "Normal",
"StreamType": "video",
"Rand": "hxg2Eg/z0dNXXOnkBm/6er7RJArXR2Ev/j1s8tHtK2A=",
"Size": 16558852,
"Definition": "OD",
"Fps": "30",
"Duration": "289.0565",
"ModificationTime": "2020-01-20T01:59:41Z",
"Specification": "Segmentation",
"Bitrate": "458.286",
"Encrypt": 1,
"PreprocessStatus": "UnPreprocess",
"Format": "m3u8",
"EncryptType": "AliyunVoDEncryption",
"PlayURL": "https://video.87fun.com/e3d536a098784c0fa612815d816ac9ae/be5d488683d445898197978de4a69cf5-3f67ac71ee44d93c9b0aa1318b691264-od-S00000001-100000-encrypt-stream.m3u8",
"NarrowBandType": "0",
"Plaintext": "Is5rfIvn2me+mc0V6E7ygYtwDvhoD3kt1q/IXFwy698=",
"CreationTime": "2020-01-20T01:59:35Z",
"Height": 720,
"Width": 1280,
"JobId": "026ebcdb3d4348c0868fadbbee9d6cd4"
}
]
}
}

这里有想要的一切。


ts 文件是如何解密的?

key 对吗?

下面要做的,首先判断得到的 key,可用于解密的 key 是否一致。
this.callbacks.onSuccess(h, i, a, e) 下断并当请求的为 ts 文件时 F11 进入。

再 F11:

再 F11:

注意,这里 c 中包含7个函数,所以会调用7次:

这是一个事件响应,里面做的事情很多,当执行到第5个函数的时候,进入了
blob:

至于这些函数做了什么事情,大概是对 ts 文件做了一些预处理,但文件的内容没有任何更改,具体的解密操作需要到 blob 中。
而对音视频文件的 decode 也在 blob 中。
下面对进入 blob 的入口位置加入断点,F11 直接进入:

条件不满足,执行 else:

当跟到下面的时候就可以确定,确实是用这个 key 解密了:

ts 解密流程

先来分析一下 ts 的文件结构,因为这部分有点跑题,所以另起一篇。
在上上图中函数的执行过程中,执行到最后一段,这是一段准备解密环境的内容:

其中最后一行 F11 进入:

这就是 ts 文件的解密方式的关键函数了。经过大概两三天的分析 ts 文件,终于搞明白了,然后模仿着写了一段解密代码。下面总结一下流程

  1. 一上来先 parse 了很多变量,主要是各种 pid,包括视频、音频、PMT 等
  2. 然后开始循环,每次处理 0xbc 长度的内容,并且对每种数据类型都有一个数组,保存着需要解密的内容
  3. 在每次循环开始的时候,首先取出 payload 位,以后每次遇到 payload 都要执行解密并初始化解密函数
  4. 有 adaptation_field 的时候,跳过 adaptation_field,不存储到需要解密数组中
  5. 如果没有遇到 payload,就一直往各自类型的数组里面存储内容
  6. 当遇到 payload == 0x1,把需要解密数组的内容先做 parsePES,然后去掉 pes header,剩下的部分解密
  7. 当数据 % 16 != 0 时,去掉余数,然后保存到数组的对应位置,重置解密函数
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
//  真实的解密函数
function l(t, e) {
try {
var r = (new Date).valueOf()
, i = new d.ModeOfOperation.ecb(e) // 创建了一个 AES-ECB 函数
, n = t;
if (t.length % 16 > 0) {
var a = 16 * parseInt(t.length / 16); // 去掉余数
n = n.slice(0, a),
n = i.decrypt(n), // 每 16B 解密一次
t.set(n, 0);
var o = (new Date).valueOf();
return h.b.log("parse pes extra time:" + (o - r)),
t
}
n = t,
n = i.decrypt(t); //
var o = (new Date).valueOf();
return h.b.log("parse pes extra time:" + (o - r)),
n
} catch (e) {
return t
}
},

//
t.prototype._parsePAT = function(t, e) {
return (31 & t[e + 10]) << 8 | t[e + 11] // 返回 PMT 的 PID
}
//
t.prototype._parsePMT = function(t, e, r, i) {
var n = void 0
, a = void 0
, o = void 0
, s = void 0
, f = {
audio: -1,
avc: -1,
id3: -1,
isAAC: !0
};
for (n = (15 & t[e + 1]) << 8 | t[e + 2],
a = e + 3 + n - 4,
o = (15 & t[e + 10]) << 8 | t[e + 11],
e += 12 + o; e < a; ) {
switch (s = (31 & t[e + 1]) << 8 | t[e + 2], // 取出 stream id 对应的 element pid
t[e]) { // 给 stream id 对应的媒体类型赋予 PID,这个 PID 将用于判断每个包中的数据类型
case 207:
if (!i) {
h.b.log("unkown stream type:" + t[e]);
break
}
case 15: // stream id == 0x0f 表示这是 AAC 音频
-1 === f.audio && (f.audio = s);
break;
case 21: // stream id == 0x15 表示这是 MP3 音频
-1 === f.id3 && (f.id3 = s);
break;
case 219:
if (!i) {
h.b.log("unkown stream type:" + t[e]);
break
}
case 27: // stream id == 0x1b 表示这是 AVC 视频
-1 === f.avc && (f.avc = s);
break;
case 3:
case 4:
r ? -1 === f.audio && (f.audio = s,
f.isAAC = !1) : h.b.log("MPEG audio found, not supported in this browser for now");
break;
case 36:
h.b.warn("HEVC stream type found, not supported for now");
break;
default:
h.b.log("unkown stream type:" + t[e])
}
e += 5 + ((15 & t[e + 3]) << 8 | t[e + 4])
}
return f
},
//
t.prototype._parsePES = function(t) {
var e = 0
, r = void 0
, i = void 0
, n = void 0
, a = void 0
, o = void 0
, s = void 0
, f = void 0
, c = void 0
, u = t.data;
if (!t || 0 === t.size)
return null;
for (; u[0].length < 19 && u.length > 1; ) { // 如果第一个包中的数据不够一个 PES header
var d = new Uint8Array(u[0].length + u[1].length);
d.set(u[0]),
d.set(u[1], u[0].length), // 合并两个包
u[0] = d,
u.splice(1, 1)
}
if (r = u[0],
1 === (r[0] << 16) + (r[1] << 8) + r[2]) {
if ((n = (r[4] << 8) + r[5]) && n > t.size - 6)
return null;
i = r[7], // 下面几行是一些时间戳操作,与 decode 有关,忽略
192 & i && (s = 536870912 * (14 & r[9]) + 4194304 * (255 & r[10]) + 16384 * (254 & r[11]) + 128 * (255 & r[12]) + (254 & r[13]) / 2,
s > 4294967295 && (s -= 8589934592),
64 & i ? (f = 536870912 * (14 & r[14]) + 4194304 * (255 & r[15]) + 16384 * (254 & r[16]) + 128 * (255 & r[17]) + (254 & r[18]) / 2,
f > 4294967295 && (f -= 8589934592),
s - f > 54e5 && (h.b.warn(Math.round((s - f) / 9e4) + "s delta between PTS and DTS, align them"),
s = f)) : f = s),
a = r[8], // 取得 PES header 中循环部分的长度
c = a + 9, // 加上 pes header 长度
t.size -= c,
o = new Uint8Array(t.size);
try {
for (var b = 0, g = u.length; b < g; b++) {
r = u[b];
var v = r.byteLength;
if (c) {
if (c > v) {
c -= v;
continue
}
r = r.subarray(c), // 取得从 c 开始的子 array
v -= c,
c = 0
}
o.set(r, e), // 把这个子 array 放在 o 里面,这就是要进行解密的内容 了
e += v
}
} catch (t) {
console.log(t)
}
return n && (n -= a + 3),
p && l && (o = l(o, p)), // 调用函数 l() 进行 AES 解密操作
{
data: o,
pts: s,
dts: f,
len: n
}
}
return null
},



// 对整个 ts 文件的处理函数
t.prototype.append = function(e, r, i, n) {
var a = void 0
, o = e.length
, f = void 0
, c = void 0
, d = void 0
, l = void 0
, p = !1;
this.contiguous = i;
var b = this.pmtParsed
, g = this._avcTrack
, v = this._audioTrack
, y = this._id3Track
, m = g.pid
, _ = v.pid
, w = y.pid
, S = this._pmtId
, E = g.pesData
, A = v.pesData
, T = y.pesData
, R = this._parsePAT
, k = this._parsePMT
, D = this._parsePES
// 下面的 parse 和 decode 有关,忽略
, I = this._parseAVCPES.bind(this)
, M = this._parseAACPES.bind(this)
, x = this._parseMPEGPES.bind(this)
, L = this._parseID3PES.bind(this)
, O = t._syncOffset(e);
for (o -= (o + O) % 188,
a = O; a < o; a += 188)
if (71 === e[a]) {
if (f = !!(64 & e[a + 1]),
c = ((31 & e[a + 1]) << 8) + e[a + 2],
(48 & e[a + 3]) >> 4 > 1) {
if ((d = a + 5 + e[a + 4]) === a + 188)
continue
} else
d = a + 4;
switch (c) {
case m: // AVC 解密
// 当 payload == 0x1 的时候开始 parse PES,然后顺便解密
f && (E && (l = D(E)) && void 0 !== l.pts && I(l, !1),
// 把需要解密数据的类置为空
E = {
data: [],
size: 0
}),
// 如果存在 解密数据类,就把 PES 数据整个放入其中,以备后面 parse 和解密
E && (E.data.push(e.subarray(d, a + 188)),
E.size += a + 188 - d);
break;
case _: // AAC 的解密
f && (A && (l = D(A)) && void 0 !== l.pts && (v.isAAC ? M(l) : x(l)),
A = {
data: [],
size: 0
}),
A && (A.data.push(e.subarray(d, a + 188)),
A.size += a + 188 - d);
break;
case w:
f && (T && (l = D(T)) && void 0 !== l.pts && L(l), // MP3 的解密
T = {
data: [],
size: 0
}),
T && (T.data.push(e.subarray(d, a + 188)),
T.size += a + 188 - d);
break;
case 0:
f && (d += e[d] + 1),
S = this._pmtId = R(e, d); // 遇到了 PAT,parse 一下
break;
case S: // 遇到了 PMT,给上面的一些操作做些清理,然后重置 AES,重新开始解密
f && (d += e[d] + 1);
var C = k(e, d, !0 === this.typeSupported.mpeg || !0 === this.typeSupported.mp3, null != this.sampleAes);
m = C.avc,
m > 0 && (g.pid = m),
_ = C.audio,
_ > 0 && (v.pid = _,
v.isAAC = C.isAAC),
w = C.id3,
w > 0 && (y.pid = w),
p && !b && (h.b.log("reparse from beginning"),
p = !1,
a = O - 188),
b = this.pmtParsed = !0;
break;
case 17:
case 8191: // 处理 SDT 和 空包
break;
default:
p = !0
}
} else
this.observer.trigger(s.a.ERROR, {
type: u.b.MEDIA_ERROR,
details: u.a.FRAG_PARSING_ERROR,
fatal: !1,
reason: "TS packet did not start with 0x47"
});
E && (l = D(E)) && void 0 !== l.pts ? (I(l, !0),
g.pesData = null) : g.pesData = E,
A && (l = D(A)) && void 0 !== l.pts ? (v.isAAC ? M(l) : x(l),
v.pesData = null) : (A && A.size && h.b.log("last AAC PES packet truncated,might overlap between fragments"),
v.pesData = A),
T && (l = D(T)) && void 0 !== l.pts ? (L(l),
y.pesData = null) : y.pesData = T,
null == this.sampleAes ? this.remuxer.remux(v, g, y, this._txtTrack, r, i, n) : this.decryptAndRemux(v, g, y, this._txtTrack, r, i, n)
}

实现解密

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
from Crypto.Cipher import AES
from base64 import b64decode


def parsepmt(t: bytes, e: int, vpid, apid, mpid) -> (int, int, int):
section_length = (0x0f & t[e + 1]) << 8 | t[e + 2]
section_body = e + 0x03 + section_length - 0x04
program_info_length = (0x0f & t[e + 10]) << 8 | t[e + 11]

e += 12 + program_info_length
while e < section_body:
s = (0x1f & t[e + 1]) << 8 | t[e + 2]
if t[e] == 0x1b and vpid == -1:
vpid = s
elif t[e] == 0x0f and apid == -1:
apid = s
elif t[e] == 0x1c and mpid == -1:
mpid = s
elif t[e] != 0x1b and t[e] != 0x0f and t[e] != 0x1c:
print('ERROR @ {1}, UNKONWN STREAM TYPE: {2} STREAM_ID: {0}.'.format(s, e, t[e]))
e += 5 + ((0x0f & t[e + 3]) << 8 | t[e + 4])
return vpid, apid, mpid


def parsepes(datarray: bytearray, index: dict) -> bytes:
r = bytes(datarray)
if (r[0] << 16) + (r[1] << 8) + r[2] == 0x000001:
if (r[4] << 8) + r[5] > len(r) - 6:
return b''
pes_header_data_length = r[8] + 9
for key, value in index.items():
index[key] += pes_header_data_length
break
return r[pes_header_data_length:]


def doset(decdata: bytes, index: dict, tsarray: bytearray) -> None:
v = 0
for j, start in index.items():
end = min(j + 0xbc, start + len(decdata) - v)
length = end - start
tsarray[start:end] = decdata[v:v + length]
v += length


def decrypt(datarray: bytearray, index: dict, tsarray: bytearray, key: bytes) -> None:
data = parsepes(datarray, index)
key = b64decode(key)
encdata = data[0:len(data) - len(data) % 16]
decdata = AES.new(key, AES.MODE_ECB).decrypt(encdata)
doset(decdata, index, tsarray)


def dects(filename: str, key: bytes) -> None:
tsfile = open(filename, 'rb')
ts = bytearray(tsfile.read())
tsfile.close()

sdtid = 0x0011
nulid = 0x1fff
patid = 0x0000
pmtid = vpid = apid = mpid = -1

vdata = bytearray()
adata = bytearray()
mdata = bytearray()
vindex = {}
aindex = {}
mindex = {}

for i in range(0, len(ts), 0xbc):
if ts[i] != 0x47:
print('ERROR@ {0}, NOT START WITH 0x47.'.format(i))
break

payload = 0x40 & ts[i + 1]
adap = (0x30 & ts[i + 3]) >> 4
pid = ((0x1f & ts[i + 1]) << 8) + ts[i + 2]
d = 0x00

if adap == 0b00 or adap == 0b01:
d = i + 0x04
elif adap == 0b10:
continue
elif adap == 0b11:
d = i + 0x04 + 0x01 + ts[i + 4]

if pid == sdtid or pid == nulid:
continue
elif pid == patid:
if payload:
d += ts[d] + 1
pmtid = (0x1f & ts[d + 10]) << 8 | ts[d + 11]
elif pid == pmtid:
if payload:
d += ts[d] + 1
vpid, apid, mpid = parsepmt(ts, d, vpid, apid, mpid)
elif pid == vpid:
if payload and vdata:
decrypt(vdata, vindex, ts, key)
vdata = bytearray()
vindex = {}
vdata += bytearray(ts[d:i + 0xbc])
vindex[i] = d
elif pid == apid:
if payload and adata:
decrypt(adata, aindex, ts, key)
adata = bytearray()
aindex = {}
adata += bytearray(ts[d:i + 0xbc])
aindex[i] = d
elif pid == mpid:
if payload and mdata:
decrypt(mdata, mindex, ts, key)
mdata = bytearray()
mindex = {}
mdata += bytearray(ts[d:i + 0xbc])
mindex[i] = d
else:
print('ERROR @ {0}, {1} IS NOT A VALID PID.'.format(i, pid))

if len(vdata) != 0:
decrypt(vdata, vindex, ts, key)
if len(adata) != 0:
decrypt(adata, aindex, ts, key)
if len(mdata) != 0:
decrypt(mdata, mindex, ts, key)

decfile = open('dec' + filename, 'wb')
decfile.write(ts)
decfile.close()


# if __name__ == '__main__':
# for filenum in range(29):
# dects(str(filenum) + '.ts', b'Okrb/FBF+uFjevMGP0uyLA==')

评论