今天我要写个大一点的——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 | var jsvmportal_0_3 = function() { |
jsvm_this_run
函数做了严重的混淆,使用了一个 switch
函数将所有步骤分解,使得查看过程异常艰难。
好记性不如烂笔头,将每一步的操作都记录下来,整个过程大概做了三天,下面捡重点说一下。
初识 jsvm_this_run
最开始看到这个函数完全不知所措,不知道每一步做什么,经过单步调试许久之后,发现了几个关键地方:
d
这个参数指示接下来的处理流程,会在每次操作完成后重置s
这个参数包含了很多固定参数和函数(名),像是一个仓库o
这个参数包含了将要进行处理的或者刚刚处理过的函数(名)、参数等h
这个参数包含处理结果,是很多数据的最终存放位置u
这个参数前面几个参数表示该函数调用做的事情,后面几个参数常常是类似于o
,最后一个参数会用来存放函数调用结果。
尝试总结一下数据和操作的处理过程为:
从 s
中取出参数和函数名放入 o
/u
中,使用 eval()
执行函数得到结果,存入 o
/u
中再存入 h
中。s
也并非单向的,有时候会把 s
中的函数名转为函数后再存入供以后使用。
经过仔细分析后,发现断点应该放在如下位置,第一个断点决定 d
,第二个断点做真正的操作:
然后开始 F8 调试,如果不知所云,先看 F8 过一遍大概要进行的操作,做一下大概的记录,然后再来一遍做详细分析。
case 6
函数将在这里面进行真实的操作,这也是 switch
函数,其中有几个重要的 case
拿出来看看:
1 | w = jsvm_this_insns[d]; |
为了方便可以直接条件断点 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 | case 50: |
在 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 | { |
根据关键词 send()
/2017-03-21
/HMAC-SHA1
/Signature
之类的定位:
SignatureNonce
对应的是 randomUUID()
,是一个很简单的随机 UUID 生成器:
那么至此,就还剩两个参数没有搞定:Rand
和 Signature
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)), |
是请求链接中除最后一个参数外的其余所有参数 urlencode 的字符串,类似于字典的结构,但无论 key,value 都 urlencode,然后使用 =
和 &
拼接起来,这是请求链接前面的部分,后面的就是签名:
其中函数 n() 就是 HMAC-SHA1 算法,o.stringify() 是 base64 加密算法。
n() 的第二个参数是 key,也就是说 accessSecret + '&
‘ 就是 HMAC 的 key。
最后把目光放在需要签名的内容上:其实是在 makeUTF8sort() 的基础上又做了一次 urlencode,然后在前面加上 GET&/&
实现请求
1 | import requests |
请求返回
1 | { |
这里有想要的一切。
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 文件,终于搞明白了,然后模仿着写了一段解密代码。下面总结一下流程
- 一上来先 parse 了很多变量,主要是各种 pid,包括视频、音频、PMT 等
- 然后开始循环,每次处理 0xbc 长度的内容,并且对每种数据类型都有一个数组,保存着需要解密的内容
- 在每次循环开始的时候,首先取出 payload 位,以后每次遇到 payload 都要执行解密并初始化解密函数
- 有 adaptation_field 的时候,跳过 adaptation_field,不存储到需要解密数组中
- 如果没有遇到 payload,就一直往各自类型的数组里面存储内容
- 当遇到 payload == 0x1,把需要解密数组的内容先做 parsePES,然后去掉 pes header,剩下的部分解密
- 当数据 % 16 != 0 时,去掉余数,然后保存到数组的对应位置,重置解密函数
1 | // 真实的解密函数 |
实现解密
1 | from Crypto.Cipher import AES |