前言:
分析了一下Math.expm1(-0)
的OOB
的洞,学到了很多,并且也意识到即使一个看起来很小很小,小到可能觉得只是个功能特性问题,并不是一个bug
的漏洞,也能够通过一些极其巧妙的方法来达到一个意想不到的漏洞利用。
正文:
相关issue
在这里:
- https://bugs.chromium.org/p/project-zero/issues/detail?id=1710
- https://bugs.chromium.org/p/chromium/issues/detail?id=880207
关键的在这里:
1 | function foo() { |
可能乍一看,也就是一个特性问题,正不正确的其实也没多大关系..漏洞发现者开始也是这么觉得的..但是后来他才发现这个漏洞是完全可利用的RCE
。该漏洞修复了两次,第一次官方只patch
了一个文件operation-typer.cc
,后面又patch
了typer.cc
文件。patch
记录可以参考如下:
- https://chromium.googlesource.com/v8/v8.git/+/76df2c50d0e37ab0c42d0d05a637afe999fffc49
- https://chromium.googlesource.com/v8/v8.git/+/56f7dda67fdc9777719f71225494033f03aecc96
这里就拿35C3
上的题来说,作者拿了他发现的这个洞去出了题,出的是只打了operation-typer.cc
没有打typer.cc
的题。现在我们直接来分析一下,先看看两个patch
:
operation-typer.cc
:
1 | Type OperationTyper::NumberExpm1(Type type) { |
typer.cc
:
1 | @@ -1433,7 +1433,6 @@ |
需要说明的是这时候的CheckBounds
检查还是可以消除的。
从patch
来看修改了MathExpm1
的type
类型,本来是PlainNumber加NaN
类型的,现在修改成了Number
类型。PlainNumber
类型表示除-0之外的任何浮点数,但是这是在TurboFan
当中的,实际不优化过程是被当作浮点数的,浮点数是包括-0
的。所以这就产生了错误。
当我们直接运行Poc
的话,仍然会得到一样的结果,我们先看看IR显示结果:
1 | function test(x){ |
这里直接显示了Number
类型,原因是他打了operation-typer.cc
的补丁,导致TurboFan
猜测类型结果为Number
类型,所以导致后面可真也可假,不会触发bug
。那我们要怎么去利用typer.cc
没有打上的patch
呢?我们首先得知道typer.cc
上JSCallTyper
函数是拿来用在内置函数优化上的,而不是NumberExpm1
上的,OperationTyper::NumberExpm1
是用在普通优化Math.expm1
函数上的。所以我们需要利用内置函数上的bug
去触发,那么我们该怎么去触发Math.expm1
出现在内置函数的优化上呢,这就需要去优化了:
该函数Math.expm1
是数字输入的优化结点,也就是说TurboFan
推测该函数输入将会是一个数字。如果运行的确实是一个数字的话,那么它就继续执行代码,但是如果不是一个数字,优化函数将会把不是一个数字的结果反馈给解释器,那么会执行一个“去优化”,此时解释器将会使用内置函数,他可以接受所有的类型。下次编译之后,TurboFan
会有反馈信息通知他输入的并不总是数字,从而会产生内置函数的调用,而不是NumberExpm1
的调用。
修改一下代码如下:
1 | function test(x){ |
此时再看IR会发现有两个文件,其中一个是正常NumberExpm1
优化,另一个就是内置函数的优化了,得到了一个Call
结点:
加上--trace-deopt
来查看一下去优化的信息:
1 | [deoptimizing (DEOPT eager): begin 0x1bddfbb9df21 <JSFunction test (sfi = 0x1bddfbb9dc71)> (opt #0) @0, FP to SP delta: 24, caller sp: 0x7ffe301a06c0] |
可以看见一些not a Number or Oddball
的信息,说明跟编译器推测的Number
类型不一样,从而发生了去优化,此时编译器在结点处猜测的类型为PlainNumber|NaN
,已经达到了我们所期望的结果了。
整个过程其实就是编译器先运行假设输入为Number
类型,当类型反馈告诉编译器此时的输入是一个字符串时,TurboFan
此时就会去优化,第二次编译该函数时,会调用输入可以为任何类型的内置函数来进行优化。达到期望效果。
总体来说,TurboFan
是根据类型反馈FeedBack
来工作的,还有一个点是“预测”。就是反馈和预测相结合来工作的。
接下来要考虑的就是该如何去触发OOB
的访问了。
先测试如下代码:
1 | function test(x){ |
直接看simplified lowering
阶段的IR
:
可以发现这里被折叠为直接取了数组的第零位。往前看看被折叠的最初始位置。
最开始可以在typer
阶段就可以看见,typer
阶段的SameValue
结点就已经折叠为false
了,后面自然就直接取index
为0了。具体可以看operation-typer.cc
的代码:
1 | Type OperationTyper::SameValue(Type lhs, Type rhs) { |
所以我们需要改变一下代码形式,使得SameValue
在该阶段不被折叠,也就是不被“发现就可以了”。
根据代码,我们有两种方式,第一种为使得左分支可能为-0
,第二种为使得右分支不为-0
。因为第一种是固定不能变的,所以我们只能从第二种方式下手,我们得把-0
右分支替换掉。
先试试这样的:
1 | function test(x,y){ |
这时候虽然SameValue
结点会保留下来,但是到了simplified lowering
阶段的时候无法消除CheckBounds
,这样最终也是无法利用的,后面可以发现y
作为第二个参数Parameter[2]
结点为NotInternal
类型,该类型表示编译器不知道y
的类型,也就是说,y
也可以是-0
,那么SameValue
可真也可假,导致最后CheckBounds
结点无法消除。
这点得去好好研究一下TurboFan
的pipeline
运行机制。此处引用一个作者的图来表示pipeline
管道优化的大概流程:
typed-optimizitaion阶段会简化SameValue
结点,可以简化为ObjectIsMinusZero
结点,simpified-lowering阶段会简化ObjectIsMinusZero
结点,会直接将他折叠为false
常量。
又上面可知我们不希望在typer
阶段就被折叠为false
,也不希望CheckBounds
无法消除,那我们就需要将SameValue
结点保留到simpified-lowering
阶段,让这个阶段知道在和-0
比较,从而折叠为false
消除CheckBounds
结点。
也就是需要绕过typer-lowering
阶段稳定到simplified-lowering
阶段。
这时候我们可以用一下逃逸分析(escape-analysis
),代码改为如下:
1 | function test(x){ |
逃逸分析阶段的作用就是简化非逃逸对象,什么叫非逃逸对象呢。
1 | function test(){ |
此时a
就叫非逃逸对象,因为他的x
属性值是固定不可变的,也就是说可以将a.x
直接折叠为1
。
1 | function escape(x){ |
此时a
是逃逸对象,也就是说逃脱了test
的范围,因此就无法优化折叠了。
此时我们用以上更改过的代码跑之后就可以得到结果:
1 | 2.2741325538412e-310 |
显然我们已经成功OOB
了。还不够,此时我们再来看看IR
图。
typer
阶段:
显然已经不会被直接折叠为false
。
typed-lowering
阶段:
没有被简化为ObjectIsMinusZero
结点。
escape-analysis
阶段:
此时SameValue
右结点被折叠为-0
。
simpified-lowering
阶段:
此时checkbounds
结点被消除了。
所以SameValue
结点一直存活到了最后一个简化阶段。
这题目其实也可以先考虑“逃逸分析”后考虑“去优化”,也会发现一些有趣的东西,比如在十万次循环中写上的是"-0"
,那么还会多出一个NumberLessThan
结点等等,这就自行分析了不多说。分析到最后还是可以发现一些TurboFan
很奇怪的地方的。
总结:
发现TurboFan
最大的一个特点也是最重要的一个特点就是它的“惰性思维”,也就是说不断输入某个特定情况时,那么TurboFan
会以为以后的情况也是该种情况,从而优化代码也是按照该种情况来生成,这样就会产生许多问题,比如说这种情况:优化过程中预测所出现的情况并没有把所有可能的情况都包含进去(或者说过于限制,把原本大范围的限制的更小了),导致一些没有包含进去的情况在预测情况生成的优化代码中运行,出现不可知的错误。那么我们就可以在TurboFan
中有目的的寻找这种错误,从部分开始下手审计。
问题:
这里其实有几个问题我是不太明白的。
首先是本身已经是Call
结点了,但是后面调用一次test(-0)
的时候结果为True
,也就是说Call
结点产生了-0
,但是Call
结点是PlainNumber|NaN
并没有-0
啊,是怎么产生的?
答:Call
结点处的PlainNumber|NaN
类型只是TurboFan
的“预测”,并不是说就是只含有这两种类型,他只是“预测”而已,相当于它只是觉得Call
结点只能产生PlainNumber|NaN
而已,并不是说实际上就是只能产生这样的type
,相当于是实际的子集,也就是“错误的预测”,用错误的预测生成了错误的优化代码。
其次是CheckBounds
是如何消除的?图中已经表明了CheckBounds
左分支为Range(0,4)
,那么4
不应该是已经超出Array MaxLength
了吗,为什么还能被消除呢?
这里不理解。
后面上手调,才发现几个很坑的点,一开始是下不了断点的,只有在程序在gdb
里面跑起来的时候才能用文件地址加行数下断点,这里巨坑。后面在checkbounds
下断点,共断下来四次左右,前两次是满足不了条件无法消除CheckBounds
的,且没满足lowering
这个条件就退出了,但是后面进了,且也是成功执行了DeferReplacement
。但是我这里没法看Range()
,我也不知道为啥,头痛。调不来。所以暂且就猜测他是在最后的时候反馈了Range(0,0)
结点才消除CheckBounds
的吧。
最后是SameValue
处右结点已经折叠为-0
了,那么之后反馈过程中一直为false
,为什么不折叠为index为0
呢直接取第一个元素呢,还是要用index offset
去取element
呢?
这个我也不理解。
我猜测是TurboFan
开始在simplified-lowering
阶段中得到的反馈是Range(0,4)
的类型导致无法消除CheckBounds
,自然也不可能直接折叠为false
,而在后面阶段的继续执行的时候,得到了一直为false
的反馈,导致消除了CheckBounds
,但是又因为之前得到过true
的情况,导致了编译器“惰性”的认为有不同的情况,所以采用了取element
的形式而不是直接折叠。
v8
代码好多也太难调了,给个活路吧..到时候都要变成预言家了..开局一电脑,全局全靠猜。
总结:
这个洞给我提供了一个思路。
就是说既然v8
全靠猜和自我认知,那么就往它猜错的方向找,自然而然不就可以找到漏洞吗?
也就是说,我只要找到他没有猜到的地方,就可以找到洞。
相似的,我们可从上面得到的typer.cc
以及operation-typer.cc
两个文件下手,去找一找有没有相类似的所遗漏“猜”的地方,利用遗漏的“猜”找到突破点。