作者:Qixun Zhao(aka @S0rryMybad && 大宝) of Qihoo 360 Vulcan Team
作者博客:https://blogs.projectmoon.pw/2018/08/17/Edge-InlineArrayPush-Remote-Code-Execution/
回顾之前Edge出现的漏洞,在root cause属于回调的问题当中(不一定有用到回调的root cause就是回调的问题),虽然出现的漏洞有很多个,但是无非可以分为三种情况:
第一种,GlobOpt阶段的bailoutKind没有加入或者处理不当,对应的一个例子: CVE-2017-11837
第二种,Lower阶段新加入的指令没有update GolbOpt阶段的bailoutkind,对应的一个例子: CVE-2017-8601
最后一种是进行回调的时候没有update implicitCallFlags,导致GlobOpt和Lower阶段的工作全部白费:CVE-2017-11895
前段时间一直在学习与研究一些新的方向iOS的越狱(以后有机会也会写一系列关于越狱的文章),没有怎么关注JIT的代码.mosec之后回来看一看JIT相关的代码,发现漏洞可能没以前好找了,但是也不是没有.在最近我报告了一系列的Edge 漏洞给微软,在此后的一段时间我将会陆续分享这一系列的关于JIT的与以往不太一样的漏洞, 这些漏洞品相都是相当好, 并且最后能RCE的.
作为我一系列Edge JIT 漏洞的第一篇,这次我选择的是原理最简单的一个洞作为分享(属于单个opcode的问题),当然也因为漏洞修补的时间刚好.在前几天的微软补丁中,修复了我两个Edge的漏洞,其中这篇就是CVE-2018-8372,另外一个并没有assign CVE,但是在代码中已经修复,在以后的文章中会提到.
这一系列的文章需要读者对js或者浏览器漏洞有一定的研究基础,因为我们只会关注于JIT本身,而不会过多关注js和浏览器的一些基础概念.
试想如下一段JS代码JIT会怎么处理:
Function jit(){ arr[1] = 1.1; arr[2] = 2.2; }
我们都知道js是一门动态语言,所以一开始肯定是先检查arr的类型,然后再进行赋值,但是由于opcode的原子性,在第二行语句的时候假如没有优化,肯定也会再次检查一次arr的类型.但是聪明的读者肯定也会发现,第二行的检查是没有必要的,刚刚才检查过,为啥又要检查一次啊,cpu闲的蛋疼啊,而且Edge还想你设置成默认浏览器呢,还要比V8快呢,怎么能这么多冗余的指令呢?于是这时候chakraCore就要引入JIT的其中一个优化措施,消除冗余的检查.下面我们看看最终经过GlobOpt阶段的IR是怎样的:
我们可以清楚看到,第二句JS代码没有|BailOnNotArray|
,也就是没有了类型的检查.
但是也不是什么时候也能消除检查的,在中间存在回调的时候就不能消除检查:
Arr[1] = 1.1; Object.property; => callback Arr[1] = 2.2;
这里明显中间有一个回调,所以我们是需要把chakra中已经保存的type信息去掉,这时候chackaCore就引入了一个kill机制,其中一个相关的处理代码是在|GlobOpt::CheckJsArrayKills|
.在审阅这个函数的时候,InlineArrayPush opcode
引起了我的注意:
代码注释已经说得很清楚,假如array的type与element type一致,就不要把type信息去掉,这是一个比较激进的优化,而InlineArrayPush opcode
通过调用Array.prototype.push
生成的.简单来说就是,假如生成这么一段代码:
arr[1] = 2.2; arr.push(value); arr[1] = 3.3;
假如arr的type信息是float array,value的type信息是float,前面保存的arr type信息就不会被kill.换言之,在|arr[1] = 3.3;|
中就不会生成|baitOnNotArray| IR
,没有了type类型的检查代码.是的,这样就非常快了,比v8还快,但是安全吗?需要注意的是,在push里面不能触发回调,因为InlineArrayPush会生成|BailOutOnImplicitCallsPreOp|
,如果触发回调是会bailout的.
接下来要思考的问题就非常直观了,将一个float数值push到一个float array里面,在不触发回调的前提下,真的不会改变array的类型吗???
在JS里面,有一个特殊的值undefined,表示这个变量未初始化.试想这样一个数组|arr = [1.1,,3.3];|
,不考虑prototype的情况下,当我们访问arr[1]的时候就会返回一个值undefined.这里就有一个疑问了,这个undefined的值在内存里究竟这么表示,所以我们先看一下这个arr的内存表示:
可以看到,arr[1]在内存的值是0x8000000280000002
,这时候敏锐的读者可能就会想到了,这个值是在浮点数的表示范围内啊(详情查看IEEE 754),通过转换,我们可以知道这个值对应浮点数-5.3049894784e-314
.所以为了区分-5.3049894784e-314
和undefined,chakraCore在float Array的|setItem|有一个特殊的处理:
当把这个值传入setItem,就会进行数组的转换,变成var array,而在push函数里面,是通过调用setItem函数处理的.所以回到最初的问题:|将一个float数值push到一个float array里面,在不触发回调的前提下,真的不会改变array的类型吗???|.答案是否定的.
但是,通过如下pattern,我发现我的array type信息还是被kill了,即使我保证了arr的type信息是float array,value的type信息是一个float:
Arr[1] = 1.1; Arr.push(value); Arr[1] = 3.3;
在push下面的arr赋值还是生成了bailOnNotArray.通过研究发现,在上面还有一个语句引起了我的注意,push语句会把missingValues相关的type信息删掉:
我们来看看这个MissingValues 的kill是怎么处理的:
原来如果chakraCore觉得arr的type信息中没有MissingValues,在经过push后,还是会把arr的type信息删去,当然这个十分容易bypass,只需要令|valueInfo->HasNoMissingValues|
返回false,就会进入continue语句.换句话说,就是需要我们传入的arr中带有MissingValues.所以最后的PoC也就呼之欲出:
在这一系列的文章当中,我们对于利用的讲解步骤都只是会讲解到达成两个漏洞利用原语,第一是任意对象地址泄露,第二是任意地址对象fake.当有了这两个原语以后到最后的RCE网络上已经有大量公开的参考资料,大家可以自行参考查阅.
在PoC中,我们已经达到了第二种的漏洞利用原语,但是对于第一种的利用还是需要点技巧.因为在这个bug中,我们不能触发回调,而且只能插入一个固定的double float -5.3049894784e-314
,所以很难泄露任意一个对象的地址.想挑战的读者可以先行尝试一些怎么利用,或者可以直接继续阅读查看怎么编写代码.
试想一下,在经过push语句后,|arr|对象的类型已经变成var array,换言之就是arr现在可以通过任何关于var array的检查.所以现在|arr|可以用于var array的赋值,而这个赋值是可以任意的一个对象.而我们在JIT的profile阶段必须要给对应的Symbol transfer一个var array.数组的赋值对应的字节码是StElem_A,这里唯一需要注意的就是不要触发这个Opcode
的任何bailout.下面查看|oarr[2] = leak_object|
生成的对应IR信息:
可以看到,只需要array的type信息符合(oarr必须是var array,上文已经提到,|arr|通过push已经转换成var array),MissingValue信息符合,index不大于数组的长度,即可当成一个正常的var array赋值.通过赋值以后,现在arr[2]上已经有一个对象的地址,通过return arr[2]即可得到该对象的地址.但是这个return的语句对应的native code的|RegOpnd|是float类型,而不是var,所以会直接把对象地址以浮点数的方式返回给我们,从而泄露该对象的地址,因为|arr|数组现在JIT的profile信息中还是一个double array.
这部分可能有点难以理解,下面我们结合PoC和注释进一步理解:
在每次JIT开始之前,都会经历一个profile阶段,用于收集对象的类型信息用于JIT时候生成相关的类型检查与访问代码.在Profile阶段,我传入了一个NoMissingValues为false的float array,所以|arr[0]|和|arr[2]|的读写都是以float形式访问,换句话说,如果arr数组中存在对象的地址则可以通过|return arr[2]|成功读取出来.但是必须在第一句|arr[0]|通过类型检查,也就是arr一开始必须为float array类型.
其次,我传入了一个var array类型的数组|oarr|,所以|oarr[2] = leak_object|
会把需要泄露的对象地址赋值到oarr[2]中,但是必须通过类型检查,也就是oarr在访问的时候必须为var array类型.
在漏洞触发的最后一次调用中,|arr|和|oarr|其实是同一个数组,在|arr[0] = 1.1|中,此时arr是float array,通过检查,赋值成功.通过|arr.push(value)|
触发漏洞,改变数组类型,变成var array类型.在第三行代码|oarr[2] = leak_object|
,因为|arr|和|oarr|是同一个数组,所以oarr当前为var array类型,通过检查,赋值成功.
最后一句是最关键的代码,我们可以看到,|arr[0] = 1.1|
和|return arr[2]|
中有两行代码,这两行代码必须不能kill |arr|的type信息,否则就会重新类型检查,因为arr已经转变成var array类型了,如果此时有类型检查就会检查失败然后bailout.上文已经详细分析了如果arr NoMissingValues为false,|arr.push(value)|
是不会kill arr的type信息的.所以现在剩下|oarr[2] = leak_object|
这句,对应的opcode是StElemI_A,|CheckJsArrayKills|
代码如下:
我们可以看到,并没有任何情况会kill array的type信息.所以到最后没有任何类型检查,直接以浮点数的方式访问已经变成var array类型的arr,返回刚刚赋值的对象|leak_object|,将浮点数转换为16进制,即可得到对象的地址.得到这两个原语以后,距离RCE就不远了.
在这个bug中,我们可以看到,不需要触发任何的回调,最后我们成功利用了这个bug达到任意对象地址泄露和任意地址对象fake.关于这个bug的利用我个人觉得还是有点技巧的,而这个bug的根本原因就是开发人员忘记了push中的一些特殊情况而导致的过激优化,我们需要时刻记着,在保持性能优化的同时,也要注重安全.