转换复杂性阻碍了使用 Ghidra 的 Widevine 解密

2021-08-01 23:16:20

这项工作(显然)基于widevine-l3-decryptor 扩展。许多部分是相同的,部分自述文件是逐字复制等。 Tldr:结果似乎有效,但依赖于将代码提升到 wasm 模块和大量蛮力,导致大约 15 分钟等待单个 RSA解密。更新在编写自述文件时,我发现编码表 Bignum 算法取自 CryptoPP 库。我发现它是最容易使用的库,也最容易编译成 wasm。适用于 4.10.2209 版本的 64 位 Windows 的widevine。不太可能适用于任何其他版本。 Widevine 是 Google 拥有的 DRM 系统,许多流行的流媒体服务(Netflix、Spotify 等)都在使用它来防止媒体内容被下载。但是,Widevine 最不安全的安全级别 L3,在大多数浏览器和 PC 中使用,是 100% 在软件中实现的(即没有硬件 TEE),从而使其具有可逆性和可绕过性。这个 Chrome 扩展演示了如何通过劫持对浏览器的加密媒体扩展 (EME) 的调用和(非常缓慢地)解密所有传输的 Widevine 内容密钥来绕过 Widevine DRM - 有效地将其转换为 clearkey DRM。

要查看此概念的实际效果,只需在开发人员模式下加载扩展程序并浏览到任何播放受 Widevine 保护的内容的网站,例如 https://bitmovin.com/demos/drm 。首先,扩展程序将尝试暴力输入代码提升部分的编码。然后,假设它成功,密钥将以明文形式记录到 javascript 控制台。 (更新:现在将避免暴力破解)解密媒体本身只是使用可以解密 MPEG-CENC 流的工具(如 ffmpeg)的问题。老实说,DRM 是在各种形式的媒体上滋生的毒瘤,执行或强制执行的人在道德上是可憎的,对社会没有好处。考虑到这一点,我很伤心2021年5月,原来的扩展将很快过时学习。我发现自己有一些空闲时间,所以我决定尝试复制原始密钥提取。不幸的是,关于原始作者使用什么过程的数据并不多,甚至对于谁是执行提取的人有些困惑。尽管如此,我还是决定试一试,并希望能稍微增强我的自信心。我没有成功完成这些任务,但我设法编写了一个几乎无法运行的解密器,并决定记录我遵循的步骤,以防其他人使用它们。为了处理可执行文件,我决定使用 Ghidra,尽管它与 NSA 有关联,主要是因为它是免费的并且具有我想要的大多数功能。我还写了一个简单的片段来调试 dll。 lib = LoadLibrary(L"widevinecdm_new.dll"); if (lib != NULL){ InitializeCdmModule init_mod = (InitializeCdmModule)GetProcAddress(lib, "InitializeCdmModule_4"); CreateCdmInstance create = (CreateCdmInstance)GetProcAddress(lib, "CreateCdmInstance"); GetCdmVersion getver=(GetCdmVersion)GetProcAddress(lib, "GetCdmVersion"); printf("%d\n", (ulonglong)init_mod); init_mod(); printf("%d %s\n", (ulonglong)create,getver()); getchar(); printf("正在创建\n"); std::string keys = "com.widevine.alpha"; std::string clearkeys = "org.w3.clearkey"; ContentDecryptionModule_10* cdm =(ContentDecryptionModule_10 *) create(10, keys.c_str(), keys.length(), GetDummyHost, (void*) msg​​); printf("创建?%d \n", (ulonglong)cdm);无符号整数 pid = 10; const char* sid = "Sessid"; //pssh框? byte initdata[92]= { 0, 0, 0, 91, 112, 115, 115, 104, 0, 0, 0, 0, 237, 239, 139, 169, 121, 214, 74, 206, 206 , 39, 220, 213, 29, 33, 237, 0, 0, 0, 59, 8, 1, 18, 16, 235, 103, 106, 187, 203, 52, 94, 150, 2708 , 102, 48, 241, 163, 218, 26, 13, 119, 105, 100, 101, 118, 105, 110, 101, 95, 116, 101, 115, 3, 17, 110 10 , 108, 106, 97, 83, 100, 102, 97, 108, 107, 114, 51, 106, 42, 2, 72, 68,50, 0}; gcdm=cdm; printf("第一次比较:%d\n",(int)((byte *)gcdm)[0x92]); cdm->初始化(真,假,假); printf("Sc 比较:%d\n", (int)((byte*)gcdm)[0x92]); getchar(); cdm->CreateSessionAndGenerateRequest(pid,SessionType::kTemporary,InitDataType::kCenc,initdata,91);} 上面代码片段中的所有结构都是从 Chromium eme 源中复制的,例如这里。首先,我尝试运行生成的程序,生成正确的签名作为(中间)结果。然而,试图在调试器中跟踪它会导致问题。起初,它只是因访问冲突而崩溃。修改 PEB 中的 BeingDebugged 字段后,它反而进入了无限循环。

查看反编译后的代码,在大多数 API 函数中发现了大量奇怪的 switch 语句。它看起来有点像(来自 Ghidra 反编译器): while(true) { uVar5 = (longlong)puVar3 + (longlong)(int)puVar3[uVar5 & 0xffffffff]; switch(uVar5) { case 0x1800f489e: uVar5 = 5;转到 LAB_1800f488e;案例 0x1800f48ad:local_20 = local_2c + 0x47b0e7d4; uVar5 = 3;转到 LAB_1800f488e;案例 0x1800f48c1:uVar5 = 0x17;转到 LAB_1800f488e;案例 0x1800f48c8:local_28 = local_20 - local_2c; bVar1 = (int)(uint)local_21 < (int)(local_28 + 0xb84f182c); unaff_RSI = (未定义 *)(ulonglong)bVar1; uVar5 = (ulonglong)((uint)bVar1 * 5 + 2);转到 LAB_1800f488e;案例0x1800f48f4:local_28 = local_2c + 0xd689ea6; uVar5 = 0x16;转到 LAB_1800f488e;案例 0x1800f4908: uVar5 = 0x19;转到 LAB_1800f488e;案例 0x1800f491a:local_2c = local_2c & local_28; uVar5 = 1;转到 LAB_1800f488e; case 0x1800f492c: if (true) { *(undefined *)&param_1->_vtable = *unaff_RSI; unaff_RSI[1] = unaff_RSI[1] - (char)(uVar5 >> 8); /* 警告:错误指令 - 在此处截断控制流 */halt_baddata(); }... 经过几天的调查,很明显这是一种代码混淆形式,将代码流分解为小段,并按照原始 PRNG 定义的顺序将它们排列在 switch 语句中。可以控制 PRNG 执行 if/else 语句和循环。到达halt_baddata 部分时会导致访问冲突崩溃。任何超出边界的跳转表索引都会导致 while(true) 无限期执行。由于 switch 是由 PRNG 驱动的,反编译器似乎无法找到跳转表的限制,从而导致无效的 switch 语句或损坏的反编译。我试图通过修复跳转表来改善这种情况,但结果并不令人鼓舞。然后我尝试使用 Ghidra Emulator API 来遵循指令流。经过大量的实验,我得出以下结论:许多开关案例几乎是重复的,有些要么从未达到,要么只有在检查失败、程序崩溃或将其发送到无限循环的情况下才达到。大多数反调试代码似乎与此处描述的内容相似。调试器窗口名称列表完全相同,这很有趣(而且已经过时)。一些函数实际上使用内存校验和作为 PRNG 种子,这使得在不知道校验和的情况下猜测它会去哪里是不可能的。以及计算它需要多少次迭代。以及中间各种检查的结果。等等...没有任何反调试器技巧被仿真激活,但仿真实际上比直接 CPU 执行慢数百甚至数千倍,因此校验和计算可能需要几个小时(取决于日志详细程度)。

仅模拟一个函数并没有多大帮助,因为流程可能取决于输入参数 :( 。在那之后,我尝试反转代码中找到的 Protobuf 编码/解码函数。虽然我确实设法找到了其中的一些(使用 getchar 作为方便断点附加调试器),它们与原始存储库中的 Protobuf 函数不匹配,导致我认为源文件被更改了。例如,SignedMessage 现在有超过 9 个字段,而不是原来的 5 个。幸运的是,协议似乎落后足够兼容,所以仍然可以提取必要的签名/密钥。为了解析 protobuf 消息,我使用了原始扩展或这个方便的网站。无论如何,该调查似乎没有任何结果,最终(几周后)和很多诅咒),我决定在 Ghidra 中模拟整个程序。为此,我开发了一个简单的脚本来模拟 DLL 进行的系统和主机调用。只需运行即可提取必要的系统调用直到遇到无法执行的代码并用 python 函数替换它为止。顺便说一句,脚本一次执行一条指令,所以它比使用 Ghidra 断点慢,但对我来说更容易管理。操纵它允许我转储程序流和内存内容的日志,以及保存和恢复模拟器状态。最终,我设法达到了模拟器形成有效签名并用它调用假主机代码的地步。不过花了几天时间,最长的部分似乎是生成跳转表和计算表。之后,就是将签名追溯到生成函数的问题了。盯着反编译不好的代码墙看了一会儿后,我意识到它的一部分实现了一个简单的 Bignum 乘法算法,但他们没有使用线性数组,而是使用了类似 PRNG 的函数排列的数组,因此每个算术运算都在置换生成器看起来像这样: uint * PFUN_180119595(uint *param_1,uint *out,uint length,int param_4){ byte *pbVar1; uint uVar2; ulonglong uVar3; uint uVar4; uint uVar5; ulonglong uVar6; bool bVar7; uVar6 = (ulonglong)(长度 * 4); if (*(char *)((longlong)out + uVar6) != '\x02') { do { pbVar1 = (byte *)((longlong)out + uVar6); bVar7 = *pbVar1 == 0; *pbVar1 = *pbVar1 ^ bVar7 * (*pbVar1 ^ 1); } while ((byte)(!bVar7 * *pbVar1) == '\x01'); if (bVar7) { if (length != 0) { uVar5 = param_4 * 0xe286515; uVar3 = 0;做 { uVar2 = param_1[uVar3]; uVar4 = uVar2 ^ uVar5;出[uVar3] = uVar4; uVar5 = uVar5 + uVar2 * uVar4; uVar3 = uVar3 + 1; } 而(长度!= uVar3); } *(undefined *)((longlong)out + uVar6) = 2; } } return out;} 大部分时间都使用相同的函数,但具有不同的偏移量和初始数组,从而导致各种排列。无论如何,我能够粗略地识别在 256 字节数组上执行的蒙哥马利乘法、减法和加法(意味着使用 2048 位密钥)。最重要的因素之一是“ADC”汇编命令的使用,主要限于代码的两个区域,我暂时将其确定为“签名生成”和“会话密钥解密”。我专注于前者,因为我可以访问和验证输出。然而,这确实提出了函数采用什么样的输入的问题。稍后再谈。当然,混淆背后的病态、虐待狂并没有使用直接的幂运算算法。如谷歌专利 US20160328543A1 中所述,它们将输入乘以常数并通过反转常数输出,使用置换函数来混淆内存布局,有时似乎使用“拆分变量”,尽管在这种情况下并不常见。在任何情况下,得到的求幂函数也有一些最终相互抵消的加法。

为了从代码中提取指数,我首先记录了似乎在 bignum 上运行的函数的大部分输入和输出,使用内存中已经生成的表来解读排列。然后,我使用 python 脚本来猜测对数字执行的操作,并使用单独的脚本将这些操作映射到树中。当我尝试各种事情时,第二个脚本经历了多次迭代,包括添加双数支持以从结果的导数中提取指数。最终,我选择了简单的单变量追踪。找到一条不会导致多项式幂的指数爆炸的路线有点困难,但最终(再一次,经过一两个星期的工作:|)我成功地提取了一个指数和乘法常数:一个可以很容易看出指数的长度是 3072 位,比预期的要长很多(2048)。显然,由于指数是周期性的,它可以扩展到任何长度。还可以确认这不是一个完整的指数,因为函数中的第一个类似 bignum 的结构与加密输入不匹配。 (使用公共指数 65537 可以轻松完成 RSA 的解密)。也没有线性。或二次方,或...(我将多项式检查到大约 128 次方)依赖性。这使我进入下一个阶段。如果要查看发生幂运算的函数,您会发现它有太多的输入参数。在那里, Param_1 似乎是恒定的,或者至少与输入无关。仍然需要得到正确的结果,但可以用静态数组表示。然而,从 2 到 5 的参数确实取决于输入......不知何故。它们也被 ConstUser_18016b077 函数在循环中过度排列。这些函数代表了输入混淆的一半,也是这个 repo id 被称为“guesser”的原因。他们使用一系列运行时生成的查找表来对奇怪编码的输入执行各种功能。序列也是运行时生成或解包的,我不确定。对该函数的每次调用都包含序列偏移量、处理长度等,所有这些都组合在一起成为一个 64 位数字。在这种情况下,奇数编码是指以下内容:每个 X 字节数字被拆分为 X*4 个 2 位的块,另外两个块附加有似乎是任意数字的内容,结果通过上述 Const 之一传递函数,导致每个字节中存储 3 位(!),就像这样(256 字节数的十六进制表示为 1026 字节):(从现在开始我将称其为长形式) ConstUser_18016b077 中的查找表本质上映射了 11 位数字( 2x3 位+5 位“进位”)到 8 位数字(3 位输出加进位)。代码中还有其他表可以处理更多的位。但是,由于输入和输出以随机顺序排列(并且可能有一个进位位),我一生都无法弄清楚每个(数千个)表实际上做了什么。每个操作似乎都调用了一个新表,或者至少调用了一个新的序列偏移量。无论如何,我们有 4 个或那些数字以某种方式从输入生成并呈现给幂函数。它们被分成 18 字节重叠增量,在循环中处理,压缩回 4 字节整数并传递给另一个函数:哪里......我不知道:(我花了很多时间寻找在代码中,但直到今天我都不知道它对 4 个输入缓冲区究竟做了什么。这些缓冲区似乎不是 256 字节 bignums 的表示(缓冲区长度各不相同,但大多是 90 的倍数)。很多操作涉及准备工作,如

do { *(int*)(local_8f90 + lVar6 * 4) = *(int*)(local_8f90 + lVar6 * 4) + *(int*)(local_8500 + lVar6 * 4) * *(int*)((longlong) DAT_18091b030 + (ulonglong)((uint)lVar6 & 7) * 4); lVar6 = lVar6 + 1; } while (lVar6 != 0x4a);哪个似乎在丢弃进位时执行 4 字节整数的乘法和加法?然后有这样的操作: do { uVar8 = (uVar8 >> 4) + *(longlong*)(DAT_18091af30 + (ulonglong)((uint)uVar8 & 0xf) * 8); *(ulonglong*)(local_8f90 + lVar12 * 8 + 8) = uVar8; lVar12 = lVar12 + 1; } while (lVar12 != 0x10); iVar46 = (int)lVar6; lVar6 = *(longlong *)(&local_8f90[128]) * 0x434c3d5000000000 + *(longlong*)(&local_8f90[56]) * 0x7c7bcb1aebcb3c2b + 0x7ffc69ede4fe8哪位好象用查表(DAT_18091af30)查8字节进位?是的,令我非常惭愧的是,我不知道我在看什么。唯一想到的是高度混淆的 Schönhage-Strassen 算法,或者来自那个家族的东西,即涉及傅立叶或数论变换的东西。这将允许某些运算以二的模幂或二加一的模幂执行,而无需像更简单的乘法算法那样使用进位。所以,如果有人有任何好的想法,请提出问题或请求请求?代码可用......在花了太多时间呆呆地盯着反编译器并尝试在 Ghidra 模拟器中运行代码修改之后,我决定尝试将反编译的代码转储到 C++ 文件中并使其再次编译,“明亮”的想法是“也许操纵输入会给我一些洞察力”。我相信这就是所谓的“代码提升”?这带来了一系列挑战。主要的一个事实是反编译器被重叠的缓冲区访问所混淆,并且无法正确分离局部变量。另一个是 Ghidra 反编译器团队中的某个人认为访问 uint64 中的最后两个字节应该表示为 variable._6_2 而不是说 ((short*)&variable)[3]。其中之一是不正确的 C...所以我不得不通过代码并替换它。以及对堆栈变量重叠和拆分的猜测,这需要数周的艰苦寄存器比较。下一个障碍是一个函数,它采用两个已编码为长格式的缓冲区并吐出几乎输出的长格式。那个首先运行表生成(解包?)然后跳到运行时生成点。然后它使用一长串地址和值来跳过 6(?)个可能的代码点并对数据执行各种操作。数组中的结构看起来有点像: 数组很长... 5153 次操作很长。如果我对傅立叶变换的猜测是正确的,那可能是执行逆变换的函数,但再一次,不知道;(

代码提升的最后一个障碍,也是对 wasm 大小贡献最大的障碍,是不断提取。一些常量从一开始就可用,而其他常量,例如查找表,则是在运行时的不同点生成的。使用了 600 多个常量,所以最后我只是用 python 脚本自动从内存转储中抓取它们,而没有检查适当的长度,这导致了很多重叠(常量太长比访问冲突更好未定义的行为)。通过小心地去除重叠(然后检查,因为有些似乎是必要的),可能可以将 wasm 大小至少减少一半。执行完所有这些之后,我设法用 C++ 代码重新创建了 HasMulAdc_18016d24d。不幸的是,我没有获得任何洞察力。实际输入数对输入缓冲区的依赖性似乎也是高度非线性的。经过大量的反复试验,我别无选择,只能重新创建签名的输入函数,幸运的是,它没有被 switch 语句混淆。然而,与以前的版本不同,要取幂的实际 RSA 消息在运行时从不在内存中,因此我必须从 protobuf 消息跟踪它的创建。我提出的第一个想法之一,最终证明是最富有成效的,是跟踪 SHA1 调用。根据 wiki,所有 SHA1 调用都应使用相同的起始值:通过在内存中搜索这些值或舍入常量并跟踪对它们的引用,我设法找到了一些似乎可以计算 SHA1 的区域,其中一个非常接近求幂代码(删节):void Longstringproc_18017e3b0(byte **param_1,stdstring *data,uint len,stdstring *param_4){ byte *charbuffer; longlong lVar1; undefined8 local_24b8;字节输出_24b0 [512];字节 local_22b0 [2056];字节 local_1aa8 [2056];字节 local_12a0 [1040];字节 local_e90 [1040];字节 local_a80 [1032];字节 local_678 [1032];字节罗……