旧版widevine内容解密模块逆向

1030天前 · 分享 · 3714次阅读

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

翻不动了,算了

👍 4

none

还没有修改过

评论

取消回复
贴吧 狗头 原神 小黄脸
收起

贴吧

狗头

原神

小黄脸

  1. dajiba 1016天前

    挂了用不了 读取不到key了

    1. 未末 1015天前

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

目录

avatar

未末

迷失

126

文章数

275

评论数

7

分类