谷歌 Widevine DRM 破解

前言

参考文章:https://www.52pojie.cn/thread-609243-1-1.html

最近某酷很多的独播剧在PC端的网页上都开启了谷歌的widevineDRM 保护,开了会员在不支持谷歌widevineDRM 的浏览器最多也只能观看720P的清晰度,网页调试分析了一番,发现目前从JS里是无法进行破解了。

简单说下DRM的机制:
1、最简单就是ClearKey了,从字面上就可以知道,秘钥是暴露清晰可见的,只要你够耐心,总是可以从JS里面分析出秘钥,然后进行AES解密。
2、Fair PlayWidevine等其他基于CDM解密模块的,这种简单点来说就是客户端或者浏览器将网页中的播放器重写了,标准的H5播放器是直接将视频流传入MediaSource,显然这是不安全的,即使别人不会从JS中找出你的秘钥,但只要拦截appendBuffer函数就能够将你解密后的流捕捉。所以Fair PlayWidevine等这种加密形式就出现了,也就是JSappendBuffer也是直接将加密的数据流传进去,然后将MediaDRM解密实例绑定进行一系列的会话操作,从DRM license服务器获取秘钥(获取到的秘钥也是加密的,只有CDM解密模块能够解析),然后对音视频流进行解密,解密后将结果渲染到浏览器。

分析

从上面的流程可知,在JS中是无法对其进行破解了,也就是说JS里面没有解密操作。
那么我们要么就是破解出DRM加密的秘钥(这个难度比较大,虽说国外早有报导有人通过白盒DFA攻击能够进行秘钥还原),但对于普通人来说简直是天方夜谭。
所以,退而求其次,还是以拦截的形式进行破解,其实前面基于JS加密里也说过了,拦截appendBuffer。对于widevine呢,由于Chromium是开源的,所以我们能够在它的源码里发现,有这么个头文件:content_decryption_module.h
这就是解密模块一些对外开放的接口定义,虽说个人编译的浏览器是无法使用widevineDRM的,但它却能够为我提供拦截的线索。

整个流程在前面的参考文章中说的已经比较清楚了,这里不想赘述,主要讲下自己实现的时候遇到的一些问题,也是参考文章中没有提及的问题。

  1. virtual Status Decrypt(const InputBuffer& encrypted_buffer,
  2. DecryptedBlock* decrypted_buffer) = 0;

我们真的是仅仅只要拦截此方法的decrypted_buffer吗?答案是否定的,经过我的多方验证,无论是同时包含了音视频流还是只包含视频流,该方法都不是用来解密视频流的,它的作用是:若有音频流,则用来解密音频,并且不包含未加密的音频流部分(有些m3u8里面前面开头几段是未加密的,比如某酷)。

那么视频流该如何捕获?

  1. virtual Status DecryptAndDecodeFrame(const InputBuffer& encrypted_buffer,
  2. VideoFrame* video_frame) = 0;

没错,就是它,函数名称显而易见,与其类似的还有个DecryptAndDecodeSamples,官方注释说是用来解密与解码音频流的,但是测试时音频流一直都是Decrypt方法进行解密。

DecryptAndDecodeFrameDecrypt方法的不同看参数就能够知道了,Decrypt返回的是DecryptedBlock解密后的数据块,注意:此时的数据,是没有解码的。其实我们要的就是解密后的这种数据,但是可惜的是Decrypt并不解密视频。

DecryptAndDecodeFrame这个方法就很坑了,返回的是解码后的视频帧,实践测试的时候H264视频返回的解码格式是I420裸流。直接写出到文件?写个锤子,一部几百兆的视频,能给你输出几十G的yuv数据,并且直接写出保存为.yuv文件还是播放花屏的。

到此为止,已经触碰到自己的盲区了,无奈,只好硬着头皮去学习图像的一些知识,这些东西在这里我就不写出来了。
VideoFrame对象包含了几个重要的信息:frameBuffer、planeOffset、stride。
frameBuffer:视频帧的数据(对齐后的数据,而不是.yuv文件里的那种能够直接写出文件播放的)
planeOffset:YUV420P平面格式,在这里I420即是由3个plane来存储图像的,可以通过该方法获取每个planeframeBuffer中的相对位置。
timestamp:时间戳,实践时我抓取的是按40000递增,其实有一个坑是需要这个值来进行解决的,最后再说这个坑。
stride:间距,用下面的一张图片来解释:
0_1321278834jZXb.gif

