当开发者决定使用新的Torque语言重新实现两个CodeStubAssembler(CSA)函数时,V8中出现了一个漏洞。这两个函数用于在JavaScript中创建新的FixedArray和FixedDoubleArray对象,虽然新的实现乍一看似乎有效,但它们缺少一个关键组件:最大长度检查以确保新创建的数组的长度是不会超过预定义的上限。对于普通人来说,这种缺失的长度检查看起来很正常,但对于攻击者来说,可以利用TurboFan的typer来访问一个非常强大的利用原语:一个长度字段远大于其容量的数组。该原语为攻击者提供了V8堆上的越界访问原语,很容易导致代码执行。如果想继续漏洞实现过程,需要构建V8版本8.5.51(commit64cadfcf4a56c0b3b9d3b5cc00905483850d6559),建议使用全符号构建(修改args.gn,增加symbol_level=2行)。在x64.release目录中,您可以使用以下命令编译具有零编译器优化的发布版本:find.-typef-execgrep'\-O3'-l{}";"-execsed-i's/\-O3/\-O0/'{}";"-ls如果您想继续阅读这篇博文中的一些代码示例,我仍然建议构建正态分布(启用编译器优化)。如果不进行优化,某些示例将需要很长时间才能运行。从上面链接的错误跟踪器中获取概念证明。2017年之前的V8在2017年之前,许多JavaScript内置函数(Array.prototype.concat、Array.prototype.map等)都是用JavaScript本身编写的,尽管这些函数使用了TurboFan(V8的推测优化编译器,将在稍后详细介绍)。为了最大限度地提高性能,它们的运行速度根本不如用本机代码编写的那么快。对于最常见的内置函数,开发人员会在手写汇编中编写非常优化的版本。这是可能的,因为这些内置函数在ECMAScript规范中有非常详细的描述(单击以查看示例)。但是,它有一个很大的缺点:V8面向大量的平台和架构,这意味着V8开发人员必须为每个架构编写和重写所有这些优化的内置函数。随着ECMAScript标准的不断发展和新语言功能的不断标准化,维护所有这些手写的程序集变得乏味且容易出错。遇到这个问题后,开发人员开始寻找更好的解决方案。直到TurboFan推出V8引擎后才找到解决方案。CODESTUBASSEMBLERTurboFan为低级指令带来跨平台中间表示(IR),V8团队决定在TurboFan之上构建一个新的前端,他们将其称为CodeStubAssembler。CodeStubAssembler定义了一种可移植的汇编语言,开发人员可以使用它来实现优化的内置函数。最重要的是,可移植汇编语言的跨平台特性意味着开发人员只需编写一次内置函数。所有受支持的平台和架构的实际本机代码均由TurboFan编译,您可以在此处阅读有关CSA的更多信息。虽然这是一个很大的改进,但仍然存在一些问题。用CodeStubAssembler的语言编写最佳代码需要开发人员的大量专业知识。即使拥有所有这些知识,仍然存在许多非常规错误,很容易导致安全漏洞,这导致V8团队最终编写了一个名为Torque的新组件。TorqueTorque是一种建立在CodeStubAssembler之上的语言前端,它具有类似TypeScript的语法、强大的类型系统和强大的错误检查功能,所有这些都使其成为V8开发人员编写内置函数的理想选择。TorqueCompiler使用CodeStubAssembler将Torque代码转换为有效的汇编代码,它大大减少了安全漏洞的数量,您可以在此处阅读有关Torque的更多信息。出现漏洞由于Torque仍然相对较新,因此仍然需要重新实现大量CSA代码。这包括CSA代码来处理创建新的FixedArray和FixedDoubleArray对象,它们是V8中的“快速”数组(“快速”数组有一个连续的数组后备存储,而“慢速”数组有一个基于字典的后备存储)。漏洞利用开发人员将CodeStubAssembler::AllocateFixedArray函数重新实现为两个Torque宏,一个用于FixedArray对象,另一个用于FixedDoubleArray对象:如果将上述函数与CodeStubAssembler::AllocateFixedArray变体进行比较,您会注意到最大长度检查。NewFixedArray应确保返回的新FixedArray的长度小于FixedArray::kMaxLength,即0x7fffffd或134217725。同样,NewFixedDoubleArray应根据FixedDoubleArray::kMaxLength(即0x3fffffe或67108862)检查数组的长度。在我们查看如何使用缺失长度检查之前,让我们先了解一下Sergey是如何触发此漏洞的,因为它不像创建一个大于kMaxLength的新数组那么简单。V8中的数组现在,我们需要更多地了解数组在V8中是如何表示的。内存中的数组我们以数组[1,2,3,4]为例,看看它在内存中的表现。您可以通过使用--allow-natives-syntax标志运行V8、创建数组并执行%DebugPrint(array)来获取其地址、使用GDB查看内存中的地址来执行此操作。当你在V8中分配一个数组时,它实际上分配了两个对象。请注意,每个字段的长度为4字节/32位:JSArray对象是实际的数组,它包含四个重要字段以及一些其他重要字段。映射指针:它决定了数组的“形状”,具体来说,它决定了数组存储什么样的元素,它的后备存储是什么类型的对象。在这种情况下,我们的数组存储整数,后备存储是FixedArray。属性指针:指向存储数组可能具有的任何属性的对象的指针。在这种情况下,数组除了长度外没有其他属性,它以内联方式存储在JSArray对象本身中。元素指针:指向存放数组元素的对象。这也称为后备存储,在这种情况下,后备存储指向FixedArray对象,稍后会详细介绍。数组长度:这是数组的长度。在研究人员发布的概念证明中,这是他将长度字段覆盖为0x24242424,从而允许他进行越界读写。JSArray对象的元素指针指向backingstore,它是一个FixedArray对象,有两点需要记住:FixedArray中不需要考虑backingstore的长度,你可以将其覆盖为任意值,但它仍然无法读取或写入边界。每个索引都存储在数组的一个元素上,值在内存中的表示取决于数组的“元素种类”,而这又取决于原始JSArray对象的映射。在这种情况下,值是小整数,它们是最低位设置为零的31位整数。1表示为1<<1=2,2表示为2<<1=4,依此类推。元素类型V8中的数组也有“元素类型”的概念,你可以在这里找到所有元素类型的列表,但是所有表的基本思想是相同的:V8中每次创建一个数组时,它是createdwiththeelement是用kind标记的,kind定义了数组包含的元素的类型。三个最常见的元素如下:PACKED_SMI_ELEMENTS:数组被打包并且仅包含Smis(31位小整数,第32位设置为0)。PACKED_DOUBLE_ELEMENTS:同上,但用于双精度(64位浮点值)。PACKED_ELEMENTS:同上,但数组只包含引用。这意味着它可以包含任何类型的元素(整数、双精度、对象等)。数组也可以在元素类型之间进行转换,但仅限于更通用的元素类型,不能用于更具体的元素类型。例如,类型为PACKED_SMI_ELEMENTS的数组可以转换为类型HOLEY_SMI_ELEMENTS但不能转换为类型HOLEY_SMI_ELEMENTS,即填充数组中已经有空洞的所有空洞不会导致转换为压缩元素类型的变体。下图显示了最常见元素类型的转换格:我们实际上只关心与元素类型相关的两件事:SMI_ELEMENTS和DOUBLE_ELEMENTS类型的数组将它们的元素存储在连续的数组后备存储中,因为它们在内存中。实际表示在。例如,数组[1.1,1.1,1.1]会将0x3ff199999999999a存储在内存中三个元素的连续数组中(0x3ff199999999999a是1.1的IEEE-754表示形式)。另一方面,PACKED_ELEMENTS类型的数组将存储对HeapNumber对象的三个连续引用,这些对象又包含1.1的IEEE-754表示。基于字典的备份存储也有元素种类,但这不是本文的重点。因为SMI_ELEMENTS和DOUBLE_ELEMENTS类数组具有不同的元素大小(Smis是31位整数,而double是64位浮点值),它们也有不同的kMaxLength值。概念证明Sergey提供了两个概念证明:第一个给我们一个HOLEY_SMI_ELEMENTS类型的数组,长度为FixedArray::kMaxLength+3,而第二个给我们一个HOLEY_DOUBLE_ELEMENTS类型的数组,长度为FixedDoubleArray::kMaxLength+1.他只利用第二个概念验证来构造最终的越界访问原语。两种概念证明都使用Array.prototype.concat首先获得一个数组,其大小恰好小于对应元素类型的kMaxLength值。完成后,Array.prototype.splice用于向数组添加更多元素,这导致其长度增长到kMaxLength以上。这是有效的,因为Array.prototype.splice的快速路径间接使用新的Torque函数,如果原始数组不够大,则分配一个新数组。出于好奇,实现此目的的函数调用链可能如下所示:您可能想知道为什么不能创建一个大小刚好低于FixedArray::kMaxLength的大数组并使用它。让我们尝试一下(使用优化的发行版,等待时间会更短):这不仅需要一些时间,而且您还会收到OOM(内存不足)错误!发生这种情况是因为数组分配不会一次性完成。AllocateRawFixedArray的调用有很多次,每次都分配一个稍大的数组。您可以在GDB中看到这一点,方法是在AllocateRawFixedArray上设置断点,然后如上所述分配数组。我不完全确定为什么V8会这样做,但是大量分配会导致V8很快耗尽内存。我的另一个想法是改用FixedDoubleArray::kMaxLength,因为它要小得多(使用优化分布):这确实有效,因为它返回一个类型为HOLEY_DOUBLE_ELEMENTS的新数组,长度设置为FixedDoubleArray::kMaxLength+1,所以这可以用来代替array.prototype.concat。我认为这是因为分配大小为0x3fffffd的数组所需的分配数量足够小,不会导致引擎出现OOM。但是,这种方法有两个缺点:分配和填充一个巨大的数组需要花费大量时间,因此在漏洞利用中并不理想。另一个问题是,在内存受限的环境(例如旧手机)中尝试以这种方式触发exploit可能会导致引擎运行OOM。另一方面,Sergey的第一个概念证明在我的电脑上花费了大约2秒,而且内存效率很高。下面是具体的分析过程。Firstproofofconcept第一个proofofconcept如下,确保你用优化过的release版本运行,否则需要很长时间才能完成:我们一步步分析,在[1]处,创建了一个大小为array0x80000由1填充。这种大小的数组需要大量内存分配,但几乎不会导致引擎OOM。由于数组最初为空,因此它的类型为HOLEY_SMI_ELEMENTS,即使用1填充它也会保留元素的类型。我们稍后会回到[2],但在[3]中创建了一个包含0xff元素的新args数组,每个元素都设置为在[1]中创建的数组。这使得args数组共有0xff*0x80000=0x7f80000个元素。在[4]处,另一个大小为0x7fffc的数组被压入args数组,这使得总共有0x7f80000+0x7fffc=0x7fffffc个元素,0x7fffffc只比FixedDoubleArray::kMaxLength=0x7fffffd少1。在[5]中,Array.prototype.concat.apply将args数组中的每个元素连接到空数组[],您可以在此处阅读有关Function.prototype.apply()工作原理的更多信息,但它实际上将args视为数组参数并将每个元素连接到最终数组中。我们知道元素的总数是0x7fffffc,所以最终的数组会有那么多元素。这种串联发生得相对较快(在我的设备上大约2秒),尽管它比我之前演示的简单创建数组要快得多。最后,在[6]处,Array.prototype.splice向数组附加了4个额外的元素,这意味着它的长度现在是0x8000000,即FixedArray::kMaxLength+3。唯一需要注意的是[2],其中属性被添加到原始数组。要理解这一点,您必须首先了解几乎所有V8内置函数的约定是存在快速路径和慢速路径。对于Array.prototype.concat,采用慢速路径的一种简单方法是向要连接的数组添加属性。快速路径有以下代码:如您所见,快速路径检查以确保最终数组的长度不超过kMaxLength值。由于FixedDoubleArray::kMaxLength是FixedArray::kMaxLength的一半,上述概念证明永远不会通过此检查。随意尝试在没有array.prop=1的情况下运行代码;走着瞧吧!另一方面,慢速路径(Slow_ArrayConcat)没有任何长度检查(但是,如果长度超过FixedArray::kMaxLength它仍然会崩溃并产生致命的OOM漏洞,因为它调用的其中一个函数仍然检查长度。这就是研究人员使用慢速路径的原因,因为快速路径中存在的检查可以被绕过。第二个概念证明(第1部分)虽然第一个概念证明演示了漏洞并可用于开发(在第二个概念证明你只是稍微修改了触发器函数),它需要几秒钟才能完成,这可能也不理想。Sergey选择使用HOLEY_DOUBLE_ELEMENTS类数组。这可能是因为FixedDoubleArray::kMaxLength值明显更小比它的FixedArray变体,导致更快的触发器。如果你理解第一个概念证明,那么第二个概念证明第一部分的以下评论版本是有道理的:此时giant_array的长度为0x3ffffff,即FixedDoubleArray::kMaxLength+1。现在的问题是,我们如何在漏洞利用中使用这个数组?我们没有任何有用的原语,所以我们需要找到引擎的其他部分,其代码取决于数组长度不超过kMaxLength值。对于大多数研究人员来说,漏洞本身确实很容易找到,因为您只需要将函数的新Torque实现与旧实现进行比较即可。了解如何利用它需要对V8本身有更深入的了解。Sergey走的漏洞利用路线是利用了V8的推测优化编译器TurboFan,需要自己导入。本文翻译自:https://www.elttam.com/blog/simple-bugs-with-complex-exploits/#content
