本文首发于先知。
最近在学习V8的compiler pipeline,CVE-2018-17463是一个很好的学习例子,该漏洞是由于V8在编译优化过程中消除检查节点造成的类型混淆错误,最终可造成任意代码执行。
Compiler Pipeline简介
V8的整个compiler pipeline可以用下图表示,一段JS代码首先被转换成AST,然后在Ignition中解析并转换成V8的字节码,当字节码在执行JS函数的过程中,会收集profiling和feedback数据,这些信息会被Turbofan用来优化生成机器码。
Ignition
Ignition是V8引擎的解释器,这个阶段旨在快速启动执行JS代码,通过解释器直接执行字节码,它并不产生机器代码。它具有以下的功能和特点:
- 字节码生成: Ignition将JavaScript源代码解析为字节码,这是一种更加紧凑和机器友好的中间表示。字节码是一组高度优化的指令序列,更易于解释器执行。
- 解释执行: Ignition的主要任务是解释执行字节码。通过直接执行字节码,Ignition能够快速启动JavaScript应用程序,而无需等待完整的编译过程。
- 快速启动和执行: 为了提高启动速度,Ignition专注于快速执行,避免了一些传统解释器执行源代码时的性能瓶颈。这使得V8引擎能够在用户启动网页或执行简单命令时迅速响应。
Sparkplug
在CVE-2018-17463这一版本中并没有这一阶段,Sparkplug是在V8 9.1版本中引入的非优化JS编译器,它位于Ignition和Turbofan之间,用于产生未经优化的机器码。
引入Sparkplug的原因非常简单,因为Ignition的性能达到了一个瓶颈,对于字节码的解释执行永远无法摆脱字节码解码等开销,而Turbofan的优化需要收集足够的运行时反馈信息,否则无法获取稳定的object shape,因此无法更早地进行优化。
Sparkplug具有以下的功能和特点:
- 快速编译: Sparkplug的设计目标之一是实现快速编译。它使用一些巧妙的技巧,如在字节码已经生成的基础上进行编译,避免了一些复杂的工作。
- 直接生成机器码: Sparkplug直接在字节码上执行线性通道生成机器代码。这种方法减少了编译器的优化机会,但使得代码易于移植,而且由于后续流水线中有强大的优化编译器(TurboFan),这并不成问题。
- 解释器兼容栈帧: 引入新编译器到已有的JS虚拟机中是一项艰巨的任务。为了解决这个问题,Sparkplug保持了“解释器兼容栈帧”,与Ignition解释器的栈帧结构基本一致,使得在整个系统中的集成变得简单。
Turbofan
Turbofan是V8引擎的最终优化编译器,负责将IR转换为高度优化的机器代码。在这个阶段,V8会根据程序的执行情况应用更复杂的优化策略,包括内联缓存、类型推断、循环优化等。Turbofan生成的机器代码是高度优化的,以提供最佳的性能。但是,由于优化的程度很高,在这一阶段很容易产生错误。CVE-2018-17463就是由于在优化过程中操作的side effect判断不正确,造成了类型混淆错误。
漏洞信息
查看# Issue 888923,我们可以看到该问题的初始修复补丁是通过提交52a9e67a477bdb67ca893c25c145ef5191976220
推送的,提交信息为“[turbofan] Fix ObjectCreate’s side effect annotation.”。有了这个信息,可以使用git show
命令来查看该commit修复了什么。
1 | commit 52a9e67a477bdb67ca893c25c145ef5191976220 |
可以大致了解这一漏洞为何产生,如何触发,这个放在后面去分析。首先需要将代码切换到未修复这一bug的版本,使用git log 52a9e67a477bdb67ca893c25c145ef5191976220
查看commit信息。
1 | commit 52a9e67a477bdb67ca893c25c145ef5191976220 |
可以看到包含这一漏洞的最后一个commit是568979f4d891bafec875fab20f608ff9392f4f29
,使用git checkout
命令切换到这一commit。
漏洞分析
通过仔细分析漏洞的patch,可以发现漏洞存在于src/compiler/js-operator.cc
中。在这里,代码定义了许多标识,实际上只进行了一处修改,就是将CreateObject
的标志从kNoWrite
变成kNoProperties
。
1 | - V(CreateObject, Operator::kNoWrite, 1, 1) \ |
这些标志位的定义在src
中,是一个枚举类。
1 | enum Property { |
可以看出kNoWrite
表示一个操作并不产生任何side effects,很容易猜到,CreateObject
应当是产生了一定的side effect。通过调试可以发现在Map::CopyNormalized
函数中,使用set_is_dictionary_map(true)
将新生成的map设定为dictionary
模式。修改commit中给出的poc做一个调试和验证。
1 | function f(o) { |
在执行f前后输出obj信息,执行输出结果如下。
1 | $ ./d8 --allow-natives-syntax poc.js |
可以看到,在执行之前,map的类型为FastProperties
,但是在执行之后map的类型是DictionaryProperties
。如果f
经过Turbofan的优化,只保留了第一次的CheckMaps
,则会导致后一次程序使用fast mode去寻找dictionary mode中的元素,产生错误。再次修改poc,并观察Turbofan的优化过程。
1 | function vuln(obj) { |
首先查看初始的IR图,在每一次LoadField
之前都进行了CheckMaps
操作。
由于CreateObject
被定义为不写入副作用链,因此消除了冗余,CheckMaps
节点应该不再存在。正如下图所示的simplified lowering阶段,在调用JSCreateObject
之后的CheckMaps
节点已被删除,直接调用LoadField
节点。
不过在利用时,需要大量循环来调用这一函数来触发Turbofan的优化。
漏洞利用
现在我们有了一个可以工作的Type Confusion漏洞,该漏洞会导致V8将Dictionary当作数组访问,从而允许我们操纵对象内存布局中的函数指针和数据,但是利用的过程十分繁琐。
经过前面的分析,我们有办法可以让函数中多余的CheckMaps
节点消失,并且能够通过CreateObject
改变Properties
,则很容易可以构造一种非预期情况。首先构造一个obj
,初始化时赋予属性a
,然后增加属性b
,函数中首先访问a
,通过类型检查,然后读取x.b
,由于没有类型检查,此时返回一个与原b
属性偏移相同的数据,但由于Properties
发生变化,返回的数据不会再是b
属性的值。
想要稳定利用这一漏洞,一个很大的问题在于,Dictionary内部的内存布局是随机的,不过在V8中有一规律,相同属性的obj
,在Dictionary
中各属性的偏移也相同。根据这一规律,我们可以使用同样的构造方式来构造具有相同属性的不同obj
,来满足不同的需求。
Property Overlapping
根据上面的分析,利用这一漏洞时,访问属性b
时会访问到一个其他的元素。我们可以利用这种property overlapping,实现类型混淆。如属性b
是一个Number
类型,而另一个属性是一个Object
类型,访问属性b
会以Number
的方式读出Object
的地址,造成地址泄露。如果想要控制泄露的Object
,就需要知道另一个属性是什么。
下面是一种比较简单常用的方法,按照一定的规律进行赋值,如属性bi
的值定义为-i
,然后依次读取,如果读出的值与原值不同,则可以根据读出的值找到最终读出的是哪一个属性的值。
1 | function getObj(values) { |
很容易可以找到我们需要的两个属性。
1 | [+] Finding Overlapping Properties... |
Addr Of
Addrof
是一个在V8利用中很常见的原语,用来泄露一个obj
的地址。当我们有类型混淆漏洞时,这个原语的实现就十分简单,在前面也介绍过。我们将上一步中得到的第一个属性记为p1
,第二个属性记为p2
,由于对p1
的访问实际上是对p2
内容的访问,设置属性p1
为Number
,属性p2
为想要泄露地址的Object obj
,通过读取p1
就可以读取出obj
的地址。
1 | function addrof(obj) { |
上面的代码中,vuln
函数访问了p1
的x1
,在产生优化之后,会访问到p2
的y
也就是obj
,从而以浮点数返回obj
的地址。
Arbitrary Write
同样,我们可以构造一个Object
,如下面的o
,其中的各个属性均为Number
。
1 | let o = { x1: 1.1, x2: 1.2 }; |
我们对p1
中的x1
和x2
进行写入时,实际上会写入obj
中的对应位置。这里有一个非常常用的结构ArrayBuffer
,其中的backing_store
字段存放了一个内存地址,该地址是实际读写时的地址。如果能够修改该地址为任意地址,则可以利用这一ArrayBuffer
达到任意地址读写的效果。
通过调试很容易发现,backing_store
和x2
的偏移相同,因此对x2
进行修改能够达到修改backing_store
的作用,进而可以控制ArrayBuffer
任意地址读写。
1 | function fakeObj(obj, addr) { |
Get Shell
Get shell的方法比较常规,通过wasm
分配一块可读写可执行的内存,然后通过间接寻址找到这一内存,在这一版本中wasmInstance
偏移0xf0
处即为该地址。通过任意地址读写讲shellcode写入,通过调用wasm中的函数执行写入的shellcode。
1 | var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]); |
exp
完整的exp如下,前面的板子来自这里。
1 | function gc() { |
exp运行结果如下图,因为有大量循环,所以运行稍慢。
参考
http://p4nda.top/2019/06/11/%C2%96CVE-2018-17463/
https://v8.dev/blog/sparkplug
https://jhalon.github.io/chrome-browser-exploitation-3/
Comments