旧版widevine内容解密模块逆向

June 3, 2021 · 分享 · 261次阅读

本文是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_keyencrypted_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实现

翻不动了,算了

ENJOY 4

none

最后编辑于3个月前

添加新评论

  1. dajiba dajiba
    2021-06-17 20:01

    挂了用不了 读取不到key了

    回复
    1. 2021-06-18 08:33

      请勿发布和本文章无关的内容

      回复
avatar

未末

118

文章数

256

评论数

7

分类

新鲜出炉の评论

XstreamDL-CLI BUG修复记录
Andist
Andist2021-09-17

en……实在不好意思,代理我知道怎么启用了。我只勾选了自定义代理,但没有填写proxy参数,我太愚钝了对不起!!!

XstreamDL-CLI BUG修复记录
Andist
Andist2021-09-17

感谢您开发的这款软件,对于第一次下载mpd的小白而言很友好! 这段时间用下来就是有时候下载直连的海外视频流时可能因为网络状况不佳,会有下载不完整的问题,下载完进度没到100%,但是也合并解密了,不知道能否增加下载不完整在最后输出报错信息的功能呢? 以及我想请教一下如何让下载器使用小飞机的代理呢?我尝试在“使用自定义代理”的选项上勾选,但是好像命令行中没有变化? (另外我猜您图中的样本是在下载CP+上的用九柑仔店是吗?我也很喜欢这部剧,是我心目中排名第一的台剧哈哈)

XstreamDL-CLI BUG修复记录
未末
未末2021-09-16

要批量下载可以通过传入文件夹,文件夹内放要下载的m3u8。 要批处理调用建议使用N_m3u8DL-CLI。 你后面说的这个准确来说是混流操作,考虑到dash流,master m3u8的复杂性,目前不提供混流功能,这不是bug。混流请自行手动完成

白嫖某视频网站widevine解密key
未末
未末2021-09-16

用mp4dump查看对应视频的kid,对应使用

XstreamDL-CLI BUG修复记录
dehooy
dehooy2021-09-15

发现命令行并没有针对本地M3U8下载完成后输出文件名的参数,批处理测试会自动启动本地M3U8的文件名,这不方便批处理调用。另外 ,测试发现关于音视频分流的情况,下载解密后并不会合并这两路流,这是一个BUG,可参考某酷CMAF类型WV加密的M3U8