土豆不好吃

解密人人影视客户端加密的视频文件:从C++ Port到Python的经过

文章目录[显示]

在几个月开始给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())

有两点需要注意:

  1. 要记得MD5的摘要本质上是二进制,所以我们print一定是乱码的,所以我们此时要转换成十六进制表示出来;
  2. 要记得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()

有几点需要注意:

  1. Key需要16、24或者32字节,数据类型为二进制,也就是Python 2的str,Python 3带b前缀或者encode或者bytes一下;
  2. modes.ECB()指定分组模式为ECB模式,如果使用CBC模式,那么还要提供一个初始化向量,如modes.CBC(os.urandom(16))
  3. 密文也是二进制类型的,同进行hash,如果要加密文件,那么直接f.read()传递过去就好了,加密之后的返回值也是二进制;
  4. 加密密钥实际上就是MD5散列值,因为MD5散列值恰巧是128比特,恰巧符合需求。需要注意的是,密钥是二进制,所以我们直接把digest.finalize()传过去就好了,不要再hexlify啦。
  5. 理论上来说,你用什么密钥都是可以解密确定的密文的,只是正确的密钥会解密出来有意义的东西(比如说写给你的情书),错误的密钥会解密出来无意义的东西(一堆胡乱的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,下载回来面目全非的的文件,再进行解密。叫我怎么说呢……运营商应该不会像我这么无聊,也是个办法吧……


文章版权归原作者所有丨本站默认采用CC-BY-NC-SA 4.0协议进行授权|
转载必须包含本声明,并以超链接形式注明原作者和本文原始地址:
https://dmesg.app/yyets.html
退出移动版