谢尔盖·格拉祖诺夫(Sergey Glazunov)最近在Project Zero BugTracker上披露了2046号问题。这个问题描述了当开发人员决定使用新的TORQUE语言重新实现两个CodeStubAssembler(CSA)函数时引入到V8中的一个错误。这两个函数用于在JavaScript中创建新的FixedArray和FixedDoubleArray对象,虽然新实现乍一看是有效的,但它们缺少一个关键组件:最大长度检查,以确保新创建的数组的长度不会超过预定义的上限。
对于外行来说,这个bug看起来是不可利用的(当然,如果我亲眼看到它,我一开始会认为它是不可利用的),但是正如bug报告中所显示的,Sergey利用turbofan的Typer来访问一个非常强大的利用原语:一个长度字段远远大于其容量的数组。此原语为攻击者提供了V8堆上的越界访问原语,这很容易导致代码执行。
让我们对BugTracker中提供的复杂复制案例执行根本原因分析,并确切地了解如此强大的原语是如何从这样一个简单的bug中实现的。
如果您希望跟随构建V8版本8.5.51,您将需要构建V8版本8.5.51(提交64cadfcf4a56c0b3b9d3b5cc00905483850d6559)。请遵循本指南获取构建说明。我建议使用完整符号进行构建(修改args.gn并添加SYMBOL_LEVEL=2行)。
在x64.release目录中,您可以使用以下命令编译没有编译器优化的发布版本(优化的二进制文件的逐行调试有时可能非常烦人):
找到。-类型f-exec grep';\-O3';-l{}";;";-exec sed-i&39;s/\-O3/\-O0/';{}";;";-ls。
如果您想跟随这篇博客文章中的一些代码示例,我仍然建议您构建普通发布版本(启用编译器优化)。如果不进行优化,一些示例将需要非常长的时间才能运行。
为了理解为什么首先引入这个bug,我们首先必须稍微了解一下CodeStubAssembler的工作方式,以及为什么创建扭矩来替代它。
在2017年前,许多JavaScript内置函数(Array.Prototype.conat、Array.Prototype.map等)都是用JavaScript自己编写的,虽然这些函数使用turbofan(V8的推测性优化编译器,稍后将详细介绍)来尽可能地提高性能,但它们的运行速度根本不如用本机代码编写的快。
对于最常见的内置函数,开发人员会用手写汇编编写非常优化的版本。这是可行的,因为ECMAScript规范(示例)中对这些内置函数的描述非常详细。然而,这也有一个很大的缺点:V8的目标是大量的平台和架构,这意味着V8开发人员必须为每个架构编写和重写所有这些优化的内置函数。由于ECMAScript标准在不断发展,新的语言功能也在不断标准化,维护所有这些手写汇编变得极其乏味且容易出错。
遇到这个问题后,开发人员开始寻找更好的解决方案。直到涡扇被引入V8,他们才找到了解决方案。
Turbofan为低级指令带来了跨平台的中间表示(IR)。V8团队决定在涡扇上建造一个新的前端,他们称之为CodeStubAssembler。CodeStubAssembler定义了一种可移植的汇编语言,开发人员可以使用它来实现优化的内置函数。最棒的是,可移植汇编语言的跨平台特性意味着开发人员只需编写每个内置函数一次。所有支持的平台和架构的实际本机代码都留给turbofan编译。你可以在这里阅读更多关于CSA的信息。
虽然这是一个很大的进步,但仍然有一个小问题。使用CodeStubAssembler的语言编写优化代码需要开发人员在头脑中携带大量专业知识。即使有了所有这些知识,仍然有很多经常导致安全漏洞的不平凡的问题。这导致V8团队最终编写了一个他们称之为扭矩的新组件。
TORQUE是构建在CodeStubAssembler之上的语言前端。它具有类似打字脚本的语法、强大的类型系统和强大的错误检查功能,所有这些都使它成为V8开发人员编写内置函数的一个很好的选择。TORQUE编译器使用CodeStubAssembler将扭矩代码转换为高效的汇编代码。它极大地减少了安全错误的数量,以及以前在学习如何用CSA汇编语言编写高效的内置函数时,新的V8开发人员所面临的陡峭的学习曲线。你可以在这里阅读更多关于扭矩的内容。
因为TORQUE仍然相对较新,所以仍然有相当数量的CSA代码需要在其中重新实现。这包括处理新FixedArray和FixedDoubleArray对象创建的CSA代码,它们在V8中是“快速”数组(“快速”数组有连续的数组后备存储,而“慢”数组有基于字典的后备存储)。
问题2046中的实际错误源于开发人员决定在TORQUE中重新实现这个数组创建内置函数。不幸的是,重新实现错过了导致可利用条件的关键安全检查。
开发人员将CodeStubAssembler::AllocateFixedArray函数重新实现为两个TORQUE宏,一个用于FixedArray对象,另一个用于FixedDoubleArray对象:
宏NewFixedArray<;迭代器:type>;(length:intptr,it:Iterator):FixedArray{if(length==0)return kEmptyFixedArray;return new FixedArray{map:kFixedArrayMap,Length:Convert<;SMI>;(Length),Objects:...。It};}宏NewFixedDoubleArray<;迭代器:type>;(length:intptr,it:Iterator):FixedDoubleArray|EmptyFixedArray{if(length==0)return kEmptyFixedArray;return new FixedDoubleArray{map:kFixedDoubleArrayMap,Length:Convert<;SMI>;(Length),浮点数:...。IT};}。
如果将上述函数与CodeStubAssembler::AllocateFixedArray变量进行比较,您会注意到缺少最大长度检查。
NewFixedArray应确保返回的新FixedArray的长度小于FixedArray::kMaxLength,即0x7fffffd或134217725。
同样,NewFixedDoubleArray应该对照FixedDoubleArray::kMaxLength(0x3fffffe或67108862)检查数组的长度。
在我们了解如何处理这个缺失长度检查之前,让我们先试着理解Sergey是如何触发这个bug的,因为它不像创建一个大小大于kMaxLength的新数组那么简单。
为了理解概念证明,我们需要更多地了解V8中数组的表示方式。如果您已经知道这是如何工作的,请随意跳到下一节。
让我们以数组[1,2,3,4]为例,在内存中查看它。您可以通过运行带有--allow-native-SYNTAX标志的V8、创建数组并执行%DebugPrint(Array)来获取其地址来实现这一点。使用gdb查看内存中的地址。我将展示一张图表来代替。
在V8中分配数组时,它实际上分配了两个对象。请注意,每个字段的长度为4字节/32位:
A JSArray对象A FixedArray对象+-++。+地图指针|+-+|+-+|属性指针|后备存储长度|+。。|index 1|+-+-|其他不重要的字段...。|0x00000006|索引2|+-++。
JSArray对象是实际的数组。它包含四个重要字段(以及其他一些不重要的字段)。以下是以下内容:
映射指针-它确定数组的“形状”。具体地说,它确定数组存储什么类型的元素,以及它的后备存储是什么类型的对象。在本例中,我们的数组存储整数,后备存储是FixedArray。
属性指针-指向存储数组可能具有的任何属性的对象。在本例中,数组除了长度之外没有任何属性,长度以内联方式存储在JSArray对象本身中。
元素指针-指向存储数组元素的对象。这也称为后备存储器。在本例中,后备存储指向FixedArray对象。稍后会有更多关于这一点的报道。
数组长度-这是数组的长度。在Sergey的概念证明中,这是他改写为0x24242424的长度字段,这样他就可以越界读取和写入。
我们的JSArray对象的元素指针指向后备存储,它是一个FixedArray对象。关于这一点,有两个关键问题需要记住:
FixedArray中的后备存储长度根本无关紧要。您可以将其覆盖为任何值,但仍然不能读取或写入越界。
每个索引存储在数组的元素上。值在内存中的表示形式由数组的“元素种类”决定,而数组的“元素种类”由原始JSArray对象的映射决定。在本例中,这些值是小整数,即底部位设置为零的31位整数。1表示为1=2,2表示为2=4,依此类推。
V8中的数组也有元素种类的概念。您可以在这里找到所有元素种类的列表,但其基本思想如下:任何时候在V8中创建数组时,都会用Elements种类来标记它,它定义了数组包含的元素类型。最常见的三种元素类型如下:
PACKED_SMI_ELEMENTS:数组是打包的(即它没有洞),并且只包含SMI(31位小整数,第32位设置为0)。
PACKED_ELEMENTS:与上面相同,只是数组只包含引用。这意味着它可以包含任何类型的元素(整数、双精度数、对象等)。
这些元素种类还有一个HOLEY变量(HOLEY_SMI_ELEMENTS等),它告诉引擎数组中可能有孔(例如,[1,2,4])。
数组也可以在元素类型之间转换,但是转换只能朝向更一般的元素类型,而不能朝向更具体的元素类型。例如,具有PACKED_SMI_ELEMENTS类型的数组可以转换为HOLEY_SMI_ELEMENTS类型,但转换不能反过来发生(即,填满已有孔的数组中的所有空洞不会导致转换到PACKED ELEMENTS类型变量)。
下面的图表展示了最常见的元素类型的转换晶格(摘自V8博客文章,我建议您阅读它以获取有关元素类型的更多信息):
就这篇博客文章而言,我们实际上只关心与元素类型相关的两件事:
SMI_ELEMENTS和DOUBLE_ELEMENTS种类数组将它们的元素存储在一个连续的数组后备存储中,作为它们在内存中的实际表示。例如,数组[1.1,1.1,1.1]将把0x3ff199999999999a存储在内存中由三个元素组成的连续数组中(0x3ff199999999999a是1.1的IEEE-754表示)。另一方面,PACKED_ELEMENTS种类数组将存储对HeapNumber对象的三个连续引用,而HeapNumber对象又包含1.1的IEEE-754表示。也有基于字典的后备存储的元素种类,但对于本文来说这并不重要。
因为SMI_ELEMENTS和DOUBLE_ELEMENTS种类数组的元素大小不同(SMI是31位整数,而DOUBLE是64位浮点值),所以它们也有不同的kMaxLength值。
Sergey提供了两个概念证明:第一个给出了一个长度为FixedArray::kMaxLength+3的HOLEY_SMI_ELEMENTS类型的数组,第二个给出了一个长度为FixedDoubleArray::kMaxLength+1的HOLYY_DOUBLE_ELEMENTS类型的数组。他只利用第二个概念证明来构造最终的越界访问原语,稍后我们将了解他为什么这样做。
这两个概念证明都使用Array.Prototype.tenat来初始获得一个数组,该数组的大小略低于相应元素种类的kMaxLength值。完成此操作后,将使用Array.prototype.plice将更多元素添加到数组中,这会导致数组的长度增加到超过kMaxLength。这之所以可行,是因为如果原始数组不够大,Array.Prototype.plice的快速路径会间接使用新的扭矩函数来分配新的数组。对于好奇的人来说,执行此操作的可能函数调用链之一如下所示:
您可能想知道为什么不能创建一个大小刚好低于FixedArray::kMaxLength的大型数组并使用它。让我们试一试(使用优化的发布版本,除非您想等待很长时间):
$./d8V8版本8.5.51d8>;Array(0x7fffff0)//FixedArray::kMaxLength is 0x7fffffd[...]##无效数组长度中的致命javascript OOM#接收信号4 ILL_ILLOPN 5650cf681d62[...]。
这不仅需要一点时间才能运行,我们还会收到OOM(内存不足)错误!之所以会发生这种情况,是因为数组的分配不是一次完成的。有大量对AllocateRawFixedArray的调用,每个调用都分配一个稍大的数组。您可以通过在AllocateRawFixedArray上设置断点,然后分配如上所示的数组,在GDB中看到这一点。我不完全确定为什么V8会这样做,但是如此多的分配会导致V8很快耗尽内存。
我的另一个想法是改用FixedDoubleArray::kMaxLength,因为它要小得多(同样,使用优化的发布版本):
//FixedDoubleArray::kMaxLength=0x3fffffe let array=(new Array(0x3fffffd))。Fill(1.1);数组。拼接(阵列。Length,0,3.3,3.3);//长度现在为0x4000000。
这确实有效,并且它返回一个新的HOLEY_DOUBLE_ELEMENTS种类数组,其长度设置为FixedDoubleArray::kMaxLength+1,因此可以使用该数组来代替Array.Prototype.tenat。我相信这样做的原因是因为分配大小为0x3fffffd的数组所需的分配数量足够小,不会导致引擎出现OOM。
不过,这种方法有两个很大的缺点:分配和填充这个巨大的数组需要相当长的时间(至少在我的机器上是这样),因此它在利用漏洞方面并不理想。另一个问题是,在内存受限的环境(例如,旧手机)中尝试以这种方式触发错误可能会导致引擎出现OOM。
另一方面,Sergey的第一个概念证明在我的机器上大约需要2秒,并且内存效率非常高,所以让我们来分析一下。
第一个概念证明如下。确保您使用优化的发布版本运行它,否则将需要非常长的时间才能完成:
Array=Array(0x80000)。Fill(1);//[1]数组。属性=1;//[2]参数=数组(0x100-1)。Fill(Array);//[3]个参数。Push(Array(0x80000-4)。Fill(2));//[4]GREAGE_ARRAY=Array。原型。康卡特。Apply([],args);//[5]GREAGE_ARRAY。拼接(巨型阵列。长度,0,3,3,3,3);//[6]。
在[1]处,创建了一个大小为0x80000的数组,并用1填充。这种大小的数组需要大量的分配,但根本无法使引擎进入OOM状态。因为数组最初是空的,所以它会获得HOLYY_SMIEMENTS类型,并且即使在填充了1之后也会保留该元素类型。
我们稍后会回到[2],但是在[3]中,创建了一个具有0xff元素的新args数组。每个元素都设置为在[1]处创建的数组。这使args数组总共有0xff*0x80000=0x7f80000个元素。在[4]处,另一个大小为0x7fffc的数组被推到args数组上,这使它总共有0x7f80000+0x7fffc=0x7fffffc元素。0x7fffffc仅比FixedDoubleArray::kMaxLength=0x7fffffd少1。
在[5]处,Array.Prototype.connecat.Apply将args数组中的每个元素连接到空数组[]。您可以在这里阅读有关Function.Prototype.Apply()如何工作的更多信息,但它实际上将args视为参数数组,并将每个元素连接到得到的最终数组。我们知道元素的总数是0x7fffffc,所以最终的数组将有那么多元素。这种连接发生得有点快(在我的机器上大约需要2秒),尽管它比我前面展示的简单地创建数组要快得多。
最后,在[6]处,Array.Prototype.plice向数组追加4个额外的元素,这意味着它的长度现在是0x8000000,即FixedArray::kMaxLength+3。
剩下的唯一需要解释的是[2]将属性添加到原始数组的位置。要理解这一点,您必须首先了解几乎所有V8内置函数的约定都是有快路径和慢路径。在Array.Prototype.conat的例子中,采用慢速路径的一种简单方法是向正在连接的数组添加一个属性(实际上,对于大多数Array.Prototype.*内置函数来说,这是采用慢速路径的一种简单方式)。为什么谢尔盖要走慢道呢?嗯,因为快速路径具有以下代码:
//builtins/builtins-array.cc:1414 MaybeHandle<;JSArray>;Fast_ArrayConcat(Isolate*Isolate,BuiltinArguments*args){//...//如果(FixedDoubleArray::kMaxLength<;result_len||FixedArray::kMaxLength<;result_len){AllowHeapen){AllowHeapen。
如您所见,快速路径检查以确保最终数组的长度不超过任何一个kMaxLength值。由于FixedDoubleArray::kMaxLength是FixedArray::kMaxLength的一半,因此上述概念证明永远不会通过此检查。您可以尝试在不使用array.prop=1的情况下运行代码,看看会发生什么!
另一方面,慢速路径(Slow_ArrayConcat)没有任何长度检查(但是,如果长度超过FixedArray::kMaxLength,它仍然会崩溃,并出现致命的OOM错误,因为它调用的函数之一仍会检查该长度)。这就是谢尔盖的概念证明使用慢速路径的原因:绕过快速路径上存在的检查。
虽然第一个概念证明演示了该漏洞,并且可以用于攻击(您只需稍微修改第二个概念证明中的触发器函数),但它需要几秒钟才能完成(在优化的发布版本上),这可能也不理想。Sergey选择使用holey_Double_Elements种类数组。这可能是因为FixedDoubleArray::
.