作者: Qixun Zhao(@S0rryMybad) of Qihoo 360 Vulcan Team
博客:https://blogs.projectmoon.pw/2019/01/13/Story1-Mom-What-Is-Zero-Multiplied-By-Infinity/
今天我们文章介绍的是CVE-2018-8391,对应的patch commit. 这是一个关于Loop循环的越界读写漏洞,漏洞的成因十分有趣.我们都知道零乘以无限等于零,但是开发人员在写代码的时候忽略了这样的一种特殊情况.
在这里我除了介绍漏洞本身以外,还介绍了在引入了Spectre Mitigation之后的一种通用的Array OOB RW利用方法.关于这个漏洞,我们还有后续的Story2.
实验环境: chakraCore-2018-8-15附近的commit
在之前的文章中我们已经简单介绍过关于Loop的优化,在编译器的优化过程中,我们需要把很多在Loop中不需要变化的指令hoist到LandingPad中,不然每次循环会执行很多没必要的指令.而在针对数组的边界检查中,有一种特殊的优化处理方法,这种优化是针对在循环inductionVariable
并且用inductionVariable
进行数组访问的情况.inductionVariable
就是循环中的自变量.举个例子最直接:
这里i就是inductionVariable
,并且arr2用i进行数组访问.优化的详情在doLowerBoundCheck
和doUpperBoundCheck
这两个函数中.这里用doUpperBoundCheck
作为例子.
我们可以看到最下面有一个|CreateBoundsCheckInstr|
的函数,用于生成一个boundcheck指令,用于检查|indexSym <= headSegmentLength + offset (src1 <= src2 + dst)|
(注释已经很清楚).只需要通过这个检查,在下面的循环中就不会再有任何边界检查,因为已经hoist到LandingPad中,问题的关键就出在这个边界检查中.所以关键是这个检查是怎么保证在循环中数组的访问一定不会发生越界呢?
HeadSegmentLength很清楚就是数组的长度,问题就在于这个indexSym是怎么得来的,通过阅览代码我们可以发现是在上面的函数|GenerateSecondaryInductionVariableBound|
(生成的hoistInfo.IndexSym
最终用于初始化lowerBound这个Opnd).
这个函数根据字面意思已经很清楚,就是计算inductionVariable
的取值范围,只有inductionVariable
的最大值少于HeadSegmentLength
,循环中的数组访问必定不会越界.
至于计算的方法其实代码的注释已经十分清楚,下面我截取代码的注释来解释:
inductionVariable
就是我们i的初始化值,也就是我们上图的start,而loopCountMinusOne
的计算方法在GenerateLoopCount
函数中:
在这里我用我小学毕业的数学知识把这个公式变换一下,得到如下的等式,当然要注意运算符号的顺序: (left - right + offset) / minMagnitudeChange * maxMagnitudeChange + inductionVariable
这里简单结合js代码介绍一下各个变量的含义:
函数中:
Left对应的是end变量,right对应的是start,至于offset我们不用太在乎,如果判断条件是|i<end|
,则offset是-1,如果是|i<=end|
,则offset是0,对我们影响都不大.minMagnitudeChange
是自变量在每一次循环中可能增加的最小值,这里是1(也就是if条件不成立的时候),同理maxMagnitudeChange
是可能增加的最大值,这里是0x1001,也就是if条件成立的时候,inductionVariable
我们上面已经提到,也就是start,最终得到的公式与Opcode如下:
结合我们文章的题目,聪明的读者肯定已经想到问题出在哪里.
上述的公式在计算i的取值范围的时候已经十分保守了,因为没可能每一次循环i都是增加最大值,但是它忽略了一种特殊情况:zero.当(end- start - 1) / 1等于0的时候,无论它后面乘以多大的数,结果都是0,最后边界检查就是只需要start < headSegmentLength即可,而这个边界检查是不安全的(试想maxMagnitudeChange 远远大于headSegmentLength).
有了越界读写的能力,下一步就是如何利用了,chakraCore在这个commit中加入了一个mitigation, 这个commit简单来说在每一次数组访问的时候都会再次检查index是否少于数组的长度,如果不少于就直接crash,本来是用于防御Spectre,但是也把这些越界读写漏洞堵住了.换句话说,即使bypass了boundcheck,还要这些mask指令需要bypass.在刚引入的时候,很多人都觉得这种越界读写的漏洞不能再利用了.
这些指令的引入是十分拖累速度的,千辛万苦才消去了boundcheck的检查,又引入这个措施等于boundcheck的消去毫无意义,特别是在Loop中,每一次的循环都要运行这些没必要的mask指令,因此微软很快就引入了一个优化措施,在某些情况下hoist这些mask指令到循环外.由于这个优化措施比较复杂,这里只能简单介绍一下,它存在于Backward阶段的processBlock中,相关代码如下:
首先遍历所有的opnd,查看这个opnd的有没有type-specialized,这里我们可以理解成有没有针对特定类型的优化,例如Float64等等,如果没有则记录下这个Sym的id,记录下的id最终在这里进行判断:
如果这里满足两个条件,如果是LdElemI_A指令并且之前没有把Opnd的Sym记录下来,则把这个指令SetIsSafeToSpeculate(true);
意思是不需要添加mask指令,最终在一个air block中加入防御指令:
这个指令是架构相关的,不同架构有不同实现,这里与我们讨论的无关,不再展开.
换句话说,第一数组的访问必须在loop里面,触发它的loop优化机制,第二我们只能进行数组的load并且数组是int32类型或者float64类型,则我们可以把mask指令hoist到loop外.但是单单有这样的越界读(除非再多一个object数组的越界读)是不够的,我们需要更多的东西去RCE.
有了越界读,我们是可以越界读取一个missingValue的值的,只要我们首先初始化一个数组,然后把这个数组的length重新设置,例如:
则在它的index 4的地方有一个missingValue,同时也满足了HasNoMissingValue为true,如果不满足在后续我们JIT取出该值的时候是要bailout的,内存区域如下:
这时候如果我们能off by one index,我们就能读取到这个missingValue,然后我们可以用这个missingValue创建一个evil Array:HasNoMissingValue为true,但是headSegment中带有missingValue,最终创建evil Array的PoC如下:
有了这样的数组,离RCE还远吗,网上已经有大量的利用例子.可以参考我们的第一篇文章或者project-zero 或者 From zero to zero day
剩下的就作为读者的练习吧.
零乘以无限等于零