stride指在内存中每行像素所占的空间,它的作用就是前面说的对齐,作用就是提升性能吧(和计算机中的内存对齐概念差不多),所以frameBuffer实际上包含了很多无用的字节,直接写出到文件播放时花屏的。

需要注意的是stride并不是图像的width,一般情况下它和width相等,但在widevine中它是大于width的。
根据上图,我们需要的数据就是Image块部分的buffer,在说写出Image部分的buffer之前,我们来看一下I420图像的格式:
20181109115016207.png

I420使用3个面来存储数据的,在VideoFrame对象中就是一个planes数组。
YUV420的数据量为: wh+w/2h/2+w/2h/2 即为wh*3/2,即对应与上图中的YUV,plane[0]存储Y,plane1存储U,plane2存储V

下面来说下如何将frameBuffer中的有效数据写出到文件,我们要做的就是将三个面的数据按照上面的格式取出然后写出到文件,Y的数据,即width*height,但是widevinestridewidth不相等,我们无法直接从video_frame->PlaneOffset(video_frame->kYPlane)的的位置按顺序取出width*height个字节写入文件,这样子会把前面图片中padding的无用数据也写出导致花屏。

解决办法是,按行读取数据,每次读取width个字节,后面跳过stride - width个字节,同理UV实现如下:

  1. uint32_t c = video_frame->Size().width * video_frame->Size().height;
  2. if (buffer == NULL)
  3. buffer = (unsigned char*)malloc(video_frame->Size().width * video_frame->Size().height * 1.5);
  4. //Y Plane
  5. uint32_t offset = 0;
  6. for (int i = 0; i < video_frame->Size().height; i++) {
  7. memcpy(buffer + video_frame->Size().width * i, video_frame->FrameBuffer()->Data() + offset, video_frame->Size().width);
  8. offset += video_frame->Stride(video_frame->kYPlane);
  9. }
  10. //U Plane
  11. offset = 0;
  12. for (int i = 0; i < video_frame->Size().height / 2; i++) {
  13. memcpy(buffer + c + (video_frame->Size().width / 2) * i, video_frame->FrameBuffer()->Data() + video_frame->PlaneOffset(video_frame->kUPlane) + offset, video_frame->Size().width / 2);
  14. offset += video_frame->Stride(video_frame->kUPlane);
  15. }
  16. //V Plane
  17. offset = 0;
  18. for (int i = 0; i < video_frame->Size().height / 2; i++) {
  19. memcpy(buffer + c + (c / 4) + (video_frame->Size().width / 2) * i, video_frame->FrameBuffer()->Data() + video_frame->PlaneOffset(video_frame->kVPlane) + offset, video_frame->Size().width / 2);
  20. offset += video_frame->Stride(video_frame->kVPlane);
  21. }
  22. fwrite(buffer, 1, video_frame->Size().width * video_frame->Size().height * 1.5, pVideo);

关于stride,在我搜索资料时,一直有个千篇一律的文章:
u_stride = width / 2v_stride = width / 2,实则不然,widevineYUVstride都是相同的。
以上就是对I420buffer的处理,写出的yuv数据是可以直接进行播放的,但是如果全部写出最后再进行编码成H264的话会产生一个很大的yuv文件,所以需要改进的就是按帧进行编码成H264格式再写出到文件。

由于还未去实现最后的这一步,代码就不贴出来了。

前面说的timestamp,有个坑就是:如果视频播放卡了,我们在DecryptAndDecodeFrame中将会收到重复的视频帧。由于有timestamp,所以我们可以记录最后一次写出视频帧的timestamp,虽说这个值对于编码成H264没什么作用,由于它是递增的,我们只需做如下判断即可:
lastTimestamp 初始化为 -1;
lastTimestamp == -1 || lastTimestamp > video_frame->Timestamp() 则表示是最新的视频帧而不是重复的。

以上都是针对老版本的Chrome,用的是IAT修改导入表的函数地址来实现拦截的,新版本的貌似没了widevinecdmadapter.dll文件,但widevinecdm.dll还是有的,CreateCdmInstance导出函数也还在,我们只需将IAT改成EAT应该还是照样可以进行拦截的。

完~~~~

2020.7.7补充:

实践测试某酷发现yuv编码回h264经常会出现视频时长发生变化导致音视频不同步,视频帧率为25应该是没有错的,最有可能导致这个问题的原因就是进程在调用ffmpeg编码时导致网页播放卡顿,卡顿导致画面图像丢帧或者重复帧,所以最终出现yuv编码回h264视频时长发生变化。

