在几个月开始给ExpressBot加入人人影视的API的时候,发现有一个很神奇的API,对某个URL进行curl url -C - -Lv -o xxx.mp4
,会下载回来一个和原始视频文件一样大小、内容却经过某种加密处理的文件。
前几天经过JasonKhew96的分析,发现是人人影视对视频文件进行了加密。
加密原理
一句话简单概括就是,每4096字节就加密16个字节,加密密钥是file_id+字符串zm+file_id进行一次md5
进一步的分析探讨
分析相应JSON
首先,我们先看一下相应吧,大概如下所示:
[ { "file_name": "天赋异禀.彩蛋合集.the.gifted.s01.extras.中英字幕.webrip.aac.720p.x264-人人影视.mp4", "file_size": 61001062, "fileid": "fb755fd1b51c769bfed987e2a8c8b03ee7a8e7cc", "url": "https://www.zmzfile.com:9043/rt/route?fileid=fb755fd1b51c769bfed987e2a8c8b03ee7a8e7cc" }, { "file_name": "天赋异禀.the.gifted.s01e12-13.中英字幕.webrip.aac.720p.x264-人人影视.mp4", "file_size": 995104165, "fileid": "a01975eda089190c7e43fcba5eb91371e54205a6", "url": "https://www.zmzfile.com:9043/rt/route?fileid=a01975eda089190c7e43fcba5eb91371e54205a6" }, 省略………… ]
首先观察下这个fileid,40个字符,看样子应该是某个散列函数的摘要,那么应该是SHA1吧。SHA1摘要长度是160位,也就是20个字节,用十六进制表示法就是40个字符了。
然而事实证明,可能并不是这样。我比对了所有已知的散列函数,都没有一样的散列值,猜测可能是加了点“作料”然后再运行的SHA1吧。不过这不重要。
十六进制分析
为了验证文件究竟被做了怎样的修改,我们必须要准备两个版本的文件:原版的与被加密的。
打开Hex Comparison比较两个文件的异同。
第一处不同:
文件一开始就发现不同了。这个部分相当重要啦,file命令就是看这里区分文件类型的。
第二处不同:
注意左侧的十六进制偏移,我们用计算器把它换算成十进制,发现刚好是4096.那么大胆的猜测一下,下一个不同的地方的十进制就应该是8192,对应十六进制就应该是2000,对吧?
没错哦,真的是这样。那么加密流程就可以这样详细描述了:
将文件以4096字节进行分块,加密每一块中的前16个字节(也就是图上的一行)。如果遇到文件尾恰巧是该加密的块但是又不足16字节(这种概率真的很低),那么如何处理还暂时未知,不过按照常规的思路应该是先padding然后再走加密。
有人可能要问,上面那一行不同明明是32个字符,那么就是32个字节,你怎么睁眼说瞎话呢?唉ε=(´ο`*)))十六进制表示法嘛,下面会细说的。
C++源代码的结构流程分析
JasonKhew96提供了一个C++版本的可以成功解密文件的源代码,下载可以戳这里https://github.com/BennyThink/ExpressBot/files/1710533/YYeTs.zip
使用CLion打开进行分析,发现流程大概是这样的(我的C/C++水平接近于0了,已经完全完蛋了(๑′ᴗ‵๑)):
主函数的里只有这两句关键代码,第一句猜测是拿到加密的Key,第二步是用Key解密文件。
uint8_t *md5Test = getMd5(argv[2]); decrypt_file(md5Test, argv[1]);
我们跳转到getMd5这个函数
char zmzSecret[] = "zm"; char bfMd5[82] = {}; strcat(bfMd5, file_id); strcat(bfMd5, zmzSecret); strcat(bfMd5, file_id); char zmzKey[32]; strcpy(zmzKey, md5(bfMd5).c_str());
我们发现思路很简单,就是
fileid = "d21a081cc6a32daa85310ca6aad81e378f0b736e"
secret = "zm"
key = fileid + secret + fileid
也就是d21a081cc6a32daa85310ca6aad81e378f0b736ezmd21a081cc6a32daa85310ca6aad81e378f0b736e,然后对这个进行一次md5,通过跳转到md5.cpp
中,查看对应代码,return md5.hexdigest()
我们可以猜测到,这里返回的值是十六进制表示的md5值,也就是我们经常看到的32个字符的MD5值。
小提示:
根据维基百科,MD5的摘要长度是128位,那么应该是128/8=16字节,为什么我们看到的摘要长度都是32呢?哦是这样的,MD5运算之后的结果是二进制,基本上是不可读的(无法正确显示出来),所以我们一般都会把结果转换成十六进制,这样就是可以看到的字符啦。只不过我们是4位转换一个,用两个“字符”表示, 所以128位(16字节)的摘要以16进制表示法显示是32个“字符”啦。
接下来就是一段for循环,反正当时我是没太搞懂这段for循环的含义。好吧,就当我们拿到了真正的key,那么我们看下decrypt_file这个函数吧。
逻辑就不细说了,基本上就是判断当前的文件指针位置,需要处理文件大小与文件尾,然后进行解密写入文件。
Port到Python之使用MD5计算AES Key
由于后面需要用到aes,所以这里就都使用cryptography啦,要是只进行散列倒是可以用用自带的hashlib呢。
生成摘要很简单,几行而已:
from binascii import hexlify from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes digest = hashes.Hash(hashes.MD5(), backend=default_backend()) digest.update('hello world') print hexlify(digest.finalize())
有两点需要注意:
- 要记得MD5的摘要本质上是二进制,所以我们print一定是乱码的,所以我们此时要转换成十六进制表示出来;
- 要记得
digest.update()
接受的参数是bytes类型,也就是二进制,所以如果你使用Python 2,那么直接引号包围的就是二进制了,如果你使用Python 3,那么要带b前缀,或者加上encode('utf-8')
或者bytes(your_str, encoding='utf-8')
。如果想要计算文件的散列值,那么直接把f.read()
传递过去就好了。
Port到Python之使用AES-ECB
简单谈谈AES
在开始之前,首先我要科普一下,AES是一种对称加密算法,所谓对称加密算法,就是指的加密解密用同一个密钥。在对称加密算法中,可以分成两种,一种是流密码,一种是分组密码(块密码),像AES就是典型的块密码,而RC4是典型的流密码。
顾名思义,块密码就是将明文分成多个等长的模块(不足就padding),使用确定的算法和对称密钥对每组分别加密解密。比如说,AES的块长度固定为128比特,密钥长度则可以是128,192或256比特。也就是说,你的密钥,如果用肉眼可见的字符来说话的话,那么就必须是16字节、24字节、32字节。
另外,块密码有分组模式这种说法,即使是不同的分组模式、相同的密钥也会产生不同的密文。所以我们如果要精确的用AES描述加密,那么大概模式是这样的:
AES-256-CFB,使用AES加密算法,密钥长度是256比特,分组模式是CFB。
分组模式究竟是个啥啊,不同的分组模式之间有啥区别啊,能加密不就行了吗?
不不不这样是远远不够的。由于块密码结构的特殊性,我们必须要定义每一个块与密钥之间的运算关系。像是最简单的电子密码本(Electronic codebook,ECB)模式,就是把每个块和相同的密钥进行加密运算,得到的密文再组合到一起;
像是密码分组链接(CBC,Cipher-block chaining)模式,每个明文块先与前一个密文块进行异或后,再进行加密,第一个明文块与初始化向量进行异或再加密。
稍微思考一下下我们其实可以得出结论,ECB模式本质上是不安全的(实现最简单,没错)。为什么呢?只要明文块一样,那么得出来的密文也就一样了,无论使用多长的密钥长度都是这样的。有没有想起重放攻击,有没有想起那群自作聪明用HMAC保存密码的人?
另外,我在推特上保存了这么一张有趣的图,挺好玩的。
在Cryptography中使用AES
非常简单,如下几行代码:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend key='1111111111111111' cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()) encryptor = cipher.encryptor() decryptor = cipher.decryptor() ct = encryptor.update("a secret message") + encryptor.finalize() print ct print decryptor.update(ct) + decryptor.finalize()
有几点需要注意:
- Key需要16、24或者32字节,数据类型为二进制,也就是Python 2的str,Python 3带b前缀或者encode或者bytes一下;
modes.ECB()
指定分组模式为ECB模式,如果使用CBC模式,那么还要提供一个初始化向量,如modes.CBC(os.urandom(16))
;- 密文也是二进制类型的,同进行hash,如果要加密文件,那么直接f.read()传递过去就好了,加密之后的返回值也是二进制;
- 加密密钥实际上就是MD5散列值,因为MD5散列值恰巧是128比特,恰巧符合需求。需要注意的是,密钥是二进制,所以我们直接把
digest.finalize()
传过去就好了,不要再hexlify啦。 - 理论上来说,你用什么密钥都是可以解密确定的密文的,只是正确的密钥会解密出来有意义的东西(比如说写给你的情书),错误的密钥会解密出来无意义的东西(一堆胡乱的0和1)。
Port到Python之“按位”操作文件
给人的感觉,Python似乎是一种“高级”的编程语言(我这里的高级指的是对应底层的那个意思),没有指针,也没有各种精灵古怪的概念。那Python怎么按照比特操纵文件呢?
其实是有的,我们想一想,对文件句柄执行read的时候,read是要一个参数的啊,如果你参数是1,那么就是读一个字节啦。
所以如果要读前16个字节,那么可以这么做:
raw_file = open('sample.mp4', 'rb') print hexlify(raw_file.read(16))
转换成十六进制之后,看看结果是不是和hexcompare中第一行一样的?
那接下来的问题就好办了,继续读4080个字节,写入文件,然后读16个解密,读4080……那……文件尾怎么办?不要担心,当我们读到文件尾的时候,read()
的返回值就会是布尔假,我们就会有终止条件了,不用我们操心判断这那的。
所以操作文件这块我们可以这么写(注意要以rb、wb模式读写文件):
data = 1 while data: data = raw_file.read(16) final_file.write(decrypt(data, key)) data = raw_file.read(4080) final_file.write(data)
其中decrypt是用于解密的函数,边处理边写入文件。
所以糅合一下,加上单元测试。这个玩意基本上就写完了。总计52行,很精简吧。
通过dnspy反编译结果
大概只搜索到了这么一个有用的信息块,可以清楚看到,使用AES-ECB模式,不填充??
本项目的代码
欢迎Star、PR、Fork,目前还差一个file_id生成的问题。
开源地址
最后的一些点评
人人影视宣传语中有一句话我印象特别清楚:
全程加密的P2P传输,保证安全,海外用户也不用担心被运营商起诉。
是的就是加密这俩字,我特敏感。
从开头用Postman咱能发现,是post的https地址(买了一年的泛域名证书),然后被跳转到了http的各路IP,下载回来面目全非的的文件,再进行解密。叫我怎么说呢……运营商应该不会像我这么无聊,也是个办法吧……