前言:
分析了一下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两个文件下手,去找一找有没有相类似的所遗漏“猜”的地方,利用遗漏的“猜”找到突破点。