至于先将yuv保存到本地,使用快进功能快速将视频的yuvdump下来,然后再进行编码成h264,算了下一集40多分钟的1080P视频大概会产生100多Gyuv文件,计算方法:以分辨率1920:1080为例,帧率为25时长为40分钟的视频,共有40*60*25帧图像,每帧图像大小为1920*1080*1.5,所以yuv总大小为1920*1080*1.5*40*60*25/1024/1024/1024约等于173G,由于我的电脑没有空闲磁盘所以就没有测试了,关键还是电脑配置太渣了,网页边播放边编码只能按照正常播放速度进行勉强不会卡顿。

h264编码视频调用DecryptAndDecodeFrame解密并解码,vp9视频编码貌似是调用的Decrypt解密,因此vp9编码的视频应该可以直接dump下来无需编码回h264这一耗时操作,具体我没去验证了,有兴趣的朋友可以去验证下。

最后更新:

都洗洗睡吧,查看了widevine的设计文档,视频流解密只会调用DecryptAndDecodeFrame来进行解密,也就是解密与解码是同时进行的,设计初衷就是不让原始视频流暴露在内存中。所以尽管你在DecryptAndDecodeFrame截获了yuv视频帧,然后再编码回去,也不是原始的视频流了,这样一来与录屏无任何差别,相当于进行了二次转码。
而二次yuvmp4的转码操作,不仅耗时,而且经过我多方测试,同一视频调用DecryptAndDecodeFrame解码出来的yuv总帧数经常出现变动,这样一来如果总帧数不正确,最终导致的结果就是视频总时长要么偏短要么偏长,也就无法与音频进行同步(编码本来就是耗时操作,最终少帧或者多帧视频时长不对相当于白压制了,非常蛋疼),测试时已经根据timestamp来进行重复帧的校正了,但最终不知道什么原因还是会导致前面视频时长不对的问题(测试时低分辨率的视频时长没出现这种问题,1080p的经常出现,应该是后台编码时网页视频卡顿导致的,也就是说在处理的时候ffmpeg编码的速度跟不上yuv视频帧的产生速度,使得DecryptAndDecodeFrame方法阻塞,这是我电脑渣的原因,所以如果电脑配置不够高的话还是选择录屏吧)。

标签: 无

