chrome V8 issue-1076708 暨 CVE-2020-6468 漏洞分析
前言
秋招结束了,5月开始学习浏览器,这是实习期分析的第一个issue,因为之后已经有公开的分析报告出来了,所以这里放出来(不过好像不会有面试官再看我的博客了:)),很大概率也是我的最后一个博客,感谢p4nda、P1umer、Keenan
对我的帮助。
四月到现在经历了很多事,感谢学长们在实习和秋招时对我的指导,感谢老曹和柯南不厌其烦教我浏览器,感谢卓饶对我论文的帮助,特奖答辩时陪我改ppt和稿子。感谢一直陪在我身边的人和只能陪我走到这里的人,阿征会努力努力再努力。
前置知识
JIT(Just-in-time)编译器
交互式解释器易于理解和实现并且反馈及时,但由于其无法跨程序各部分进行全局的优化,这种解释器的执行速度很慢。编译器在代码运行前编译源代码,对于复杂的代码编译器会做局部优化和全局优化,这使得编译的耗时较长而执行的速度较快。JIT编译器结合了二者的优点,分为baseline编译器(Ignition)和优化编译器(TurboFan),前者尽可能复用hot code
编译后的结果,后者使用解释器收集的信息进行假设,并根据假设优化,如果假设与实际不符则会进行解优化,丢弃掉优化的代码。
可以参考下面两篇文章了解JIT的设计思想和优化编译器的IR优化设计。
JIT相关CTF题目及漏洞
Linux Kernel ebpf组件中JIT权限提升漏洞 CVE-2020-8835、CVE-2020-27194错误计算了寄存器边界追踪导致可以绕过验证器检查以实现越界写。
*CTF-2021
中Favourite Architecture 2
提供了一种qemu逃逸的思路。qemu-user模拟程序运行时,目标程序和qemu-user进程共享宿主机的进程内存空间,执行目标程序时qemu-user加载目标程序的内存数据模拟执行。因而目标程序可以读写qemu-user进程的数据,由于虚拟化的限制qemu-user的内存空间的地址对目标程序不可见,但仍可以通过构造/./proc/self/maps
绕过qemu-user对于绝对路径的检查,最终获取到JIT的rwx page地址并写入shellcode实现qemu逃逸。
JS异步函数
Js同步函数中的代码是顺序执行的,这意味着在某行代码阻塞时后面的操作只能暂停,以下面的js代码为例,我们只有在fetch到图片之后才能将图片展示出来,如果异步执行这两行代码则可能报错因为fetch尚未完成。
1 | var response = fetch('myImage.png'); |
为了解决这个问题,js将语言的执行模式分为两种:同步和异步。同步模式里后一个任务总是等待上一个任务执行完毕后再执行,任务的执行顺序和排列顺序一致且同步的。异步模式下,每个任务有一个或者多个回调函数,前一个任务结束后,不是执行下一个任务而是执行回调函数,后一个任务不等前一个任务结束就开始执行,程序的执行顺序和任务的排列顺序时不一致且异步的。下面我们介绍两种异步编程的方式:callback和Promises对象。
假设我们有三个函数f1、f2和f3,其中f1、f2需要顺序执行,f3不依赖于f1、f2执行,如果f1是一个比较耗费资源的函数,我们可以将f2实现为f1的callback,如下代码所示,f1在执行完自己的代码块后会紧接着执行callback,与此同时f3并不等待f1执行结束就开始了代码执行,这样就实现了异步编程,将耗费时间和资源的f1操作异步执行。
1 | function f1(callback){ |
Promises对象是CommonJS工作组提出的一种规范,目的是为异步编程提供统一接口。它的思想是每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。比如f1的回调函数f2可以写成f1().then(f2);
。观察下面的示例,可以看到有两个then block,每个block都包含一个回调函数,如果前一个操作成功,该函数将运行,并且每个回调都接收前一个成功操作的结果作为输入,每个.then()块返回另一个promise,这意味着可以将多个.then()块链接到另一个块上,这样就可以依次执行多个异步操作。如果其中任何一个then()块失败,则在末尾运行catch()块——与同步try…catch类似,catch()提供了一个错误对象,可用来报告发生的错误类型。
1 | fetch('products.json').then(function(response) { |
async/await
关键字是为了简化异步编程而引入的,简单来说它们是基于Promises的语法糖,使异步代码更方便编写和阅读。以下面的代码为例,asyncCall为异步函数,函数内部执行第一个console.log后输出calling
字符串,同时函数调用处的下一行console.log也异步执行输出test
字符串,由于调用resolveAfter2Seconds
函数时在前面添加了await关键字,异步函数将阻塞在此直到返回一个Promise对象再执行之后的代码,故在2s后输出resolved
字符串。
await关键字后面可以跟任何js值,比如await 42,如果表达式的值不是Promise对象,则js会将其转换为一个promise。此外await后面跟的对象只要包含有then方法则await也可以实现函数执行的暂停和恢复,具体的实现原理和一些其他trick可以参见Faster async functions and promises
1 | function resolveAfter2Seconds() { |
Basic blocks && CFG
basic block
简称bb,我们把一个procedure划分成许多basic block
,每个bb都是一块straight line code,在block中没有jump in或者jump out。这些bb被组织起来就可以得到control flow graph(CFG)
。
一个flow-graph
有以下特点:
- bb B1、B2等将被作为node
- 如果control可以从B1 flow到B2,那么从B1->B2就有一个直接连接的边
Enter
和EXIT
这两个特殊的节点分别代表graph的source和sink
在一个BB的内部,IR可能表现为各种形式(我们可以用不同的表现形式构建bb):tuples,trees,DAGs(有向无环图)
如何构建BB?
假设输入形式为tuple,首先考虑一个leader
的集合,leader指BB中的第一个tuple:
- 第一个tuple为leader
- 如果存在
if ... goto L
或者goto L
的语句那么L是一个leader - 如果tuple L紧跟在一个tuple
if ... goto B
或者goto B
的后面,那么L是一个leader
每个bb中都包含leader及直到下一个leader出现前的tuple。
控制流图构造:
如果在一个有序代码中,基本块B2跟在B1后,那么产生一个由B1到B2的有向边。
a) 有跳转点。这个点从B1的结束点跳到B2的开始点
b) 无跳转点(有序代码中),B2跟在B1后,且B1的结束点不是无条件跳转语句
Scheduling
Scheduling阶段最终从node graph
中生成CFG
。
注意value,effect和control的依赖必须被遵循,在满足依赖的情况下,scheduler可以以任何顺序排布nodes!
high-level的scheduling:
- build BB:本质是遍历
control chain
- place fixed nodes into their BB:Phis,return,parameter等节点的fix
- place其他节点:获取一个节点和其所有的use,把它放在其所有use的
dominator
中
我们尝试从loop中跳出,处理cfg,撤销由于redundancy elmination
造成的影响。
scheduling和side-effects:
scheduler把effects当成普通的value进行处理。TurboFan必须注意:
- 监视effect chain防止断开:不同于普通的value,我们不能同时拷贝很多节点
- 保证effect chain不被破坏:如果一个node的output没有使用,那么它将不会被scheduled
存储effect ordering:
蓝色的edge表示effect chain,StoreField操作可以被随意地放置在branch前(比如branch在一个循环里,scheduler认为提到前面无所谓),这种情况下我们需要插入一个control dependency
到IfTrue节点以避免上述情况发生。
Load-store effect ordering: Load必须同effect chain全连接,否则load可能被scheduled到store后面
Memory region protection: 我们需要保护allocation和initialization.在change节点中可能会有alloc的操作,因此如果它被scheduled到了store之间,GC可能会看到未初始化的内存。我们使用BeginRegion
和FinishRegion
来保证内存区域是不可打断的,因而region是原子的。
Effect control linearization: 将无副作用的操作降级为low-level下有副作用的nodes。
为什么要进行scheduling?
- son可以表达不同顺序的code:许多可能的CFG、许多可能的nodes分配到CFG的blocks、在basic block中许多可能的顺序
- 最有效的顺序是什么:取决于control dominance,loop nesting和register pressure
- 产出:传统的CFG
主要思路:
- Build a schedule
- 从schedule中重建control和effect chain
- 在re-building的时候lower opration并且将他们连接到effect/control chain上
- Throw away这个schedule
漏洞描述
issue编号为1076708,后被分配CVE编号CVE-2020-6468,issue名称为OOB read/write in v8::internal::ElementsAccessorBase<v8::internal::FastHoleyDoubleElementsAccessor
,即Array中elements的读写越界,漏洞提交者称该漏洞产生于JIT阶段,可以触发类型混淆以修改array的length为任意值,进而造成OOB Read/Write。
具体地,在src/compiler/dead-code-eliminiation.cc
代码文件的ReduceDeoptimizeOrReturnOrTerminateOrTailCall
中,v8使用Throw
节点替换了Terminate
节点,这导致在effect-control-linearization
节点多个control节点attach到了同一个节点上。根据这个漏洞可以构造利用原语,该原语将在后面schedule阶段造成指令规划错误,作者使用PoC成功修改arr.length=-1
并将其checkMaps节点放在赋值操作后面。
影响范围
在issue的报告中提交者在Chrome 81
和V8 8.1.307
测试触发了崩溃,CVE描述称该漏洞影响83.0.4103.61
版本前的Chrome,我们在Chromium Download Tool下载stable
版本进行测试,发现linux下可以触发漏洞的最早的stable版本为2020-04-07
发布的81.0.4044.92
。
在chromium_source里的blame查看该代码文件的修改历史记录,发现最早在2017-11-17
的一个commit引入了漏洞代码commit-19ac10e,该代码主体使用的函数HasDeadInput
同最终使用的FindDeadInput
功能相同但是参数类型不同,在2018-01-03
的commit-8de3a3b中第一次修改了HasDeadInput
函数为FindDeadInput
,并在2018-01-04
最终确定使用该函数。
漏洞触发和扩大影响需要配合IR不同阶段的优化,从17年到20年之间优化部分的代码也有许多修改,因此上述时间线并不能成为判定漏洞影响范围的准确标准,如果需要准确判定漏洞的影响版本仍需要使用PoC进行漏洞触发测试。
环境搭建
git切换到漏洞所在的版本c011335dfaf5441b44e27d1b76dbf71cf2df775c
,gclient sync
同步文件后编译出64位的release版本和debug版本,为了方便调试在编译前设置gn
的配置文件如下,之后的调试中我们可以使用符号表。
1 | is_debug = false |
将patch_diff1应用到代码文件(这里为了查看生成的IR图做对比,我们只保留修改代码检查的patch1),编译得到patch后的d8。
安装v8的turbolizer,使用其自带的SimpleHTTPServer
启动服务到本地的8000端口。虚拟机内部的键盘识别有些问题,为了使用一些快捷键在虚拟机启动服务在物理机查看IR图。
1 | cd v8/tools/turbolizer |
漏洞分析
PoC分析
漏洞的提交团队提供了两个PoC,其中bug34_min.js
是最小化触发漏洞的验证脚本,oob.html
为构造原语触发OOB的造成严重影响的验证脚本。在第一份PoC中定义了函数f,函数内部定义了TypedArray
数组,分配了0个元素的空间,将其第一个元素设置为obj对象,函数f内部又定义了一个异步函数var5
。在var5函数里定义了常量对象var9,后面的死循环中使用了未定义的变量abc1|abc2
作为if语句判断的条件,if内部以var9
为循环进行/终止判断条件,被嵌套的循环执行await 1
以及输出未定义变量abc3
。
该PoC只能在IR图中看到Terminate节点的消除,可以据此确定触发到了漏洞,但由于并无OOB的效果我们重点分析后面的PoC
1 | //bug34_min.js |
第二份PoC在测试时去掉script标签,将alert输出改为console.log即可在d8上测试。其定义了两个class classA和classB。构造方法中为类成员赋值,classA的成员包括数值变量val、x
以及数组变量a
。classB的成员包括数值变量val、x
以及字符串变量s
。
变量A和变量B分别代表classA和classB的实例对象,定义函数f,参数arg2为41
时则直接返回5,否则执行后续代码,定义容量为10的int8arr变量,变量z被赋值为arg1.x
,对arg1的val成员赋值arg1.val = -1
,对int8arr索引为1500000000处赋值int8arr [ 1500000000 ] = 22
,定义异步函数f2并调用,f2的逻辑同PoC1中相似不再赘述。
定义变量arr为Array对象并初始化length为10,arr[0]=1.1
使其转换为浮点数数组。在循环中依次调用20000
次f(A,0);f(B,0);
,之后修改参数arg2=41再进行10000
次调用f(A,41);f(B,41);
,最终调用f(arr,0);
修改arr的length为-1。
1 | <script> |
为了弄清哪里的关键操作触发了漏洞尝试修改部分核心代码进行验证,发现对于未定义的变量而言我们只能将其精简到以下形式,其余改动都会引起PoC失效,f2也无法改成同步函数。从PoC直接对应到漏洞触发比较困难,目前我们只知道while循环在IR中会生成loop节点和Terminate节点,故这里的循环可以理解其含义,对于其他的操作并无显式的影响,只能理解为构造特殊IR图所进行的布局,进行至此我们先去研究漏洞产生的原因以及PoC生效的原理。
1 | async function f2 ( ) { |
coredump分析
在release
版本中使用poc.html
中的js代码测试,可以得到下面的crash,对比之后发现吻合issue中的OOB read/write in v8::internal::ElementsAccessorBase<v8::internal::FastHoleyDoubleElementsAccessor
1 | #0 v8::internal::FixedDoubleArray::is_the_hole (this=<optimized out>, index=0x41414141) at ../../src/objects/fixed-array-inl.h:370 |
在debug版本中使用bug34_min.js
进行测试,得到如下crash,这里是为了验证ReduceDeoptimizeOrReturnOrTerminateOrTailCall
函数引发的漏洞。PoC2中的crash是由越界写引发的,因此我们重点关注这里的crash。可以看到是在EffectControlLinearizationPhase
优化阶段依次调用了EffectControlLinearizationPhase->ComputeSchedule->BuildCFG.Run()->ConnectBlocks->ConnectThrow->AddThrow
,在DCHECK_EQ(BasicBlock::kNone, block->control());
发现block->control为throw触发了FATAL结束进程。
为了进一步研究这里DCHECK以及fail的原因我们还需要进一步分析源码。
1 | ➜ x64.debug git:(a4dcd39d52) ✗ ./d8 poc.js --allow-natives-syntax |
调试 && 源码分析
该部分代码较多,本节分析将配合调试过程讲解关键代码。
Terminate节点生成
观察IR图,131:Terminate
节点生成于Inlining
阶段,effect input
为118:EffectPhi
节点,control input
为124:Loop
节点。该阶段将一些small函数内联到调用函数处,内联可以有效降低代码的多余检查和操作,使优化更有效,如对于add(1,2)直接进行常量折叠。体现到IR图上,Inlining将callee的子图展开到caller处。
在源码中生成Terminate
节点的关键部分在BytecodeGraphBuilder::Environment::PrepareForLoop
,在创建loop header
时创建了Terminate
节点
1 | //调用链:BytecodeGraphBuilder::BuildGraphFromBytecode->BytecodeGraphBuilder::CreateGraph->BytecodeGraphBuilder::VisitBytecodes->BytecodeGraphBuilder::VisitSingleBytecode->BytecodeGraphBuilder::BuildLoopHeaderEnvironment->BytecodeGraphBuilder::Environment::PrepareForLoop |
我们在生成该节点处下断点调试查看,可以发现成功创建了0x83(131)Terminate节点1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48//b bytecode-graph-builder.cc:808
[-------------------------------------code-------------------------------------]
0x5652c3650a61 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1489>: mov edx,0x2
0x5652c3650a66 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1494>: xor r8d,r8d
0x5652c3650a69 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1497>:
call 0x5652c36bd160 <v8::internal::compiler::Graph::NewNode(v8::internal::compiler::Operator const*, int, v8::internal::compiler::Node* const*, bool)>: call 0x5652c36bd160 <v8::internal::compiler::Graph::NewNode(v8::internal::compiler::Operator const*, int, v8::internal::compiler::Node* const*, bool)>
=> 0x5652c3650a6e <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1502>: mov r12,rax
0x5652c3650a71 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1505>: mov r14,QWORD PTR [r13+0x0]
0x5652c3650a75 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1509>: mov r15,QWORD PTR [r14+0x178]
0x5652c3650a7c <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1516>: mov rax,QWORD PTR [r14+0x180]
0x5652c3650a83 <v8::internal::compiler::BytecodeGraphBuilder::Environment::PrepareForLoop(v8::internal::compiler::BytecodeLoopAssignments const&, v8::internal::compiler::BytecodeLivenessState const*)+1523>: cmp r15,rax
[------------------------------------stack-------------------------------------]
0000| 0x7ffda7ff0550 --> 0x5652c41eccf0 --> 0x5652c3bdd8f8 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+1824>: 0x00005652c3b90e98)
0008| 0x7ffda7ff0558 --> 0x5652c41ecc38 --> 0x5652c3bdda78 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+2208>: 0x00005652c3b91068)
0016| 0x7ffda7ff0560 --> 0x5652c41f1910 --> 0x7
0024| 0x7ffda7ff0568 --> 0x5652c41eccf0 --> 0x5652c3bdd8f8 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+1824>: 0x00005652c3b90e98)
0032| 0x7ffda7ff0570 --> 0x5652c41f1900 --> 0x0
0040| 0x7ffda7ff0578 --> 0x5652c41e3170 --> 0x1
0048| 0x7ffda7ff0580 --> 0x5652c41ecaa8 --> 0x5652c3bde6a0 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+5320>: 0x00005652c3b91bd8)
0056| 0x7ffda7ff0588 --> 0x7ffda7ff06c0 --> 0x7ffda7ff07c0 --> 0x5652c41c65b0 --> 0x3f3d00000000 --> 0x7ffda7ff2040 (0x00003f3d00000000)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x00005652c3650a6e 75 return NewNode(op, nodes_arr.size(), nodes_arr.data());
gdb-peda$ p *(v8::internal::compiler::Node*) 0x5652c41ef928
$1 = {
static kOutlineMarker = 0xf,
static kMaxInlineCapacity = 0xe,
op_ = 0x5652c3bdd328 <v8::internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+336>,
type_ = {
payload_ = 0x0
},
mark_ = 0x0,
bit_field_ = 0x22000083,(131节点)
first_use_ = 0x0
}
gdb-peda$ p * (v8::internal::compiler::Operator *) 0x5652c3bdd328
$2 = {
<v8::internal::ZoneObject> = {<No data fields>},
members of v8::internal::compiler::Operator:
_vptr$Operator = 0x5652c3b90788 <vtable for v8::internal::compiler::CommonOperatorGlobalCache::TerminateOperator+16>,
mnemonic_ = 0x5652c2bd6c9e "Terminate",
opcode_ = 0x12,
properties_ = {
mask_ = 0x78
},
//...
}
gdb-peda$
Throw节点生成
Throw节点的创建产生于VisitThrow、VisitAbort
或者VisitReThrow
函数中,这里以VisitThrow
为例查看源码。
1 | //调用链BytecodeGraphBuilder::BuildGraphFromBytecode->BytecodeGraphBuilder::CreateGraph->BytecodeGraphBuilder::VisitBytecodes->BytecodeGraphBuilder::VisitSingleBytecode->BytecodeGraphBuilder::VisitThrow/BytecodeGraphBuilder::VisitAbort/BytecodeGraphBuilder::VisitReThrow |
在gdb中下断点到NewNode处调试观察,可以看到成功生成了0x135(309)Throw节点。
1 | //b bytecode-graph-builder.cc:2610 |
Terminate->Throw节点替换
观察IR图,重点关注131:Terminate
节点,发现在TypedLowering
阶段该节点被优化为了Throw
节点,节点的control input
为70:JSCreateTypedArray
,effect input
为389:Unreachable
。该阶段主要是根据类型将表达式和指令替换为更简单的表示,比如JSAdd(1,2)
,由于参数均为数字,其将被优化为更底层的NumAdd(1,2)
。
在src/compiler/pipeline.cc
中可以找到该阶段的代码,reduer
用于对节点做优化,我们重点关注DeadCodeElimination
的graph_reduer
。
1 | //src/compiler/pipeline.cc:1519 |
节点的reduce最终会调用到各个reduer对应的Reduce函数,具体可以参见下面代码注释。
1 | //调用链:GraphReducer::ReduceGraph->GraphReducer::ReduceNode->GraphReducer::ReduceTop->GraphReducer::Reduce |
接下来我们看下DeadCodeElimination::Reduce
,当节点类型为Deoptimize/Return/Terminate/TailCall
时会调用漏洞函数ReduceDeoptimizeOrReturnOrTerminateOrTailCall
。
1 | //src/compiler/dead-code-elimination.cc:48 |
同样地,我们在gdb中调试观察,发现Terminate节点的某个输入为389:Unreachable
节点,由于其opcode属性为IrOpcode::kUnreachable
,FindDeadInput
返回非空指针使其得以进入后续替换为Throw节点的逻辑。
//b* 0xF533BF+0x000055be998ee000
//b dead-code-elimination.cc:3201
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39[-------------------------------------code-------------------------------------]
0x55be9a8413b5 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+165>: cmp r14,r13
0x55be9a8413b8 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+168>:
jne 0x55be9a841390 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+128>: jne 0x55be9a841390 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+128>
0x55be9a8413ba <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+170>:
jmp 0x55be9a841554 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+580>: jmp 0x55be9a841554 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+580>
=> 0x55be9a8413bf <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+175>: test rbx,rbx
0x55be9a8413c2 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+178>:
je 0x55be9a841551 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+577>: je 0x55be9a841551 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+577>
0x55be9a8413c8 <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+184>: mov r13,QWORD PTR [rbp-0x40]
0x55be9a8413cc <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+188>: mov rdi,r13
0x55be9a8413cf <v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(v8::internal::compiler::Node*)+191>: xor esi,esi
[------------------------------------stack-------------------------------------]
0000| 0x7fff2782c5f0 --> 0x55be99d8a23f ("DeadCodeElimination")
0008| 0x7fff2782c5f8 --> 0x7f82046976a0 --> 0xfbad2a84
0016| 0x7fff2782c600 --> 0x55be9c835928 --> 0x55be9ad9e328 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+336>: 0x000055be9ad51788)
0024| 0x7fff2782c608 --> 0x7fff2782c9d0 --> 0x55be9ad53c70 (:internal::compiler::DeadCodeElimination+16>: 0x000055be99fedae0)
0032| 0x7fff2782c610 --> 0x55be9c835948 --> 0x55be9c87fe40 --> 0x55be9ad9e208 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+48>: 0x000055be9ad51638)
0040| 0x7fff2782c618 --> 0x55be9c87d288 --> 0x55be9ad617b0 (:internal::compiler::(anonymous namespace)::SourcePositionWrapper+16>: 0x000055be99fedae0)
0048| 0x7fff2782c620 --> 0x0
0056| 0x7fff2782c628 --> 0x55be9c835948 --> 0x55be9c87fe40 --> 0x55be9ad9e208 (:internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+48>: 0x000055be9ad51638)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Thread 1 "d8" hit Breakpoint 9, v8::internal::compiler::DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall (this=0x7fff2782c9d0, node=0x55be9c835928) at ../../src/compiler/dead-code-elimination.cc:320
320 if (FindDeadInput(node) != nullptr) {
gdb-peda$ p *(struct Node*) $rbx
$8 = {
static kOutlineMarker = 0xf,
static kMaxInlineCapacity = 0xe,
op_ = 0x55be9ad9e208 <v8::internal::compiler::(anonymous namespace)::GetCommonOperatorGlobalCache()::object+48>,
type_ = {
payload_ = 0x1
},
mark_ = 0x1b,
bit_field_ = 0x22000185,(389节点)
first_use_ = 0x55be9c82b880
}
gdb-peda$
至此我们结合IR图以及调试找到了漏洞产生
的阶段,patch的注释称Terminate
节点不属于CFG
,因此不应当被替换为Throw
节点。在增加patch后该节点处理时不会进行替换,此时该节点的input已经被优化成了其他节点,由于在这里没有TrimInputCount
等操作,该节点后面将作为一个非法节点被回收掉,自此不再参与后续的优化阶段。在PoC中作者将此处的漏洞影响扩大到了后面的阶段,从而构造出OOB读写,接下来我们重点研究该漏洞后续的影响。一个很关键的问题是在debug版本中运行PoC触发DCHECK crash报错,这个报错因何而起,打破了开发者的什么假设,作者声称的incorrect schedule
究竟指什么,让我们带着问题继续后面的分析。
EffectLinearization阶段分析
日志对比
首先来看下为何要分析这个阶段,通过对比观察vuln
和patch
版本生成的IR图的不同phase,发现在第二次schedule的时候关键的节点73:StoreField
调度到了不同的Block中,根据节点的控制依赖关系应当有460:DeoptimizeUnless->461:Merge->462:EffectPhi->73:StoreField
,其中460:DeoptimizeUnless
节点为71:CheckMaps
在EffectLinearization
阶段优化后的结果,该节点负责检查arr.length=-1
赋值前的arr_map,不符合优化假设则解优化。根据上述节点被调度安排到的Block应当有B5->B7->B7->B4
,而我们根据其基本块的调度关系可以发现B4
在B5
前,其指令先于B5
执行,因此无法控制73:StoreField
节点,即map类型检查失效,PoC可以据此实现对于length的畸形赋值。
1 | -- Graph after V8.TFEffectLinearization -- |
作为对比,再来观察一下patch后的版本,节点的effect/control
依赖并没有变化,我们只看460:DeoptimizeUnless->461:Merge->462:EffectPhi->73:StoreField
这一条chain,对应scheduled基本块为B5->B7->B7->B7
,由于B7
的prev_block包含B5
,因此指令执行的顺序为先B5
中的指令后B7
的指令,effect/control chain
并没有被打破,同理73节点的其他control/effect chain也符合schedule的基本块顺序执行,关键的解优化检查节点460:DeoptimizeUnless
得以在赋值前执行类型检查从而避免畸形赋值。
1 | -- Graph after V8.TFEffectLinearization -- |
对比该日志之前的记录,发现在第二次
schedule时73:StoreField
节点的位置存放出现错误,在patch
版本中将其放入了B4
而在vuln
版本中其被放入了B3
,根据节点的控制关系以及基本块的支配关系猜想可能是73:StoreField
所支配的相关的节点出现错误进而引发其放置错误,按照这种猜想继续向之前日志对比,发现463节点
存放位置错误,进而发现465、465节点
存放位置错误,以此类推,一直溯源到466->465->470->416
,416:Unreachable
节点在计算所属基本块时,由于同时需要domain
两个use
,最终选择的commonDominato
r为bb3
(domin_depth
较小的基本块)出现错误,再根据节点的支配关系将这个错误扩大到了73:StoreField
。
1 | //vuln |
结合IR图发现471:Throw
节点是416:Unreachable
节点转换而来,为什么经过这个阶段原180:Throw
节点被消除而Terminate转换而来的131:Throw
节点没有变化?471:Throw
节点是如何生成的?为什么两个Throw
节点所属的Basic Block
不同?我们带着这几个关键问题继续分析源码。
Scheduling
EffectLinearization
阶段的优化有:
- 将可能解优化的节点连接到control/effect chain上,分配check point
- 根据control flow扩展宏操作(比如ChangeTaggedToFloat64节点)
- 基于phis优化branch节点(branch cloning)
首先看pipeline.c中EffectControlLinearizationPhase
,该阶段关键操作有Schedule、LinearizeEffectControl
和DeadCodeElimination
。
1 | //src/compiler/pipeline.cc:1679 |
ComputeSchedule
函数首先调用BuildCFG
方法构造基本块进而得到CFG
,之后计算出遍历基本块的顺序(这里使用reverse-post-ordering(逆后序)
,这部分可参考数据流迭代分析的相关资料,代码分析中将略过实现细节)。再之后,根据rpo
的顺序计算出每个基本块的dominator
得到支配树(对于一个基本块b1,若从start->b1
的数据流必定经过b0
,则称b0
为b1
的dominator
,一个基本块可能有多个dominator
,其中距离b1最近的基本块被称为Immediate dominator(直接支配点)
,只要计算出IDom
的信息,我们就可以得到支配树)。PrepareUses
方法计算出每个节点的use使用情况,在这个过程中根据依赖关系将一些节点添加到基本块中,ScheduleEarly
方法计算出节点的最小包含基本块以及对应的dominator所属层次等信息,同时根据依赖关系将该信息传播给该节点所支配的结点更新他们的支配信息。这里计算的节点所属位置仍是不确定的,因为只有在综合所有的支配信息之后才能得到节点真正应属的位置。ScheduleLate
就是对所有的节点的基本块所属位置做最终敲定,在SealFinalSchedule
方法里则将这些node实际添加到basic block中。
1 | //src/compiler/scheduler.cc:44 |
BuildCFG
方法首先创建了一个ControlEquivalence
对象和CFGBuilder
对象,之后调用control_flow_builder_->Run()
方法。
1 | //src/compiler/scheduler.cc:613 |
该方法从end
结点开始以广度优先的策略进行反向遍历,对于每个待处理的节点将其input
保存到队列中迭代处理,Queue
方法首先为参数节点创建基本块,之后将node压入control_
变量作为之后ConnectBlocks
的迭代对象。
1 | //src/compiler/scheduler.cc:256 |
ConnectBlocks
方法将不同类型的control_node
连接到基本块中(注意这里不止是放置节点到基本块,还有将node作为Basic block之间连接的桥梁),Placement
属性表示节点的处理情况,kFixed
表示处理完毕的节点,我们重点关注ConnectThrow
方法。
1 | //src/compiler/scheduler.cc:371 |
ConnectThrow
方法首先获取参数节点的control_input
,这里的control_input对应节点的控制节点,根据此节点寻找其所在的基本块(如果该节点尚未放入基本块则继续向前寻找其控制节点和对应的基本块),之后调用schedule_->AddThrow
方法将该节点同基本块"连接"
起来。
1 | //src/compiler/scheduler.cc:566 |
Schedule::AddThrow
方法中DCHECK检查了BasicBlock::kNone == block->control()
,这个检查表明我们传入的基本块应当是一个未初始化control的block,之后调用block->set_control
设置基本块的control为kThrow
(注意区分基本块的control
和节点的control
,节点control对应另一个控制节点
,基本块的control表明该基本块的control_input节点的属性
),再调用set_control_input
将control_input节点设置为Throw节点,最后使用nodeid_to_block_[node->id()] = block
将基本块的位置保存在全局变量nodeid_to_block_
数组中方便根据node->id查找Node所在基本块位置。最后将这个基本块同end
所在的基本块双向连接起来。
1 | //src/compiler/schedule.cc:296 |
Schedule::AddThrow
函数也是我们PoC1在debug版本触发crash的报错函数,为了查看报错的具体原因我们动态调试此处并观察节点和基本块的处理情况。
观察之后发现首先处理131:Throw
节点,该节点的control_input
为70:Call
,此节点尚未放入任何基本块中,继续向前寻找,发现70:Call
节点的control_input
节点20:IfFalse
归属基本块id3:block
,故将其作为Schedule::AddThrow
的参数.我们断到该函数处查看参数,此时的bb3
的control为kNone
因此DCHECK不会报错,最终bb3的control_input被设置为131:Throw
,bb3
也和end
块建立了双向连接。
1 | //set args p2.js --allow-natives-syntax --trace-turbo-reduction --trace-turbo --trace-turbo-scheduled --trace-turbo-scheduler |
继续运行程序,第二次处理180:Throw
节点,该节点的control_input也为70:Call
和20:IfFalse
,因此在寻找前驱block时也找到了id=3
的基本块bb3,由于之前的control已被赋值为kThrow
,这里的DCHECK检查报错程序crash。
1 | [-------------------------------------code-------------------------------------] |
经过上述调试我们发现同一个basic block bb3
被重复赋值了control
和control_input
,这样的操作会产生什么影响呢?
从源码角度看,造成的影响有:
- bb3和end基本块连接了两次,因此在bb3的successors_里会保留两个end的副本,在end的predecessors_里会保留两个bb3的副本(这一点也可以通过gdb调试vector成员的方式进行查看,查看向量中start和current属性之间保存的基本块变量即可)
nodeid_to_block_[node->id()] = block;
的赋值操作导致两个节点都可以访问到bb3
至此BuildCFG部分基本分析完毕,可以看到control_input
是基本块的一个重要属性,通过DCHECK不难发现开发者假设一个基本块只能有一个control_input
,而issue正是打破了这种假设,至于后续的影响我们还需要继续分析。
ComputeSpecialRPONumbering
函数我们暂且按下不表,这里的rpo
相比于传统意义上的rpo
还多了一些条件限制,具体可参见代码注释。
GenerateDominatorTree
函数主要创建支配树,从start块自顶向下遍历构建。对于某个待处理的基本块,获取其所有的前驱块,再调用BasicBlock::GetCommonDominator
获取两个基本块的共同支配点,dominator_depth
用于描述从start
开始的基本块的深度,因此该函数遍历两个基本块的dominator
(向着dominator_depth
减小的方向),最终得到共同的支配块,这里的支配块就是IDom
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48//src/compiler/scheduler.cc:1175
void Scheduler::GenerateDominatorTree() {
TRACE("--- IMMEDIATE BLOCK DOMINATORS -----------------------------\n");
GenerateDominatorTree(schedule_);
}
//src/compiler/scheduler.cc:1167
void Scheduler::GenerateDominatorTree(Schedule* schedule) {
// Seed start block to be the first dominator.
schedule->start()->set_dominator_depth(0);
// Build the block dominator tree resulting from the above seed.
PropagateImmediateDominators(schedule->start()->rpo_next());
}
//src/compiler/scheduler.cc:1143
void Scheduler::PropagateImmediateDominators(BasicBlock* block) {
for (/*nop*/; block != nullptr; block = block->rpo_next()) {
auto pred = block->predecessors().begin();
auto end = block->predecessors().end();
DCHECK(pred != end); // All blocks except start have predecessors.
BasicBlock* dominator = *pred;
bool deferred = dominator->deferred();
// For multiple predecessors, walk up the dominator tree until a common
// dominator is found. Visitation order guarantees that all predecessors
// except for backwards edges have been visited.
for (++pred; pred != end; ++pred) {
// Don't examine backwards edges.
if ((*pred)->dominator_depth() < 0) continue;
dominator = BasicBlock::GetCommonDominator(dominator, *pred);
deferred = deferred & (*pred)->deferred();
}
block->set_dominator(dominator);
block->set_dominator_depth(dominator->dominator_depth() + 1);
block->set_deferred(deferred | block->deferred());
TRACE("Block id:%d's idom is id:%d, depth = %d\n", block->id().ToInt(),
dominator->id().ToInt(), block->dominator_depth());
}
}
//src/compiler/schedule.cc:96
BasicBlock* BasicBlock::GetCommonDominator(BasicBlock* b1, BasicBlock* b2) {
while (b1 != b2) {
if (b1->dominator_depth() < b2->dominator_depth()) {
b2 = b2->dominator();
} else {
b1 = b1->dominator();
}
}
return b1;
}
PrepareUses
方法从end节点开始遍历整个图节点,对于状态为kFixed
节点标记为root节点,对于其中尚未分配基本块的节点将其添加到control_input
对应的基本块中。
1 | //src/compiler/scheduler.cc:1224 |
ScheduleEarly
方法对于root节点进行schedule
,将minimum_block_
赋值为节点所在的block并将这个信息向后传递给该节点所有的use
处,被传播到该信息的use
又会放入queue
中继续向后传播自己的use
。
1 | //src/compiler/scheduler.cc:1344 |
ScheduleLate
方法遍历标记为root
的节点,对于每个节点,确定其input
节点对应的基本块位置,该基本块为node
所有use
的CommonDominator
,之后调用PlanNode
将node放到该基本块中。
1 | //src/compiler/scheduler.cc:1704 |
SealFinalSchedule
方法输出最终的node<->block位置对应结果,注意这里日志输出时用的是node->id()
标识基本块,id是基本块存放的物理位置,即基本块在内存中以id0->id1->id2->...
方式存放,而在LinearizeEffectControl
阶段结束后调用PrintScheduledGraph
输出基本块和节点对应关系时使用rpo_number
作为基本块的编号,这里的编号为逻辑id,比如node_id=3
的基本块的rpo_number=4
,这里的编号对应可以在gdb中调试也可以在日志中观察对应。->
1 | //src/compiler/scheduler.cc:1724 |
在BuildCFG方法执行阶段漏洞得以触发,通过对比日志发现关键的数组长度赋值节点73:StoreFiled
在此阶段的schedule
位置并未发生错误,我们继续分析后面的LinearizeEffectControl
函数
LinearizeEffectControl
该阶段主要是将分配表示形式的变化链入control/effect chain
中并lower
节点,引入effect phis
重新连接effect得到SSA等。我们重点关注ProcessNode
函数
1 | //src/compiler/effect-control-linearizer.cc:5982 |
ProcessNode
方法对除了effect phis/phis等节点之外的其他节点进行处理,TryWireInStateEffect
函数对节点做lower处理,我们重点关注其中对于Unreachable
节点的处理函数ConnectUnreachableToEnd
。
1 | //src/compiler/effect-control-linearizer.cc:768 |
ConnectUnreachableToEnd
方法将从Unreachable
节点到end
节点之间创建一个新的Throw
节点,通过调试发现这里增加的新节点就是在EffectLinerization
阶段出现的471:Throw
节点,在MergeControlToEnd
里该节点链入了end
的前驱中,由于block_updater_==0
故不会进入后面的block_updater_->AddThrow(throw_node);
调用。
1 | //src/compiler/graph-assembler.cc:819 |
ProcessNode
执行完毕后调用gasm()->FinalizeCurrentBlock(block)
处理当前基本块,因为block_updater_==0
故这里直接返回原基本块。
1 | //src/compiler/graph-assembler.cc:804 |
在UpdateEffectControlForNode
函数中对基本块的control_input
进行处理,将节点的effect/control_input
分别替换为gasm()->effect
和gasm()->control
,注意在ConnectUnreachableToEnd
函数中effect_和control_已被设置为了mcgraph()->Dead();
,故这里将更新bb3
的180:Throw
节点的effect/control为Dead
。
1 | //src/compiler/effect-control-linearizer.cc:751 |
对于Terminate
转换为的131:Throw
节点,因为该节点不是任何基本块的control_input
,因此不会调用UpdateEffectControlForNode(block->control_input());
处理该节点,该节点最后将因此被保留下来!
ReduceGraph
重点关注DeadCodeElimination
中对于Throw
节点的处理,发现当该节点的control->opcode
为kDead
时则返回Throw
节点的control
节点作为reduction
替换Throw
节点,在这里180:Throw
节点由于control
之前被赋值为了Dead
因此被消除,131:Throw
节点仍被保留。
1 | //src/compiler/dead-code-elimination.cc:48 |
动态调试
通过以上的源码分析我们可以了解到471:Throw
节点的生成和180:Throw
节点的消失过程,接下来我们结合gdb调试看下第二次ConnectThrow
时为什么将两个Throw
节点分配到了不同的Basic Block
。
在pipeline中每过一个优化阶段都会调用RunPrintAndVerify
函数,由于我们调用时添加了--trace-turbo-scheduled --trace-turbo-scheduler
参数,因此这里会再次调用ComputeSchedule
,日志中对应的输出对应IR图中的第二次schedule
结果。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41//Run<EffectControlLinearizationPhase>();
//RunPrintAndVerify(EffectControlLinearizationPhase::phase_name(), true);
//src/compiler/pipeline.cc:2342
void PipelineImpl::RunPrintAndVerify(const char* phase, bool untyped) {
if (info()->trace_turbo_json_enabled() ||
info()->trace_turbo_graph_enabled()) {
Run<PrintGraphPhase>(phase);
}
if (FLAG_turbo_verify) {
Run<VerifyGraphPhase>(untyped);
}
}
//src/compiler/pipeline.cc:2273
struct PrintGraphPhase {
DECL_PIPELINE_PHASE_CONSTANTS(PrintGraph)
void Run(PipelineData* data, Zone* temp_zone, const char* phase) {
OptimizedCompilationInfo* info = data->info();
Graph* graph = data->graph();
//...
//在调试中我们使用trace-turbo-scheduled和--trace-turbo-scheduler开启了trace
if (info->trace_turbo_scheduled_enabled()) {
AccountingAllocator allocator;
Schedule* schedule = data->schedule();
if (schedule == nullptr) {
//这里进行第二次Schedule
schedule = Scheduler::ComputeSchedule(temp_zone, data->graph(),
Scheduler::kNoFlags,
&info->tick_counter());
}
AllowHandleDereference allow_deref;
CodeTracer::Scope tracing_scope(data->GetCodeTracer());
OFStream os(tracing_scope.file());
os << "-- Graph after " << phase << " -- " << std::endl;
os << AsScheduledGraph(schedule);
} else if (info->trace_turbo_graph_enabled()) { // Simple textual RPO.
//..
}
}
};
我们断到RunPrintAndVerify
调用处之后在ConnectThrow
下断点观察对于Throw
节点的处理,首先处理的节点为131:Throw
,其control_input分别为70:Call
和20:IfFalse
,前者不属于任何基本块故找到20:IfFalse
节点的基本块bb3
,这个过程同第一次Schedule时的结果一致。
1 | b pipeline.cc:2498 |
下面是根据node
定位其所在的基本块
1 | [-------------------------------------code-------------------------------------] |
第二次处理471:Throw
节点,其control_input分别为470:DeoptimizeUnless->466:DeoptimizeUnless->461:Merge
节点,前两个不属于任何基本块,461:Merge
节点对应的基本块为bb4
。
1 | [-------------------------------------code-------------------------------------] |
如下所示,index=4标识该基本块为bb4
。
1 | [-------------------------------------code-------------------------------------] |
在patch之后的版本调试发现ConnectThrow处理471:Throw
同vuln版本的处理相同,也是将其作为bb4
的control_input。对比之后可以发现,由于131:Throw
并不属于任何Basick Block,该节点在EffectControlLinearizationPhase
成功躲过了在ConnectUnreachableToEnd
调用后使用block->control_input
对于Throw
节点的effect/control
赋值为Dead
,进而躲过了reducer
的节点消除。在第二次Schedule时再次将其链入bb3,由于节点的支配关系导致416:Unreachable
节点所属基本块计算失误,最终由于支配关系导致73:StoreField
存放的位置错误,该节点的检查节点460:DeoptimizeUnless
所在的基本块指令在store
节点后面执行,此时的检查已经没有任何作用,arr.length
成功被改为-1。
这一点我们通过打印解优化的日志对比也可以发现:patch的版本里对28行的length赋值和31行的TypedArray赋值都做了解优化,而漏洞版本里只对31行的TypedArray赋值做了解优化。这是因为由71:CheckMaps
转换为的460:DeoptimizeUnless
检查时已经赋值完毕,没有赋值操作故不需解优化。
1 | 28:arg1.val = -1; |
漏洞利用
PoC已经可以将arr.length置为-1,我们可以借此构造出oob_arr,在之后布置double_arr及obj_arr,利用gc把它们放进Old Space中从而通过oob_arr可控地访问浮点数组和对象数组。
1 | gc(); |
指针压缩
在新的v8(2020.3.30之后的版本)中引入了指针压缩技术Pointer Compression in V8,用以减少内存的消耗,其核心思想是:对于64的指针不存储其绝对地址而是存储其相对于某个基址的偏移,基址存储在某个寄存器中,这是一种拿计算资源换取存储资源的做法。
具体地,v8申请出连续的4G(2^32 bits)
内存空间作为堆空间,64-bit指针原本的存储方式如下:
1 | Smi 64bit |
在指针压缩后Smi
仍可以用32位内存表示,对象指针的低位保存在内存空间中作为offset
,指针高4字节地址保存在寄存器r13
中,取用指针时只需计算r13+offset
即可得到指针的实际取值。
1 | Smi 32bit |
addressOf原语
首先通过越界读获取double_arr_maps
和obj_arr_maps
,将参数obj1存放于对象数组,越界写修改对象数组的maps为浮点数组的maps,类型混淆后可以得到该对象的地址。由于存在指针压缩,我们只能得到对象地址中的offset
部分,但v8取指针实际地址的操作对我们来说是透明的,我们只需要在合适的地址布置offset
就等价于布置了实际指针地址
。获取地址后我们再修改obj_arr_maps
为初始值。
1 | function addressOf(obj1){ |
fakeObj原语
将目标地址存放在double_arr
中,修改浮点数组的maps为obj_arr_maps
造成类型混淆,此时获取得到的double_arr[0]
即为伪造的对象,需要注意的是对象结构有合法性检查,我们在指定伪造对象的地址时需要提前在该内存地址布置合法的对象成员。获取伪造的对象后修改doubl_arr_maps
为初始值。
1 | function fakeObj(addr_high,addr_low){ |
任意地址读写原语
double_arr->elements
成员标识浮点数组的成员地址,假如我们可以控制某个浮点数组对象的elements
为fake_addr
,则可以对任意的地址做读写操作。具体地,从double_arr[1]
开始伪造浮点数组对象的内存,利用fakeObj
原语得到封装后的伪造对象fake_obj
,修改double_arr[2]
对应修改fake_obj->elements
,对fake_obj[0]
赋值即为对elements
存储的地址赋值。地址和取值均可控制因此可以实现任意地址读写原语。
1 | double_arr[1] = i2f(pack_i64(double_map_high,double_map));//maps+property |
Leak Wasm addr
我们在js代码中引入wasm
代码,v8中会存在一个rwx
段存储代码,假如我们可以将内存中的代码数据替换为shellcode就可以执行任意代码了。
调试发现可以通过Function->shared_info->instance->wasm_addr
链的成员地址找到wasm所在page的地址。
1 | //wasmInstance.exports.main |
Get Shell
劫持DataView
的backing_store
成员为wasm_addr
,向其中写入shellcode再调用wasm函数即可触发恶意代码执行,弹出计算器
1 | //---------------------------------Issue 1076708-------------------------------------- |
漏洞修复
漏洞的patch共有两处,第一处diff1如下所示,新增代码检查目标节点是否为Terminate节点,避免其进入到Throw节点的替换代码中,在该函数返回的reduction为NoChange()
。按常理说该节点应当被保留下来,但在后续IR图中该节点已经被消除,为了研究其消除的位置我们在gdb中动态调试。
在Dead-code-elimination中消除节点的判断条件是根据其control_input是否为Dead节点,其input节点181:EffectPhi
和124:Loop
节点都被消除掉成为Dead
节点,因此在之后的elmination中该节点将直接从IR图中消除,不参与后面的优化阶段。
1 | @@ -317,7 +317,10 @@ |
我们patch之后的编译版本没有符号表,因此无法直接下源码断点,这里在IDA中查看关键调用的地址,直接在gdb中对内存地址下断点。
1 | //0xB79C6A:call typed_lowering |
进入ReduceDeoptimizeOrReturnOrTerminateOrTailCall
函数时查看$rsi+20
处的bit_field_
,对于131:Terminate
节点所在的内存下硬件断点,之后继续运行程序观察关键处理函数
1 | gdb-peda$ ptype /o struct Node |
最终调试发现在DeadCodeElimination::ReduceLoopOrMerge
函数中最终调用Replace
函数将131:Terminate
节点替换为了371:Dead
节点,之后该节点被消除。
1 | 0x5614a18f6c63 <v8::internal::compiler::DeadCodeElimination::ReduceLoopOrMerge(v8::internal::compiler::Node*)+547>: mov rdi,QWORD PTR [r14+0x8] |
查看源码,与IDA对应时我们需要查看一些宏的值,此时可以在Local types
搜索kLoop
,在Edit
处可以导出联合中各成员的值。
1 | enum v8::internal::compiler::IrOpcode::Value : __int32 |
重新进行调试,发现处理的节点为124:Loop
,故node->opcode() != IrOpcode::kLoop
条件不成立,其node->InputAt(0)
为70:JSCreateTypedArray
,类型为kJSCreateTypedArray
故成功进入条件语句,此时的inputs.count()==1
,该input为371:Dead
节点,故第二轮调出循环,将131:Terminate
节点替换为371:Dead
节点,在之前某个时刻Loop的输入节点被替换为了Dead
节点,故其所有的use也被替换为了Dead
节点(具体位置调试可以再回溯查看124:Loop
的输入节点,下硬件断点观察reduce函数)
1 | //src/compiler/dead-code-elimination.cc:114 |
1 | //断于 input->opcode() == IrOpcode::kDead判断处 |
patch2的diff2如下所示,在节点放置入基本块时把DCHECK改为了CHECK,检查目标基本块的control
是否初始化为kNone
。这里的patch不仅针对Throw
节点处理函数AddThrow
,还囊括了其他节点的处理函数。这不仅修复了该issue,其他试图在schedule阶段以相同的思路(即对basic block
重复赋值control_input
使多个节点保存了索引到基本块的副本,其中非基本块最终的control_input
的节点绕过了对于基本块control_input
的节点lower和优化,根据节点支配关系计算基本块位置时造成错误)扩大影响使得指令排序错误绕过检查的思路都会失败。
1 |
|
总结 && 思考
从漏洞利用链看漏洞成因
issue_1076708
是v8 JIT编译器TurboFan
中的一个漏洞,该漏洞产生于hot code
的优化阶段,在DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall
函数中错误地将Terminate
节点替换为了Throw
节点,避免其在DeadCodeElimination::ReduceLoopOrMerge
的Loop->use
消除链中被替换为Dead
节点而回收。在EffectControlLinearizationPhase
阶段的创建CFG
时的scheduling
部分,使用ConnectBlocks->ConnectThrow
为Throw
节点寻找基本块位置时出现多个Throw节点索引到同一个基本块的情况,在节点的lower
阶段对基本块的control_input
处理时缺失了对于非最终control_input
但可以仍可以索引到原基本块的Throw
节点的消除处理,导致该节点进一步被保留至第二次scheduling
阶段。在第二次进行节点归属基本块划分时之前保留的Throw
节点根据支配关系影响了其他节点(将Throw
节点作为use
的节点,在这里为Unreachable
节点)的基本块归属,最终影响到了arr.length=-1
赋值语句中关键的StoreField
节点位置,导致该节点的脱离了解优化节点DeoptimizeUnless
(该节点由CheckMaps
节点转换而来),最终指令的执行顺序发生改变,在进行数组的类型检查前已对其length赋值完毕,从而绕过了类型检查,得到oob数组。
从开发者角度看漏洞成因
漏洞产生于ReduceDeoptimizeOrReturnOrTerminateOrTailCall
函数,开发者在设计时将Deoptimize、Return、Terminate、TailCall
节点认为地位相同,可以用同一种模式处理它们,从直觉上看,Terminate节点由Loop节点产生,其输入如果包含有Dead
节点将其优化为Throw
节点认为产生异常并无不妥,但是正如patch1中的注释所讲,Terminate nodes are not part of actual control flow, so they should never be replaced with Throw.
Terminate节点是由Loop
产生的附属产物,其和Loop
节点应当拥有相同的生命周期。在调试时我们发现在TypedLowering
阶段Loop
节点已被优化消除,因此Loop
节点作为其use
也应进行消除。而对于其他几个节点Deoptimize、Return、Terminate、TailCall
,它们都是独立的控制节点,因此可以替换为Throw
节点。
思考
该漏洞的产生位置到实际扩大影响的位置经过了多个优化阶段,假如是源码审计的方式发现漏洞函数,很难构造出一个合适的PoC触发报错,而从PoC构造的精巧程度来看为了构造出schedule的指令顺序错误其中大部分的对象布局和赋值都无法替换为其他值,个人猜测其中PoC1即bug34_min.js
为fuzz出DCHECK报错的样本,之后作者经过观察发现了该样本会使得scheduing
阶段的指令顺序错误,为了构造出arr.length=-1
绕过检查的效果又在PoC1的基础上增加新的数据和代码调试,最终得到了PoC2。
该漏洞是节点优化不当引发的,落脚在指令的scheduling
处,单从patch1看仍可能存在其他节点的消除/替换
不当引发此类风险,但patch2在划分基本块时将DCHECK
检查改为了CHECK
检查,这导致通过构造指令scheduling
不当的方式进行漏洞利用的方式被终结,假使仍存在相似漏洞,在这里划分基本块时不可能再存在有多个节点绑定到同一个基本块的情况,假使攻击者想绕过该检查构造出指令顺序异常,也只能在之前的优化阶段让节点的位置出现异常,在scheduling
阶段不会存在本应lower
而绕过该步骤的同一个Basic Block绑定
的多个control_input
节点。