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#