精彩评论
  1. 关于IAT修改导入表的函数地址来实现拦截和EAT拦截应该怎么做呢?如何定位Decrypt和DecryptAndDecodeFrame的地址呢,我尝试用IDA直接打开,没有找到它们在哪儿,是否是通过OD单步调试还是怎么操作?

    期待你的回复!

    1. 地址动态获取:

      BOOL hook_iat(LPCSTR szDllName, PROC pfnOrg, PROC pfnNew, HMODULE hmodCaller)
      {
          HMODULE hMod;
          LPCSTR szLibName;
          PIMAGE_IMPORT_DESCRIPTOR pImportDesc;
          PIMAGE_THUNK_DATA pThunk;
          DWORD dwOldProtect, dwRVA;
          PBYTE pAddr;
      
          // hMod, pAddr = ImageBase of calc.exe
          //             = VA to MZ signature (IMAGE_DOS_HEADER)
          //hMod = GetModuleHandle(NULL);
          hMod = hmodCaller;
          pAddr = (PBYTE)hMod;
      
          // pAddr = VA to PE signature (IMAGE_NT_HEADERS)
          pAddr += *((DWORD*)&pAddr[0x3C]);
      
          // dwRVA = RVA to IMAGE_IMPORT_DESCRIPTOR Table
          dwRVA = *((DWORD*)&pAddr[0x80]);
      
          // pImportDesc = VA to IMAGE_IMPORT_DESCRIPTOR Table
          pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)hMod + dwRVA);
      
          for (; pImportDesc->Name; pImportDesc++)
          {
              // szLibName = VA to IMAGE_IMPORT_DESCRIPTOR.Name
              szLibName = (LPCSTR)((DWORD)hMod + pImportDesc->Name);
              if (!_stricmp(szLibName, szDllName))
              {
                  // pThunk = IMAGE_IMPORT_DESCRIPTOR.FirstThunk
                  //        = VA to IAT(Import Address Table)
                  pThunk = (PIMAGE_THUNK_DATA)((DWORD)hMod +
                      pImportDesc->FirstThunk);
      
                  // pThunk->u1.Function = VA to API
                  for (; pThunk->u1.Function; pThunk++)
                  {
                      if (pThunk->u1.Function == (DWORD)pfnOrg)
                      {
                          // 更改内存属性为 E/R/W
                          VirtualProtect((LPVOID)&pThunk->u1.Function,
                              4,
                              PAGE_EXECUTE_READWRITE,
                              &dwOldProtect);
      
                          // 修改 IAT (钩取)
                          pThunk->u1.Function = (DWORD)pfnNew;
      
                          // 恢复内存属性
                          VirtualProtect((LPVOID)&pThunk->u1.Function,
                              4,
                              dwOldProtect,
                              &dwOldProtect);
      
                          return TRUE;
                      }
                  }
              }
          }
      
          return FALSE;
      }
      
       hWideVineCdm = LoadLibraryW(L"C:\\Users\\XWL\\AppData\\Local\\Google\\Chrome\\Application\\62.0.3192.0\\WidevineCdm\\_platform_specific\\win_x86\\widevinecdm.dll"icon_wink.gif;
      
              if (hWideVineCdm == NULL)
              {
                  WriteLog("load widevinecdm.dll failed", 0);
              }
              else
              {
                  WriteLog("load widevinecdm.dll success", 0);
              }
              
      
              PROC pfnOrig = GetProcAddress(hWideVineCdm, "CreateCdmInstance"icon_wink.gif;
      
      
      
              pCreateCdmInstance = (CreateCdmInstanceFunc)pfnOrig;
      
              //hWideVineCdmAdapter = LoadLibraryW(L"C:\\Program Files\\Google\\Chrome\\Application\\62.0.3192.0\\WidevineCdm\\_platform_specific\\win_x86\\widevinecdmadapter.dll"icon_wink.gif;
              hWideVineCdmAdapter = GetModuleHandle(L"widevinecdmadapter"icon_wink.gif;
      
              if (hWideVineCdmAdapter != NULL)
              {
                  BOOL ok = hook_iat("widevinecdm.dll", pfnOrig, (PROC)my_CreateCdmInstance, hWideVineCdmAdapter);
                  if (!ok)
                  {
                      WriteLog("hook failed:", 0);
                  }
                  else
                  {
                      WriteLog("hook success:", 0);
                  }
                 
              }
              else
              {
                  DWORD err = GetLastError();
                  char buf[20] = { '\0' };
                  _ultoa(err, buf, 10);
                  WriteLog("load widevinecdmadapter.dll failed:", 0);
                  WriteLog(buf, 0);
              }

      以上代码测试在win732位上通过,win10无效。

      1. karll karll

        [secret]大神能透露下是在哪个版本的chromium上做的吗[/secret]

  2. xiaoxian xiaoxian

    大佬请问有小白能用的方法吗? 有偿也行呀!

    1. 没有,实践测试只做到简单的拦截视频流写出.yuv文件播放,若想做成工具也比较麻烦,需要添加拦截开关(即何时开始拦截,何时结束拦截),以及多个网页视频同时拦截,.yuv重新编码回H264,实现比较复杂。

  3. k1ty k1ty

    在头文件前面还有InitializeVideoDecoder这个虚函数的定义,博主有尝试过hook让它返回kInitializationError之后能否用Decrypt方法来解密视频吗?

    1. 试过直接解密报错。

  4. 小明 小明

    很有帮助

  5. doit doit

    [secret]你好,可以付费定制工具吗,方便的话,可以加您QQ吗[/secret]

    1. 没有工具,无法全自动,目前已实现yuv420p编码回h264,按播放列表自动dump视频文件保存到本地,音频暂未处理(音频处理比较麻烦,比如某酷视频开头部分音频未加密是不会走Decrypt方法的,需要dump Decrypt解密后的音频然后合并自己手动下载未加密的部分,最终再与h264视频合并),测试与录屏差不多,使用ffmpeg编码yuvh264比较耗时,可能我电脑的原因编码比较慢,无法使用视频快进加快编码速度,理论上电脑配置足够好的话可以使用快进功能加快视频编码,这样一来相比录屏速度就会加快,编码参数可控,视频大小与编码参数设置有关。

  6. 775 775

    您好,不知道您有兴趣看一下这个网站的widevine吗?可以付费 。https://movie.trueid.net/ 网站的账号我有,如果您感兴趣的话,我邮件联系您

发表评论:

icon_mrgreen.gificon_neutral.gificon_twisted.gificon_arrow.gificon_eek.gificon_smile.gificon_confused.gificon_cool.gificon_evil.gificon_biggrin.gificon_idea.gificon_redface.gificon_razz.gificon_rolleyes.gificon_wink.gificon_cry.gificon_surprised.gificon_lol.gificon_mad.gificon_sad.gificon_exclaim.gificon_question.gif