本文是Reversing the old Widevine Content Decryption Module
的翻译文章
本文旨在说明逆向旧版Windows Widevine CDM(widevinecdm.dll 4.10.1610.0)的过程,即绕过其保护并提取RSA私钥。有了私钥之后,最终可以解密(加密)媒体的内容密钥。
本文所述均为教育目的,文章中提到的大多数技术都不会对新版CDM(的逆向)起作用,因为它进行了大量的重构,即改变了混淆技术和算法。如果你只是关心盗版视频,请绕道。
在讨论私钥提取之前,先说一下需求来源。
背景
当网站想放送由DRM保护的内容时,通常采用Encrypted Media Extensions (EME)【加密媒体扩展】API和(对应的)DRM系统,设置一个用于身份验证/隐私的服务证书,然后通过一个license服务器获取要播放的媒体的license。对于widevine一般长这样:
var keySystemAccess = await navigator.requestMediaKeySystemAccess("com.widevine.alpha", options);
var mediaKeys = await keySystemAccess.createMediaKeys();
mediaKeys.setServerCertificate(someCertificateBlob);
var mediaKeySession = mediaKeys.createSession("temporary");
// Now parse the MP4 and get the PSSH box that contains a key ID...
// then
mediaKeySession.generateRequest("cenc", psshBox);
mediaKeySession.addEventListener("message", function(message)
{
if (message.messageType == "license-request")
{
// We got a license request, now send it to a license server...
var licenseResponseBlob = send(new Uint8Array(message.message));
// and update the CDM about its response
mediaKeySession.update(licenseResponseBlob)
}
});
Chrome在后台会通过CDM DLL创建一个CDM实例,然后调用它的CreateSessionAndGenerateRequest
方法和UpdateSession
方法,用于生成和更新protobuf格式的license请求和响应(就是上面的js代码这样子),这些是content_decryption_module.h
定义的部分接口。
众所周知,像这一类的DRM协议通常都是把最终(用于解密)的密钥放在license的返回里面,密钥也可能被二次加密,或者被其他方法处理。
license响应体和设备RSA密钥对
那么第一个问题就是,如果我们想知道内容解密密钥时,我们怎么才能从widevine的license响应中提取密钥呢?当我们转储(指拦截保存数据)来自于EME会话的license响应(protbuf-inspector处理)的时候,会看到像下面这样的东西:
root:
1 <varint> = 2 # Type
2 <chunk> = message: # Msg
1 <chunk> = message: # Id
1 <chunk> = bytes (16) # RequestId
0000 49 C5 22 ...
2 <chunk> = bytes (8) # SessionId
0000 19 54 ...
4 <varint> = 1 (STREAMING) # Type
5 <varint> = 0 # Version
2 <chunk> = message: # Policy
1 <varint> = 1
...
3 <chunk> = message: # Keys
1 <chunk> = bytes (16) # Id
0000 9A A8 F8 43 ...
2 <chunk> = bytes (16): # Iv
0000 B1 D3 15 88 ...
3 <chunk> = bytes (32) # Key
0000 8E 1B 1B AB ...
4 <varint> = 2 # Type
...
4 <varint> = 1585928174 # LicenseStartTime
...
3 <chunk> = bytes (32) # Signature (HMAC-SHA256)
0000 B8 FA 8D ...
4 <chunk> = bytes (256) # SessionKey
0000 92 16 0C ...
可以看到有好几个键值对,每一个都有一个类型(CONTENT或者SIGNING,指的是它是内容还是签名),一个KID,加密的密钥数据,一个用于解密的偏移量(IV)。
通过一些文档、在线的代码等等,要解密被加密的密钥,CDM要做下面的事情:
- 用设备的RSA私钥解密
session_key
,因为在发送license请求的时候,会用这个私钥对应的公钥进行加密 - 通过license请求和CMAC计算得到一个
加密key
- 用这个
加密key
来解密全部的内容解密密钥
可以通过定位和分析widevinecdm.dll里面的函数,然后使用外部的C++程序来调用以验证这个过程。这里就不具体说了,因为一旦讨论解密过程就非常容易理解了。【PS 这里其实可以参考WVClient3
,里面的license_proxy.exe
就是等于是通过外部调用】
现在的问题关键就是在于缺少设备RSA私钥了,具体接着看。
获取设备的公钥
在从CDM里面逆向出设备私钥之前,先提取私钥对应的公钥,以及其公共模数N将有助于后面的分析。设备公钥在license请求的时候,作为证书链的一部分,它被包含在加密的encrypted_client_id
里面。【PS 我感觉这里说的好像不对】
license请求长这样:
root:
1 <varint> = 1 # Type
2 <chunk> = message: # Msg
2 <chunk> = message: # ContentId
1 <chunk> = message: # CencId
1 <chunk> = message: # Pssh
1 <varint> = 1 # algorithm (AESCTR)
2 <chunk> = bytes (16) # key_id
0000 9A ...
3 <chunk> = "..." # provider
4 <chunk> = bytes (20) # content_id
0000 9A A8 ....
2 <varint> = 1 # LicenseType
3 <chunk> = bytes (16) # RequestId
0000 49 C5 22 0E ...
3 <varint> = 1 # Type
4 <varint> = 1585928174 # RequestTime
6 <varint> = 21 # ProtocolVersion
8 <chunk> = message: # EncryptedClientId
1 <chunk> = "spotify.com" # ServiceId
2 <chunk> = bytes (16) # ServiceCertificateSerialNumber
0000 4F 2D 27 ...
3 <chunk> = bytes (3632) # EncryptedClientId
0000 72 9C 97 ....
4 <chunk> = bytes (16) # EncryptedClientIdIv
0000 F1 75 24 CF ...
5 <chunk> = bytes (256) # EncryptedPrivacyKey
0000 79 06 E9 61 8....
3 <chunk> = bytes (256) # Signature (RSA-SSA-PSS)
0000 9E E....
encrypted_client_id
是由encrypted_private_key
和encrypted_client_id_iv
一起AES加密得到的。
然而这个加密过程没有引起重视,以至于这个过程没有被混淆。我们可以从CDM的crypto/encryptor.cc
源代码里面看到,所以对OpenSSL's aes_init_key function
进行hook就可以得到了(调用来自于EVP_CipherInit_ex
)。
把encrypted_client_id
解密之后,就可以在ASN.1 DER
格式的device_certificate
里面看到公钥
root:
1 <varint> = 1 # type (DEVICE_CERTIFICATE)
2 <chunk> = message: # token
1 <chunk> = message: # device_certificate
1 <varint> = 2 # type (USER_DEVICE)
2 <chunk> = bytes (17) # serial_number
0000 EA 2E 69 8D ...
3 <varint> = 1557514008 # creation_time_seconds
4 <chunk> = bytes (270) # public_key (PKCS#1 ASN.1 DER)
0000 30 82 01 0A ...
5 <varint> = 13701 # system_id
2 <chunk> = bytes (256) # signature (RSASSA-PSS)
0000 97 E6 1C 5F 44 70 ...
3 <chunk> = message: # signer
1 <chunk> = message: # device_certificate
1 <varint> = 1 # type (INTERMEDIATE)
...
2 <chunk> = bytes (384) # signature (RSASSA-PSS)
0000 5D 79 96 17 DB ...
3 <chunk> = message: # client_info (repeated)
1 <chunk> = "architecture_name"
2 <chunk> = "x86-64"
...
...
如此一来,可以暂时将公共模数N放到一边了,记住这个对提取私钥有帮助。【PS 我记得直接在网页端下断点分析好像也能搞到公钥?】
寻找私钥
现在公钥有了,就可以开始在CDM进程中的深处寻找一个关键点来定位私钥了。私钥完全没有在内存中出现过,而且CDM也很难直接进行调试(因为有反调试)。
一点反调试技巧
当我们尝试在解密(重放)过程中附加调试器时,会立即遇到各种反调试,然后导致进程崩溃。
这是一个int 2D trick
样例:
000007fe`d31a9f18 48890424 mov qword ptr [rsp], rax
000007fe`d31a9f1c 488d4520 lea rax, [rbp+20h]
000007fe`d31a9f20 cd2d int 2Dh
000007fe`d31a9f22 e9125bfeff jmp widevinecdm!VerifyCdmHost_0+0x436449 (000007fe`d318fa39)
然后debugger就会在一些相关地方停止
(3158.1d3c): Invalid handle - code c0000008 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
ntdll!KiRaiseUserExceptionDispatcher+0x3a:
00000000`7782b5ba 8b8424c0000000 mov eax,dword ptr [rsp+0C0h]
或者在一些没法执行的地址崩掉
(4058.3430): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
000007ff`54afe670 ?? ???
即使尝试内核调试,CDM也不会进行解密过程,并且会阻止这类尝试。
不过我们不需要过这些反调试,因为还有很多其他手段来操作,比如:自定义的DLL注入hook, DynamoRIO, Hypervisor-assisted-debugging, TTD, Frida等等。
但是,要DLL注入的话,chrome就得使用--no-sandbox选项,但是这样CDM又不能直接从硬盘上映射DLL了
寻找RSA解密函数
现在我们知道RSA私钥用于session_key
解密,那么下一个问题就是:session_key
在哪里被解密,或者哪个地方更加容易能被找到。
SignedMesage
中的signature
字段是msg
字段中RSA-SSA-PSS
签名,它们都是用同一个私钥加密的——即设备密钥。【这里我晕了】
``bash
root:
1 <varint> = 1 # Type
2 <chunk> = message: # Msg
...
3 <chunk> = bytes (256) # Signature (RSA-SSA-PSS)
0000 9E E....
``
在签名过程中,会执行与解密过程中相同的数学运算——即d的N次方。所以选择逆向这些运算中的哪一个不重要。
逆向签名的更好理由是:我们确实在内存中看到了msg字段和signature字段(最终的请求过程中)。这意味着在签名的时候,我们知道解密过程的原始信息和最终结果。这和session_key
相反,因为我们并不知道它最终会是啥。
So, to reverse the signing, we'll follow the memory accesses to the signature/msg buffers in memory and quickly land in the function that actually starts to process the msg buffer, in preparation for signing.
为了逆向签名,在签名开始之前,定位到msg
字段处理进程,从内存中获取到signature/msg
相关的内容
使用RSA-SSA-PSS
(以SHA1作为哈希函数)作为签名算法,意味着msg
填充数字的方法是RFC中的EMSA-PSS-ENCODE
,这个函数本质上是通过一一系列的计算得到一个合适的数,比如:MGF(sha1(zeroes || sha1(msg) || random)),最后会拼接一个0xBC
到结果里面
我们持续追踪,经过数次SHA1
调用,直到内存中出现类似EMSA-PSS-ENCODE
这样的数据
00000000`0097c920 0f ... .... cc 5d bc .4%.d.......Y.].
可以看到EMSA-PSS-ENCODE
这个操作已经完成,因为这个时候出现了0xBC
。【PS 这里应该是原文错误,这里是字符串bc而不是0xBC】
这个时候就有一个由输入缓冲区生成的2048位的数字,然后即将开始RSA私钥相关的加密
深入了解RSA实现
翻不动了,算了
挂了用不了 读取不到key了
请勿发布和本文章无关的内容