翻译的一篇分析文章
前言
文章主要是翻译:https://securitylab.github.com/research/in_the_wild_chrome_cve_2021_30632/
加上了自己的一点理解,本人水平有限,难免出现错误,原文作者真的写的非常的详细,推荐阅读原文
环境搭建
Ubuntu20.04LTS
1 | git reset --hard 632e6e7 |
漏洞相关信息
commit:https://source.chromium.org/chromium/_/chromium/v8/v8.git/+/6391d7a58d0c58cd5d096d22453b954b3ecc6fec
这个是一个在野的漏洞:https://chromereleases.googleblog.com/2021/09/stable-channel-update-for-desktop.html
diff:
1 | diff --git a/src/compiler/js-native-context-specialization.cc b/src/compiler/js-native-context-specialization.cc |
漏洞分析
Property access in v8
该漏洞是发生在JIT对全局变量的属性的访问上,在v8中,对属性进行访问是一项非常复杂的事情以至于导致了许多的安全漏洞,如CVE-2021-30551,CVE-2021-30517。负责属性访问的代码大致在三个不同的层面上实现:
- 像
SetProperty
和GetProperty
(源码位于object.cc
)这种是最通常的方法 - 通过 inline cache 来进行访问,相关源码位于
src/ic
- Turbofan inlining 阶段的
JSNativeContextSpecialization
,主要是通过ReduceNamedAccess
方法实现的
这些代码代表了在v8中对属性访问三个不同层次的优化,由于属性访问是一个JavaScript很常见的操作,所以v8对它进行了大量的优化。为了实现这些优化,每个属性都会有很多metadata与之相关联(这些matadata由于JIT代码对属性做出假设)。对属性的修改不仅仅涉及到改变该属性的值,也涉及到这些相关联的metadata的修改。因此为了保证优化的代码不会变得无效,每一层改变属性的metadata的方式必须是一致的。从更高的层次上来说,这个漏洞是因为jit的代码错误的设置了属性的metadata,导致其它jit代码的假设无效。
以下是与这个bug相关的metadata
Object Map, map stability, and map transitions
Object Map(v8中叫这个)是JavaScript引擎表示一个对象很重要的基本概念,它代表了对象的内存布局且在属性访问的优化方面至关重要。关于Object Map的好文章有Mathias Bynens的 “JavaScript engine fundamentals: Shapes and Inline Caches”,还有 Camillo Bruni的 “Fast properties in v8”,这里就不多介绍了。从安全角度来看,map之所以至关重要,是因为:
- map里存有该对象内存布局的信息,并且这些信息被用于提高属性访问的速度
- JIT生成的代码经常依赖于对对象map的假设,如果这些假设变得无效了,那么jit代码很有可能会在错误的位置访问内存,或者以错误的type来访问属性(类型混淆)
拥有相同属性布局和类型的对象会共享同一个map,map包含了一个属性描述符数组(DescriptorArrays),这个数组包含了属性的各种信息,当然也包括了该属性的值和类型。如以下例子所示,由于它们的属性布局相同(都有一个a
属性,并且属性的值的类型为SMI
), 所以它们两个对象共享同一个map。
1 | o1 = {a : 1}; |
使用%DebugPrint(o1)
来打印出o1对象的调试信息,可以看见它的map是一个stable_map
:
1 | d8> %DebugPrint(o1); |
如果添加了一个新的属性,那么就会v8会创建一个新的map来表示该对象的新布局
1 | o2.b = 1; //<-------- o2 now has new map, MapB |
这个新的map(MapB)通过一个transition来和旧map(MapA)相关联。现在使用%DebugPrint(o1)
来打印o1,可以看见o1的map多了一个transition指向MapB:
1 | d8> %DebugPrint(o1); |
然而,o1的map就不再是一个stable_map
了。有很多种给一个map添加transition的方法,但是无论哪一种方法,在给stable_map
添加了transitions之后,他就不再是stable
了,而是unstable
。但是如果我们现在给o1也添加一个属性b,且类型也为SMI,这样o1的map也会变成MapB:
1 | d8> o1.b = 2; |
且o1的map又变成了stable_map。当给o1对象添加属性b的时候,v8首先会检查是否能transition 到合适的且已经存在的map。如果有的话,v8只是简单的将其map变更为那个map而不是创建一个新的。上面的例子中,由于MapB已经存在了,所以transition 将会进行(o1的map从mapA transition成了 mapB),这个过程叫做map transition。
可以看到map的稳定性和transition与map如何变换相关(也有map deprecation的情况,但是那更复杂也与该漏洞无光)。当创建一个全局变量或者定义一个对象属性的时候:
1 | var x = {a : 1}; |
有许多种改变对象map,首先,一个很明显的方法就是给该对象或者属性重新赋值:
1 | x = {b : 1}; |
x和obj.x的map都变了,但是这种变化与x和obj.x原来的map并没有什么关系,所以x和obj.x原来的map仍然是stable的。当然不通过重新赋值也是能改变o和obj.x的map的,就是我们上面用到的,给它们添加新的属性:
1 | x.b = 1; |
在这种情况下,我们没有对x和obj.x重新赋值,也改变了它们的map。当使用这种办法来改变对象的map会有以下两种情况:
- x和obj.x的新map(MapB)不存在,所以v8需要创建一个新的MapB。在这种情况下,原来的map(MapA)在MapB创建前是stable的,但是在MapA添加了一个transition到MapB的时候,MapA就变成unstable了。
- x和obj.x的新map(MapB)已经存在,使用x和obj.x的map只是简单的transition到MapB。在这种情况下,MapA本来就是unstable的
所以,当一个全局变量或者一个对象属性的map变化时,可能发生以下两种情况之一:
- 该变量或者属性是被重新赋值的,那原来的MapA还是stable的
- 变量或者属性的map是通过添加新的属性来改变的,这种情况下原来MapA的状态变成了unstable的
很显然jit代码对一个对象的map进行假设时,它需要确保该对象的map不会改变,通常代码会检查以下内容:
- 这个对象是对一个全局变量的访问还是对一个属性的访问?
- 这个变量/属性能否以一种可能改变其map的方式被重新赋值?
- 如果问题2的答案是不能并且该map是stable的,那么jit代码需要在map变得unstable的时候进行deoptimized,否者map可能会在没有重新赋值的情况下被改变。
- 如果问题2的答案是可以或者该map的状态已经是unstable了,那么要么放弃优化代码,要么插入checkmap节点以确保map没有变化
对于问题2,GlobalPropertyDependency
函数通常被用于确保对全局变量的访问和优化代码之间的关系。当与jit代码相关的全局变量通过普通路径(也就是SetProperty)被重新赋值的时候,该函数会把jit代码标记为无效并进行deoptimization 。而通过inline cache和jit路径来进行重新赋值是不会触发deoptimization,因为它们会有其它的check来保证该属性的map和与其相关的metadata不会改变。
DependOnStableMap
被用来确保与jit代码相关联的map的状态时stable的,如果相关的map的状态变成了unstable,那么jit代码就会被deoptimization
在jit compiler的代码中,有很多地方都做了一个假设:一个状态为stable的map,在不使用通常的路径(SetProperty)对变量或属性进行重新赋值的情况下,该map是无法被改变的。(译者注:也就是说要改变一个全局变量的map,一定会触发与他相关的那些已经被jit优化过的代码的deoptimization)。
Global property access
前面已经介绍了map稳定性的相关内容,现在来看看patch:
首先需要注意的是,该patch是对全局属性的storing操作相关。在JavaScript中,全局属性是全局对象的一个属性,这其中也包括了全局变量。每当定义了一个全局变量,就会在全局变量中创建一个带有该变量名称的属性:
1 | var x = {a : 1}; //<---------- store global property x |
与该patch相关的结构有property_cell_type
和property_cell_value
,这些是与PropertyCell
相关的metadata,与被存储的属性相关连。property_cell_value
代表了该属性真正的值,而property_cell_type
代表了一个PropertyCell
的各种状态:
1 | // src/objects/property-details.h |
当一个属性刚刚创建并且被分配了一个值的时候,它的状态为kConstant
。这个状态说明该属性当前只被一样的值赋值,而只要后续的赋值还是一样的值,该状态就不会变化:
1 | // src/objects/objects.cc |
如果我们改变了重新赋值了一个不一样的值,但是该值的map和属性原来的值的map是一样的,那么PropertyCell
的状态又会就会变成kConstantType
:
1 | PropertyCellType PropertyCell::UpdatedType(Isolate* isolate, |
RemainsConstantType
函数会检查新值的map和旧值的map是一致的,并且都是stable的:
1 | static bool RemainsConstantType(Handle<PropertyCell> cell, |
从该函数可以看出,kConstantType
的语义似乎是cell value的map不变并且该map是stable的,但是这里存在了一点疏漏。改变PropertyCell value的map而不改变它的PropertyCellType
是很容易的,因为PropertyCellType
只有在该属性的值被重新赋值的时候才会改变。所以以下的代码展示了如何改变PropertyCell value的map而又保持PropertyCellType
为ConstantType
:
1 | var x = {a : 1}; //<------ property_cell.value(): {a : 1}, MapA, property_cell_type: Constant |
变量x并没有被重新赋值,所以它的PropertyCellType依然为ConstantType
。kConstantType这个名字如其所言:它保留了类型(即Javascript对象、Javascript数组等;没有办法在不重新分配的情况下改变其类型),但是它并没有对变量的map提供任何的保证。
The bug
有了这个背景,现在来看看该bug。在没打patch前,在jit代码中,当给一个cell type为kConstantType 的全局属性重新赋值时,即使该全局属性的map时unstable的,我们也可以赋值一个新的值给该全局属性:
1 | DCHECK_EQ(AccessMode::kStore, access_mode); |
然而,jit代码会加入check([1]处的DependOnGlobalProperty
和[2]处的CheckMaps
),这意味着仍然不能改变PropertyCell
的map:
1 | var x = {a : 1}; |
所以即使在foo函数被jit优化的时候x的map是unstable的,不触发deoptimization是不可能改变其map的,因为DependOnGlobalProperty
会禁止对x的重新赋值
(如果新值的map和x不一致就会触发deoptimization),而checkMap则保证o的map和x的map是一致的,如果不一致也会触发deoptimization。而且在编译的时候,如果x的map是unstbale的,就不会进入以下的代码,因为只有在x的map为stable的时候,jit代码才会对x的map做出假设:
1 | // Reduction JSNativeContextSpecialization::ReduceGlobalAccess function |
所以当foo函数被优化时x的map时unstable的,后面的优化代码在也不会对x的map做出任何的假设了:
1 | var x = {a : 1}; |
这或多或少的解释了为什么开发人员不觉得这是个security issue。
Breaking JIT with JIT
让我们从一些更高层次的角度来思考这个问题,在打patch前,对于jit代码,我们可以将一个对象赋值给一个map为unstable的PropertyCell
,并且同时保持其PropertyCellType
仍然是kConstantType
(前提是该对象的map要和旧值的map一致)。在打了patch之后,我们就不被允许这么做了。
1 | var x = {a : 1}; //<---- x has MapA |
只要o的map为MapA,在被优化过后的函数foo中,所有的check都会被pass,而且x会被重新赋值为o(也就是map又变回了MapA)。现在,将这一点与我们在前面看到的处理kConstantType 的行为结合在一起:
1 | var x = {a : 1}; //<---- x has MapA |
最后两行至关重要,有了被优化的foo函数,就可以做到将x的map从MapB变回MapA,既不用通过重新赋值的路径,也不会使MapB变得不稳定。
而在 Object Map, map stability, and map transitions 一节的讨论中,我们知道了有很多jit的代码做了一个假设:如果要改变一个变量的map,要么通过重新赋值,要么就会使得该变量的map变得unstbale。所以如果我们使用优化后的foo来将x的map从MapB变回MapA,那么jit代码做出的这些假设都将变得无效,这很有可能导致类型混淆。 事实上,这可以通过优化一个全局属性的load操作来实现:
1 | function bar() { |
在x.b = 1
之后,优化上面的bar函数,该函数会根据property_cell_value
的map为MapB进行优化,而且由于MapB的状态是stable的,在bar函数var z = x
之后的代码会假设z的map为MapB。如果我们用foo来使得x的map从MapB变回MapA,那么在jit优化过的bar函数中就会触发类型混淆
Debug
这个section是原文没有的,为了能理解前面作者的话,还是需要调试一番
首先是前面提到的一些对象:
一些对象
PropertyCell
这个应该是内部用来记录一个全局变量的一些具体信息:
1 | // src/objects/property-cell.tq |
PropertyDetails
是一个uint32_t的bitfield
PropertyCellType
1 | // A PropertyCell's property details contains a cell type that is meaningful if |
dependent code
这个表示与该对象(好像一般都是全局变量才有)相关的jit函数,如这个:
1 | // d8 debug |
foo函数访问了全局变量a,用%DebugPrint(a)查看a的depentent code:
1 | DebugPrint: 0x1b60081096ed: [JS_OBJECT_TYPE] |
depentent code 偏移为2的code对象和 –print-opt-code打印出来的code是一致的
这个应该就是我们在对全局变量进行访问时,如果我们改变了全局变量的property-cell,就会检查这个dependent code把相关的优化代码deopt
代码调试
用于测试的代码:
1 | function store(y) { |
运行是加上–print-opt-code,查看store和load的jit code:
1 | // store function jit code |
load函数的jit code:
1 | ... |
load函数一开始是有checkmap节点的,但是该节点在load elimination阶段被消除
而在bug修复的版本,store函数直接用的就是runtime函数了:
1 | // in bug fix version |
且加上–trace-deopt,可以看见在store函数执行后,load函数就会被deopt:
1 | ./d8 ~/v8_build/v8_src/v8/out.gn/x64.debug/poc.js --allow-natives-syntax --trace-deopt |
Exploiting the bug
前面讲述了关于该bug的概述,现在来看看如何利用它,这里还有几个细节需要整理一下。首先,因为一旦对一个unstable的map进行重新赋值的时候,其PropertyCellType
会马上从kConstantType
变成kMutable
,所以需要对优化代码进行计数(也就是执行到多少次,turbofan会生成该函数的jit code),使得x的map在foo函数被优化前一刻变得unstable,而不是在foo函数被优化后,否则x的PropertyCellType
会变成kMutable
。所以我们需要对foo做如下的操作:
1 | for (let i = 0; i < N + 1; i++) { |
上述代码的N就是foo函数被优化要执行的次数。通过一些记录和调试(用二分法和–print-opt-code,但是在release版本中确认有点麻烦),这并不难发现,而且N的值是确定的,在多次运行和不同版本的v8中是稳定的。
接下来要注意的是,对象{a : 1}和{a : 1, b : 2}之间的类型混淆并不是特别有用,因为在bar中最多只能访问x的属性a或b(因为bar函数假设x有MapB)。由于a两个map中的偏移是一样的,只有对b的访问会给我们一个越界的访问,虽然已经可以用于利用了,但还是不那么有效。
因此,我们需要对JavaScript数组类型进行类型混淆。因为JavaScript数组对不同类型(Element kind)的元素有不同大小的存储大小,SMI
数组(元素大小为4)和Double
数组(元素大小为8)之间的混淆将导致JavaScript数组的越界读写,而数组的越界读写将很容易用于利用。
关于JavaScript数组的一个注意事项是,由于数组的transitions在优化中经常发生,当数组在v8中被创建时,其已经根据其元素的类型( elements kind lattice
)被插入了一个transitions,这使得它们的map变成了unstable的状态,并且将它们存储到全局属性中会导致PropertyCellType
立即就变成了kMutable
。
1 | var x = new Array(1); |
正如你所看到的,x的map已经被插入了PACKED_DOUBLE_ELEMENTS的transitions。而这个问题可以通过给数组添加一个属性来轻松解决:
1 | var x = new Array(1); |
这给了x一个stable的map,同时还允许我们transitions到一个有double element
的数组:
1 | x[0] = 1.1; |
而旧的map(0x3c63082c7939)状态变为unstable:
1 | job 0x3c63082c7939 |
所以从现在开始,我将使用这些数组来代替。随着这些技术细节的解决,可以构建出一个实现数据越界读写的poc:
1 | function foo(b) { |
在优化oobRead和oobWrite前,x的map为MapB并且其Element kind为HOLEY_DOUBLE_ELEMENTS
。这意味着,例如,当在oobWrite中写到第24个元素(x[24])时,jit代码访问数组元素的偏移会基于HOLEY_DOUBLE_ELEMENTS
来计算(也就是8),所以例子中的偏移计算会是8*24
。然而在我们使用foo函数将x设置为arr时,arr的Element kind是HOLEY_SMI_ELEMENTS
类型,它的宽度为4,这意味着其backing store(也就是elements)总共才4 * 30
字节长,这比8 * 24
小得多。因此对偏移量8*24
处的写入将会是一个越界的写入。
利用该写入将一个数组的长度改为很大的值,就可以进行后续的利用了。