TurboFan
请注意,TurboFan是V8的一个极其复杂的组件,因此本节不会对其进行详尽的介绍。
V8与当今大多数大型JavaScript引擎一样,最初只是JavaScript的编译器。随着计算机的函数越来越强大,人们希望开始在浏览器中运行越来越复杂的应用程序,因为面对现实,基于浏览器的应用程序才更具可移植性。尽管仅使用了编译器,V8很快就达到了性能极限。
当达到限制时,V8开发人员就构建了TurboFan。下图大致演示了V8当前的工作方式:
V8执行过程
优化性
TurboFan会逐个对函数编译JavaScript代码,在JavaScript中最初几次调用函数时,该函数由编译器执行,该编译器收集类型反馈。在这种情况下,类型反馈仅表示有关在函数中传递或使用的类型的信息。这种类型的反馈随后被TurboFan用于“推测性”优化,这基本上意味着TurboFan会使用这种类型的反馈做出假设来生成本地代码。
让我们看一个例子,假设你有以下代码:
当add(5,10)在循环中被调用10000次时,编译器将收集类型反馈,TurboFan将使用该反馈进行假设并生成本机汇编代码,将val1和val2像Smis一样简单地相加。
这个本机汇编代码运行得非常快,但是这里有一个问题。 JavaScript是一种动态类型的语言,因此没有什么可以阻止你将字符串或对象传递给add函数。TurboFan需要防范这种情况,因为将两个字符串加在一起与将两个数字加在一起是完全不同的,所以在这两种情况下运行相同的本机代码会导致漏洞。为防止这种情况,将类型防护插入到本机代码中,以检查val1和val2是否都是Smi类型,只有在类型检查通过时才执行本机代码。如果任何一项检查失败,那么代码将被优化,并且执行将释放给可以处理所有情况的编译器。
让我们使用V8的--trace-opt和--trace-deopt标志运行以上代码,以查看优化和反优化的过程:
你会看到,由于“调用的类型反馈不足”,该函数得到了优化,后来又取消了优化,这是有道理的!我们从来没有用字符串调用过函数,因此没有类型反馈可以引用,因此是非优化的。
优化阶段
TurboFan不仅是一种推测性的编译器,它也是一个优化的编译器,这意味着它具有许多不同的优化阶段。
TurboFan的简略优化阶段
如果你以前学习过编译,则其中的某些术语对你来说似乎很熟悉。它们只是TurboFan在某些优化阶段执行的编译器优化。但是,这些优化对于解析器生成的AST很难做到,因此TurboFan将AST转换为另一种表示形式:“sea of nodes”图。
sea of nodes
TurboFan在源代码的节点表示形式上进行了优化,我将简要介绍它的工作原理,但是如果你愿意,可以同时两篇(1 ,2 )参考文章,以更好地理解它。
sea of nodes图中的节点可以表示高级操作或低级操作,节点本身与边缘连接,并且有三种类型的边缘。
Value 边缘用于将值传递到可以使用它们的节点中, 在下面的示例中,值1和5使用Value 边缘传递给添加节点。
控制边缘用于定义控制流。例如:
最后,效果边缘(Effect 边缘s)用于显示更改程序状态的操作顺序。这些边缘由虚线定义(其他两种类型的边缘用实线绘制)。了解效果边缘的一种方法是考虑一种方案,在该方案中,加载对象的属性,对其进行修改,然后将其存储回该对象中。在sea of nodes图中,加载节点的效果输出边缘将进入修改节点,而修改节点的效果输出边缘将进入存储节点。这样,引擎就知道操作的顺序是加载->修改->存储。
以下展示了效果边缘:
Turbolizer效果边缘
让我们尝试查看我之前显示的那个添加函数的节点表示形式,为此,我们将使用Turbolizer,这是由V8开发人员创建的工具,用于在各个优化阶段查看TurboFan的sea of nodes图。
在v8根目录下执行以下操作来设置Turbolizer(确保先安装最新版本的npm和nodejs):
完成此操作后,在Chrome或Chromium(必须是这些浏览器之一)中访问https://localhost:1337,你将看到以下截图:
Turbolizer截图
Turbolizer的工作方式是输入.json文件,你可以通过使用--trace-turbo选项运行V8生成该文件。例如,你可以运行add函数的代码,如下所示:
你看到的turbo-*.json是你需要上传到Turbolizer的文件。点击Turbolizer右上角的按钮并上传文件,你应该看到以下内容:
Turbolizer添加函数:图形构建器阶段
你可以在左侧看到实际的源代码,而在右侧你可以看到生成的设备代码。在中间,你会看到大量的节点图,当前图中隐藏了许多节点(这是默认行为)。
我们要查看Typer阶段中的所有节点,因为我们想看看TurboFan如何输入函数。从顶部的下拉菜单中,选择V8.TFTyper选项。接下来,点击“显示所有节点”按钮,然后点击“布局图”按钮,最后点击“切换类型”按钮。这将显示每个节点,整齐地显示图形,并显示TurboFan推测的类型。
该图当前在单个屏幕截图中无法查看的节点过多,因此我将有选择地隐藏不重要的节点。如果你想知道我如何知道哪些节点很重要而哪些节点不重要,那仅仅是基于以前使用过Turbolizer的知识。我建议只是尝试使用不同的测试代码,然后看看Turbolizer如何布置sea of nodes,以了解更多信息,我之前提到的博客文章也介绍了一些示例。
Turbolizer添加函数:Typer阶段
你会注意到一些事情:
1.一开始,我们有一个循环节点,它告诉我们,我们要进入一个循环。由此产生的进入分支和LoopExit节点的边缘是控制边缘。
2.一个带有Any类型的Phi节点和一个带有NumberConstant[10000]类型的范围(10000,10000)的节点被传递给一个带有Boolean类型的speculativenumberless节点。Phi节点是我们的循环变量,此处将其与NumberConstant [10000]进行比较以查看其是否小于10000。此处的边缘是Value 边缘。
3.SpeculativeNumberLessThan节点通向“分支”节点,该分支节点又会引导到一个IfTrue或IfFalse节点。如果到达IfFalse节点(即循环变量不小于10000),则只需执行LoopExit。
4.在IfTrue分支中时,会发生两件事。首先,我们使用SpeculativeSafeIntegerAdd将两个常量5和10一起添加,该常量已使用Range(15,15)正确输入。其次,我们看到循环变量添加了常量1。此添加的SpeculativeSafeIntegerAdd节点已使用Range(-9007199254740991,9007199254740991)进行了输入。这样做是因为循环变量的初始类型是Any,这意味着TurboFan需要处理所有可能将Any类型添加到常量1的情况,这是一个很大的范围。
5.Branch,IfTrue和IfFalse边缘之间的边缘都是控制边缘。
6.TurboFan输入使用Range(Min, Max)的数字,如果推测它们只是一个可能的值,那么最小值和最大值相等,否则它们是不相等的(例如在循环变量的情况下)。
7.TurboFan也为我们做了另一个优化,它已将add函数内联到循环中(请注意,没有JSCall指令,这是sea of nodes图函数调用方式)。
我省去了返回结果值的退出案例,但是如果你好奇的话,可以在自己的Turbolizer图表上查看。图中也没有显示效果边缘,但是那是因为这里没有任何操作可以真正改变程序中任何变量或对象的状态。
本节的主要内容如下:
1.了解如何使用Turbolizer查看sea of nodes graph以获取优化的代码。
2.了解节点之间不同类型的边缘。
3.了解如何input node,这对于下一节中对第二个概念验证的解释尤为重要。
4.大致了解控制流在TurboFan中的工作方式。请注意,当我说控制流时,我并不一定只是指分支节点,还包括如何确定节点执行的顺序。使用“布局图”选项将使Turbolizer大致对图进行排序,以便你可以使用自上而下的方法进行阅读。它通过跟踪每个节点的效果输出边缘来获得排序,但这并不总是100%准确的,因此你必须随时进行查找。当然,我会根据需要在这篇文章中解释所有内容,因此你不必担心尚未完全了解此内容。
第二个POC(第二部分)
综上所述,我们现在就可以最终完成第二个概念验证的其余部分。
对Typer的处理
第二个概念证明的其余部分如下所示:
定义length_of_double的方式只是将实际浮点值存储到V8中的变量中的一种方式。请记住,JavaScript仅具有数字和BigInteger的概念。 Sergey在这里所做的是他将BigInteger值0x2424242400000000存储到BigUint64Array中,然后使用与BigUint64Array相同的缓冲区(后备存储)创建新的Float64Array。然后,他仅通过float数组访问0x2424242400000000值,并将其存储在length_as_double中,稍后你将看到如何使用它。
让我们从trigger函数的第一部分开始,看看它是如何工作的。我们将从sea of nodes graph开始,特别是在Typer阶段。执行./d8 --trace-turbo poc.js,在Turbolizer中打开新生成的turbo-trigger-0.json文件,然后切换到Typer阶段。首先,我将仅显示为该函数创建的节点,直到到达标有[1]的行:
TurbolizerTrigger函数Typer第1阶段(在移动设备上,在新标签页中打开图像)
仔细阅读图像,以查看Typer如何在第二次Math.max调用之前输入所有节点。你会注意到,Typer可以正确执行所有操作,但是这里仍然存在一个尚未解决的大问题。
typer假定传入的数组的长度字段具有Range(0,67108862)的类型,这在图形顶部的LoadField [+12]节点中显示。它是从以下代码中获取的:
这里的问题是我们传入的数组(giant_array)的长度实际上为67108863!如果你牢记此图,将会获得完全不同的图片。下面是概念证明的注释版本,它通过显示typer的想法与类型实际上是什么来演示该问题:
如你所见,最后的Typer确定x只能在range(0,1)的范围内,但是基于我们实际传入的数组,我们知道该范围实际上应该是range(0,7),因为x = 7!因此,我们实质上在这里欺骗了Typer。现在的问题是,我们如何利用这一点?
FOOLED TYPER == OUT OF BOUNDS WRITE
Trigger函数的下一部分如下:
让我们看一下该函数这一部分的turbolizer图:
Turbolizer Trigger Function Typer Phase 2(在移动设备上,在新选项卡中打开图像)
这张图有点复杂难读,因为它的逻辑有点混乱,但希望它能让大家明白要点。关键要点如下:
1.其中的CheckBounds节点很有趣,因为它会进行边界检查。在这种情况下,Range(0,1)只是从NumberMax节点的类型继承而来。 CheckBounds节点本质上采用Input 边缘0的值,并确保其小于Input 边缘1的值。在本文中,“Input 边缘”是指从其上方进入该节点的边缘。Input 边缘0将是最左边缘的边缘,Input 边缘1将是第二个最左边缘的边缘,依此类推。
2.在这种情况下,Input 边缘0是第二个x = Math.max(x,1)调用,该调用返回值7(请记住,此处的Typer不正确)。这里的CheckBounds节点确保x在Range(1024, FixedArray::kMaxLength+1024)的范围内。这样做的原因是因为写入大于length + 1024的索引将需要引擎将corrupting_array的后备存储转换为字典,这将需要对该函数进行优化。
3.关于CheckBounds节点要注意的另一件重要事情是,它们的函数与SpeculativeNumberLessThan节点略有不同。使用SpeculativeNumberLessThan节点,TurboFan会将推测的索引类型(Range(0,1))与限制类型进行比较。但是,对于CheckBounds节点,TurboFan到达该节点后,它将立即获取索引节点的实际值(在本例中为x)并将其与限制类型进行比较。这意味着我们导致Typer将x错误地输入为Range(0,1)的事实没有任何意义。因此我们仍然需要确保其实际值小于或等于length + 1024,否则该函数将失去优化。尽管在概念证明代码中x等于7,这对我们来说很好,但是要记住这一点很重要,因为我们可以让x等于几乎任何值,并且仍然可以使用原始漏洞将其输入为范围(0,1)。
4.通过CheckBounds节点后,将到达MaybeGrowFastElements节点。这个节点本质上是在检查是否需要在访问索引x之前增加corrupting_array。一旦通过该节点,我们最终有了一个StoreElement节点,该节点将length_as_double(这是0x2424242400000000的浮点表示形式)存储到corrupting_array [x]。
不过还有一个问题需要搞明白,此MaybeGrowFastElements节点是什么,它如何工作?我们不希望它在访问索引x之前就增加corrupting_array,因为这样它就不会再越界访问了。
为了查看MaybeGrowFastElements节点的工作方式,我们需要看一下TurboFan的载荷清除优化阶段。在此阶段,如果TurboFan遇到MaybeGrowFastElements节点,则最终会通过调用TypedOptimization :: ReduceMaybeGrowFastElements函数来对其进行优化:
查看之前的Turbolizer图,你会注意到,value input 边缘 2是从前面的CheckBounds节点进入的solid 边缘。此边缘将包含typer根据最终Math.max(x,1)调用确定的范围,即Range(0,1)。它存储在[1]的index变量中。
另一方面,value input 边缘 3是LoadField节点,该节点在[2]处加载corrupting_array后备存储区的长度。在图中将其输入为Range(0,134217725),这与Range(0,FixedArray :: kMaxLength)相同,但后来通过LoadElimination :: ReduceLoadField被优化为具有Range(2,2)类型的NumberConstant。Typer可以执行此操作,因为它知道一个事实,即corrupting_array的长度为2。
经过优化的LoadField节点最终会是适合我们的操作,因为一旦到达[3]处的if语句,index_type.Max()返回1(Range(0,1)),而length_type.Min()返回2 (Range(2,2)),这意味着index_type.Max()<length_type.Min()返回true,然后执行if语句。
执行进入if语句的事实会告诉TurboFan以下内容:我们几乎可以肯定,正在访问的数组(在这种情况下为corrupting_array)将永远不需要根据编写代码的方式来增长。如果索引可以拥有的最大值始终小于长度可以拥有的最小值,为什么我们还要增加数组呢?
本文翻译自:https://www.elttam.com/blog/simple-bugs-with-complex-exploits/#content如若转载,请注明原文地址: