2021 天府杯的Chrome漏洞。当@exp-sky分配给我这个任务时,给我的要求就是独立完成+尽全力做到极致+没有时间和目标要求;感谢sky让我野蛮生长,也不断给我鼓励。什么时候能成为像sky这么厉害的人呢?(崇拜之情溢于言表)
CVE-2021-38001
漏洞简介
漏洞描述
Issue 1260577 于2021年10月16日天府杯提交,CVE
编号为CVE-2021-38001
,漏洞描述为Type confusion in V8 in Google Chrome prior to 95.0.4638.69 allowed a remote attacker to potentially exploit heap corruption via a crafted HTML page.
。可以知道该漏洞是位于Chrome
的JS
处理引擎V8
中的类型混淆漏洞。
现该Issue
在bugs.chromium
上还未有公开的文档,但是根据查阅对应版本的patch文件和描述[super ic] Fix receiver vs lookup start object confusion related to module exports
,可知漏洞点位于src/ic/ic.cc和src/ic/accessor-assembler.cc两份代码中,漏洞的主要原因是在属性访问指令LoadSuperIC
创建IC handler
时处理的对象为holder
,而在加载IC handler
使用时处理的对象为p->receiver()
,由于这两个对象的类型可以不一致,导致了类型混淆漏洞。
找到两份该漏洞的公开POC
,分别来自:https://github.com/vngkv123/aSiagaming/tree/master/Chrome-v8-1260577 和 https://github.com/Peterpan0927/TFC-Chrome-v8-bug-CVE-2021-38001-poc 。第一份包含了完整的漏洞利用,第二份仅能触发崩溃。
根据该漏洞描述,能够找到类似的漏洞:CVE-2021-30517,该漏洞也存在于IC
创建和处理流程中。对于call_handler
类型的IC
创建时传入的参数为p->lookup_start_object()
,而IC
处理时传入的参数为p->receiver()
,最终导致了类型混淆漏洞。
影响范围
根据漏洞描述,该漏洞影响chrome
在95.0.4638.69
及之前的版本,即影响chromium
在 66a46a9e6b68454d656207831ca3a6eb7332c7a4及之前的版本,受影响的V8
为b5fa92428c9d4516ebdc72643ea980d8bde8f987及之前的版本。
该漏洞的引入,在chromium_source里的blame查看该代码文件的修改历史记录,可以发现是在2017-8-26
的24b8877中为了实现Speed up access to module exports
而引入,在该commit
第一次在src/ic/accessor-assembler.cc
中的AccessorAssembler::HandleLoadICSmiHandlerCase
中引入了漏洞代码。
而漏洞的修复是在2021-10-18
的e4dba97,在这两个版本之间的代码都受该漏洞的影响。
环境搭建
这里我们选择调试的V8
版本为b5fa92428c9d4516ebdc72643ea980d8bde8f987
,为了方便在release
版本下调试,在编译前使用如下命令在args.gn
中加入环境变量:
1 | gn gen out.gn/x64.release/ --args='is_debug = false v8_enable_backtrace = true v8_enable_disassembler = true v8_enable_object_print = true v8_enable_verify_heap = true symbol_level=2 target_cpu = "x64" v8_untrusted_code_mitigations = false' |
基础知识
prototype原型链
JavaScript 只有一种结构:对象,当存在继承时,每个实例对象(object
)都有一个私有属性(称之为__proto__
)指向它的构造函数的原型对象(prototype
)。该原型对象也有一个自己的原型对象(__proto__
),层层向上直到一个对象的原型对象为 null
。根据定义,null
没有原型,并作为这个原型链中的最后一个环节。
几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object
的实例。
原型链具有继承属性,寻找某个成员变量时,会对原型链进行遍历查找。如下代码:
1 | function doSomething(){} |
如上所示, doSomeInstancing
中的__proto__
是 doSomething.prototype
。当访问doSomeInstancing
中的一个属性,首先会查看doSomeInstancing
中是否存在这个属性。
如果 doSomeInstancing
不包含属性信息, 那么就会在 doSomeInstancing
的 __proto__
中进行查找(同 doSomething.prototype
)。 如属性在 doSomeInstancing
的 __proto__
中查找到,则使用 doSomeInstancing
中 __proto__
的属性。如果 doSomeInstancing
中 __proto__
不具有该属性,同理,则检查doSomeInstancing
的 __proto__
的 __proto__
是否具有该属性。
所以,可以通过修改对象的__proto__
,来为其增加一个继承的父类。
深入了解,可参考这篇文章:继承与原型链
Inline cache
Inline cache
是V8
中引入的用于提高属性访问效率的优化机制,其会存储去何处寻找一个对象的属性的相关信息,来减少属性遍历查找的的开销。
用如下示例代码,简单说明其实现的思想:
1 | function getX(point) { |
这句代码,会对point
对象的x
属性进行访问。那么一个简单的遍历查找属性的方法,可以总结思路1如下:
- 从对象的
Map
中获得instance_descriptors
。 instance_descriptors
遍历获取到key
存储的位置(在对象内还是在properties
中)。- 调用特定的方法读取属性值。
但是,如果对这行代码多次调用执行时,上面的处理思路因为需要不断遍历获取key
值,所以会消耗大量的时间。这里如果我们用一个slot
将属性 x
的存取位置和其对应的JSObject<Map>
存储下来,就可以在多次执行时减少遍历的消耗。
那么,此时的思路2可以总结为:
- 判断对象的
Map
与之前缓存的是否相同。 - 如相同,直接从缓存的位置读取。
- 如不同,调用第一种思路1通过遍历获取。
但是,如果存在这个对象的Map
在不断变化,那么我们思路2中其实 只会不断执行第3步,这会导致我们的缓存命中率降低,优化失效,例如如下代码:
1 | for (var i = 0; i < 10000; i++) { |
所以,我们可以将缓存由单一存储改为哈希表模式,将其遇到的 Map
及其属性值存取位置全部缓存下来。这样即使Map
值在不断变化,我们也能够迅速找到对应属性。
这里V8
会将上述的map
和缓存位置handler
存储在一个FeedbackSlotCache
中,之后相同函数对同一个名称的属性进行访问时,就会共享这些Feedback
。
这里我们把 point.x 称之为一个 callsite,把 “x” 称之为一条名为 “x” 的消息,而实际执行的 point (无论是 {x : 1},还是 {x : 1, y : 2})称之为这个 callsite 的 receiver。
总结:JavaScript
中很多操作的执行过程异常复杂,但对于特定调用(callsite
) 来说receiver
类型(Map
)的变化一般很小,V8
采用内联缓存(Inline Caches
,简称IC
)来缓存调用的实现以优化这些操作的执行过程。
IC
根据接受到的消息类型,而有了不同的状体:
当 IC 刚被创建时为初态(没有调用过),没有接收到任何一种类型信息。
当被调用时:
- 如果接收到 1 种类型信息会迁移到单态模式(MONOMORPHIC)。
- 收到大于 1 种小于 4 种类型信息会迁移到多态模式(POLYMORPHIC)。
- 当接收到大于 4 种类型信息时会迁移到复态模式(MEGAMORPHIC)。
深入了解,可参考这篇文章:JavaScript engine fundamentals: Shapes and Inline Caches
Named property load 处理
以一个示例代码,来说明发生Named property load
属性访问时,整个IC
的生成和处理流程。
1 | let o = {x: 1, y: 2}; |
Bytecode & Feedback
首先输出这个示例的 bytecode
,如下所示:
1 | 0x1d260829330e @ 0 : 7b 00 00 29 CreateObjectLiteral [0], [0], #41 |
从上面的字节码处理中,可以看到其会执行LdaNameProperty
属性加载操作,这里LdaNamedProperty r1, [1], [1]
字节码的含义是:
LdaNamedProperty
将 r1
的命名属性加载到累加器中。ri
指向 incrementX()
的第 i
个参数。在这个例子中,我们在 r1
上查找一个命名属性,这是 incrementX()
的第二个参数。该属性名由常量 1
确定。LdaNamedProperty
使用 1
在单独的表中查找名称:
1 | Constant pool (size = 2) |
可以看到,1
映射到了 x
。因此这行字节码的意思是加载 o.x
。
那么值为 1
的操作数是干什么的呢? 它是函数 incrementX()
的反馈向量feedback
的索引。反馈向量包含用于IC
性能优化的 runtime
信息,其本质就是上文中提到的IC
存储的信息。
这里提到的feedback
,可以用一个如下示例说明
1 | function f(o) { |
这里只关注输出的feedback
信息如下:
1 | - feedback vector: 0x10a30829354d: [FeedbackVector] in OldSpace |
在没有执行函数f
时,可以看到此时的feedback
状态为UNINTIALIZED
,这和我们上面说的IC
的5个状态符合。
接着,执行了两次f
函数,feedback
状态变为了MONOMORPHIC
,且有了两条slot
,slot[0]
存储了指向函数f
的Map
信息(用于比较当前对象是否发生改变);slot[1]
存储的是的handler
(存储用于辅助属性查找的位置)
LdaNamedProperty
接着继续说LdaNamedProperty
函数,这个操作对应到源码./src/interpreter/interpreter-generator.cc
中的如下代码,该代码是V8
为LdaNamedProperty
的实现构建的节点图,最终会在编译V8
的过程中生成汇编代码。所以我们想要直接去对该函数下载地址断点,是不成功的
1 | // LdaNamedProperty <object> <name_index> <slot> |
LdaNamedProperty
函数首先会获取此时访问的recv
和laze_name
,在示例代码众,这里的recv
是o
,lazy_name
是x
。接着会去调用LoadIC_BytecodeHandler
来真正处理属性访问。
LoadIC_BytecodeHandler
1 | void AccessorAssembler::LoadIC_BytecodeHandler(const LazyLoadICParameters* p, |
该函数的执行逻辑如下:
1、 判断p->vector
是否有feedbackvector
,如果没有feedback
,则进入no_feedback
流程;
2、 从map
中获取第一个遍历的map
结构体lookup_start_object_map
,接着判断该结构是否过期(表示该对象进行了属性的增减),如果是则进入miss
流程,若不是则顺序执行
3、 接着进入快速路径处理流程,首先进行monomorphic
状态的检查,这里是根据feedbackvector
中存取的信息来判断是否是monomorphic
状态,如果是则从vector
中取出handler
并跳到if_handler
中,
4、 如果不是monomorphic
,则进入polymorphic
状态检查,会判断feedback
中是否是强引用且存储了WeakFixedArrayMap
,是的话代表目前IC
的状态是Polymorphic
, 否则跳转到LOADIC_miss
或stub_call
5、 如果进入了stub_call
代表该次调用没有IC
,就调用Builtin kLoadIC_Noninlined
6、 如果进入了no_feedback
,会先调用LoadIC_NoFeedback
7、 如果进入了miss
,会调用LoadIC_Miss
,这里传入的是p->receiver()
这里面提到了receiver
,是指发生call
调用时,被作为this
传递的对象。lookup_start_object
是指进行原型查找时的第一个原型,可以是其本属性,也可以是其原型链上的其他对象。
当我们第一次对该属性进行访问且IC
机制已经启动时,此时Feedback
还没有建立,那么会跳入LoadIC_NoFeedback
去创建feedback
。那么接下来分析Feedback
创建流程:
LoadIC_NoFeedback
LoadIC_NoFeedback
这里首先关注LoadIC_NoFeedback
的处理流程,首先进入LoadIC_NoFeedback
函数,如下所示:
1 | void AccessorAssembler::LoadIC_NoFeedback(const LoadICParameters* p, |
LoadIC_NoFeedback
函数的处理流程如下:
1、 首先获得第一个对象,然后判断该对象是否为smi
,如果是进入miss
阶段
2、 接着判断对象map
是否过期,即判断该对象是否有增删变量,如果是也进入miss
阶段
3、 接着根据map
获取实例类型instance_type
,然后判断该对象是否为JSFunction
类型、判断其p->name
是否为原型字符,判断是否要在运行时去遍历查找prototype
,如果不是则进入not_function_prototype
;
4、 随后会去执行GenericPropertyLoad
函数进行相关检查,最后也会去执行LoadNoFeedbackIC_Miss
函数
这个函数的主要目的,是判断当前对象的Map
是否被修改,如果被修改了也意味着需要去为新Map
对象创建IC
。
Runtime_LoadNoFeedbackIC_Miss
1 | RUNTIME_FUNCTION(Runtime_LoadNoFeedbackIC_Miss) { |
流程如下:
1、 获取传入的receiver
、key
值,然后创建新的feedback
向量和索引
2、 调用ic
获取当前ic
的状态,随后调用UpdateState
更新IC
的状态
3、 最后调用ic.Load
函数去进行ic
设置和属性获取
这个函数的主要目的就是更新当前IC
的状态,然后去创建为当前Map
创建Feedback
反馈向量
ic.Load
1 | //调用 LoadIC |
总结:
1、 首先会对map
进行检测,如果map
改变,则调用UpdateState
进行状态更新
2、 随后会对map->name
进行检查,检查是否为 private
3、 然后会进入UpdateCaches
,对IC
进行更新
该函数的主要目的是调用UpdateCaches
更新整个缓存。
UpdateCaches
接着在看UpdateCaches
1 | void LoadIC::UpdateCaches(LookupIterator* lookup) { |
总结:
1、 首先对lookup
进行状态检测,并判断需要处理的IC
是否为全局IC
2、 最终会进入ComputeHandler
中,去生成handler
ComputeHandler
1 | Handle<Object> LoadIC::ComputeHandler(LookupIterator* lookup) { |
总结:
1、 开始也会对lookup_start_object
进行判断,随后根据lookup->state
的状态进行对应的处理;
2、 这里由于是对属性进行访问,所以会进入ACCESSOR
状态,可以看到这里是对hodler
进行处理。
3、 在ACCESSOR
中,会根据holder
类型,来计算属性访问的位置handler
。
最终,为示例代码的属性访问生成了一条Feedback
,如下所示:
1 | slot #1 LoadProperty MONOMORPHIC { |
这里handler
的是怎么计算的,以及其含义如何,在下面调试分析时再进一步说明。
Monomorphic状态处理
根据最开始LoadIC_BytecodeHandler
的处理流程,当我们的对某个属性的访问超过2次后,就会进入Monomorphic
状态处理流程。
1 | Label try_polymorphic(this), if_handler(this, &var_handler); |
可以看到其首先通过TryMonomorphicCase
函数去获取此时feedback
状态是否为monomorphic
,如果是则设置if_handler
。
然后进入HandleLoadICHandlerCase
处理。
HandleLoadICHandlerCase
1 | void AccessorAssembler::HandleLoadICHandlerCase( |
总结:
1、首先判断handler
类型,判断其是否为smi
或者LoadICProto
类型或者call_handler
类型
2、根据handler
类型进入不同的处理流程,随后去调用相应的处理函数。
handler
的类型,可以有以下四种:
- Smi
- LoadHandler / StoreHandler object
- weak pointer to a PropertyCell (for global loads)
- code object
Super 超类
supper
超类是用来获取对象的父类里的属性或者函数。如下例所示:
1 | class A { } |
这里的原型链如下所示:
1 | b -> |
这里由于在m
函数中定义了超类super.x
,那么这里开始查找属性x
时会从当前调用函数的对象的__proto__
开始查找,也就是现在直接从对象B.prototype.__proto__
开始向上查找,也即从A.prototype
开始查找,最终x
在对象A
中找到。
总结:对于超类的属性访问,遍历查找的起点lookup start object
是当前主对象的__proto__
(上例中为对象A),receiver
是当前发生超类访问的函数的对象(上例中为对象b)。
并且为了提高超类属性访问的效率,V8
按照前面所述正常的属性访问的逻辑,实现了LdaNamedPropertyFromSuper
方法,关于该方法与正常属性访问的异同,在下面漏洞分析时具体分析。
关于超类的更细致的知识,可以参考这篇文章Super fast super property access
漏洞原因
POC
1 | //1.mjs |
这里主要说明一下trigger
函数的流程:
1、 C.prototype.__proto__ = zz;
将C
类的原型链指向了zz
;
2、C.prototype.__proto__.__proto__ = module
相当于将zz
的原型链指向了exmodule
3、let c = new C();
会将c.__proto__
指向C.prototype
4、随后是对c
增加了一些成员变量;
5、let res = c.m();
会去执行C
中的m()
成员函数,return super.y;
涉及到属性查找,其会从原型链向上找到主对象zz.prototype
,查找顺序如下:
1 | c.prototype.__proto__ = C.prototype |
6、 这里由于这里是返回super.y
,结合前面基础知识中提到的超类,可知遍历查找y
的值时,会跳过当前对象c
的原型,会直接从zz
对象中起始向前查找。这里由于zz
没有y
变量,所以其会继续向上遍历查找到exmodule
中,返回y
的对象。
这里如果多次执行trigger
函数,那么则会对trigger
中的各项指令进行优化,其中就包括对其中IC
的更新。这里经过测试该POC
中c.m()
函数经过多次执行后,feedback
中状态变为如下所示:
1 | module: |
这里我们可看到对于c.m()
代码的feedback
,经过多次执行后,其状态仍然为MONOMORPHIC
。那么这条指令处理时,会进入我们上面所讲的MONOMORPHIC
流程。而slot[0]
的handler
为smi
直接运行exp
会出现如下报错:
1 | # Fatal error in ../../src/objects/object-type.cc, line 48 |
从报错信息中可以看到这里发生的错误是期望处理一个Module
类型的对象,但是却得到了一个smi
类型的整数0x21212121
。而这里的整数能够明显看出是上述POC
中传入的c.x0
的值。根据栈回溯也能够看到报错发生在LdaNamedPropertyFromSuperHandler
中调用LoadSuperIC
时对对象进行了类型检查CheckObjectType
。这里根据CheckObjectType
函数传入的第一个参数raw_value=50935128801858
,该地址等于$1 = 0x2e5342424242
,该值刚好为我们写入的c.x0
的值加上一个高位的基地址指针。
可以知道,发生错误的大概原因就是执行LoadSuperIC
时,传入的参数本应该是一个Module
类型的对象,但是此时传入的却是一个smi
整数,导致最终取值时发生了错误。
而且可以看到是在处理LoadPropoerty
操作时,引发了这个错误。通过上面对feedback
的输出和查看字节码,可以看到这个POC
中有好几处LoadProperty
操作。我们可以通过在POC
每一处LoadProperty
操作前进行输出,然后来判断是具体是哪一处出现了错误。这里,经过我的测试,是在如下代码处会报错:
1 | let res = c.m(); |
调试分析及源码分析
接下来,动态调试分析。首先在执行触发漏洞代码前,输出c
和exmodule
的对象结构,如下所示:
1 | //exmodule object |
首先可以看到mexodule
的类型为JSModuleNamespace
,c
的对象类型为JS_OBJECT_TYPE
。且此时c
的property
中存储了我们的赋值x0
到x4
。这里看一下c
和exmodule
的内存布局如下:
1 | //c |
可以看到我们传入的x0
的值被存储到了偏移0x14
的位置。而在exmodule
中偏移0x14
的位置存储的刚好是module
结构。结合我们前面的崩溃信息,可以知道这里的LoadSuperIC
函数应该传入的对象是exmodule
,但是却传入了c
对象,导致最终根据偏移去取module
结构地址时,却取到了c.x0
的值,而这个值是一个整数,最终报错。
总结:这个漏洞应该是在发生属性访问LoadProperty
时,由于c
这个对象类型和exmodule
这个对象类型发生了混淆,导致最终处理报错。
前面大概说明了漏洞原因,接着需要分析整体的漏洞触发流程。
字节码
这里看一下POC
生成的字节码,主要看漏洞触发函数指令生成的字节码:
1 | [generated bytecode for function: m (0x3b24081d3825 <SharedFunctionInfo m>)] |
基于基础知识,我们知道当发生属性访问操作时,会先由LdaNamedPropertyFromSuper
汇编码进入kLoadSuperIC
函数进行处理。在LoadSuperIC
中,会根据处理对象的的feedback_vector
来执行不同的处理流程。
这里需要注意,由于我们前面提到的超类的原因,所以在处理漏洞指令时,并不会去按照前面基础知识所提的去执行LoadIC
流程,而是会去执行LoadSuperIC
流程。
1 | // LdaNamedPropertyFromSuper <receiver> <name_index> <slot> |
No_Feedback
当第一次执行POC
中的trigger
函数时,由于此时所有指令都是第一次执行,所以此时feedback
向量全部为UNINITIALIZED
状态。所以此时会进入no_feedback
流程。
1 | //LoadSuperIC |
这里的检查方式即检查此时是否有feedback_vector
,如果没有则跳入kLoadIC_NoFeedback
处理流程。
LoadSuperIC_NoFeedback
接着进入LoadIC_NoFeedback
函数,重点分析一下在执行上面的一条指令时其对应的IC
会存储什么值。
1 | void AccessorAssembler::LoadSuperIC_NoFeedback(const LoadICParameters* p) { |
LoadSuperIC_NoFeedback
函数刚开始也会通过GenericPropertyLoad
对map
类型进行一些检查判断。随后调用LoadWithReceiverNoFeedbackIC_Miss
函数去为当前IC
创建feedback
向量。
kLoadWithReceiverNoFeedbackIC_Miss
在LoadNofFeedbackIC_Miss
函数中
1 | RUNTIME_FUNCTION(Runtime_LoadWithReceiverNoFeedbackIC_Miss) { |
在这个函数中开始创建新的feedback
向量,然后会获取当前ic
,然后更新ic
状态,随后调用ic.Load
函数去为新的feedback
向量写入值。
Ic.Load
进入这一步之后,就和我们前面基础知识分析的流程很类似了。这里结合调试分析的内容来说明,IC
创建的过程。
进入ic.Load
函数,重点关注此时传入的参数。
1 | //调用 LoadIC |
可以看到传入的receiver
此时为我们poc
中的c
对象为JS_OBJECT_TYPE
。object
对象为c
上级原型链zz
,name
为当前需要处理的变量名y
1 | pwndbg> jh receiver |
由于Lazy_feedback_allocation分配机制,当初次没有feedback
时,并不会立即去分配feedback
,而是会在执行多次之后才会去分配feedback
向量。
这里经过测试,在执行9次之后,满足了Lazy feedback allocation
机制之后,此时就会真正去启动ic
,最终会进入Updatecaches
函数,去更新cache
缓存,在UpdateCaches
最终会进入ComputeHandler
处理流程,去获得真正的handler
处理结构。
ComputeHandler
这里首先解释一下holder
和receiver
之间的关系,当发生属性访问修改时,receiver
是发起并接受结果的对象,holder
是被调用处理的对象。
以漏洞代码为例,此处c
是作为this
传递给访问器调用的对象,而访问m
函数,则C
对象就是holder
1 | res = c.m(); |
而紧接着进入m
函数执行中,发生了超类属性访问,此时receiver
仍然是this
对象即c
,而holder
变为了父类即我们引入的exmodule
。
1 | m() { |
这里以super.y
这句代码来调试说明,程序会进入ComputeHandler
中ACCESSOR
分支:
1 | case LookupIterator::ACCESSOR: { |
ComputeHandler
主要流程就是根据处理类型,去创建对应的LoadHandler
对象。首先ComputeHandler
函数传入的参数receiver
是c
对象,holder
是exmodule
这个对象,其类型为JSModuleNamespace
。
1 | pwndbg> jh receiver //c |
随后会进入LookupIterator::ACCESSOR
处理流程。在这里会将holder
对象传入作为export
的参数。然后最终返回一个LoadHandler::LoadModuleExport
处理指针,这里可以看到对应的handler
是一个smi_handler
。如下所示:
1 | pwndbg> jh handler |
Handler 产生
这里为什么会生成smi_handler
,这个数字是怎么来的,我们进入LoadModuleExport
函数调试一下:
1 | 106 Handle<Smi> LoadHandler::LoadModuleExport(Isolate* isolate, int index) { |
可以看到,在LoadModuleExport
函数中会对kModuleExport
和index
调用encode
函数进行加密,然后将结果进行与操作。
这里的kModuleExport
表示此时handler
的类型,这里的index
表示此时这个属性在哈希表中获取的索引,这里需要通过哈希表查找才能获得该值。
最后将smi
结果调用handler
进行封装,得到最终的一个smi_handler
。
总结:一个smi_handler
是将handler
的类型和属性在value
中的偏移进行加密封装之后产生的。
最终为super.y
这个超类属性访问生成的feedback
如下:
1 | DebugPrint: 0x2a6e0804a409: [Function] |
No_Feedback 总结:
在起始的8次循环中,由于Lazy Feedback allocation
机制,并不会启动IC
;
在第9次循环中,会进入No_Feedback
流程,去分配feedback
,并设置handler
。
这里对于漏洞代码,首先传入的reciver
是c
对象,但是设置的feedback
对象却是exmodule
这个JSModuleNamespace
对象。最终为其设置的handler
是LoadHandler::LoadModuleExport
类型。
Monomorphic单态处理
前面完成No_Feedback
流程之后,在第10次循环时。在LoadSuperIC
中就会进入Monomorphic
处理流程。
1 | // The lookup start object cannot be a SMI, since it's the home object's |
进入单态处理时,此时feedback
中已经有数据,其包含一个map
指针(其指向已知映射的指针)和一个处理程序handler
。map
映射指针仅用作“索引”(feedback
处理时,并不会查看map
指针其内容,只将此时处理的对象map
与feedback
存储的map
地址相比较)。处理程序handler
存储的是从一个shape
对象快速加载属性的偏移。
当首次进入Monomorphic
单态处理流程时,在TryMonomorphicCase
函数中会比较lookup_start_object_map
的地址指针与前述feedback
向量中存储的map
指针进行比较。然后其会根据比较结果,判断是否有handler
,设置if_handler
标识位,并将找到的handler
处理程序通过var_handler
返回。
随后进入HandleLoadICHandlerCase
去执行相应的handelr
。
HandleLoadICHandlerCase
1 | void AccessorAssembler::HandleLoadICHandlerCase( |
在HandleLoadICHandlerCase
中,会判断handler
类型,这里会进入if_smi_handler
分支,执行HandleLoadICSmiHandlerCase
函数。在该函数中会调用DecodeWord
对handler
进行解码,得到LoadHandler
的类型,这里的类型解码应该为LoadModuleExport
,且访问模式为kLoad
。所以该函数前面对于Element
的处理都会跳过,直接进入最后的Load Property
处理流程。
然后在该函数中最终会进入HandleLoadICSmiHandlerLoadNamedCase
函数。
1 | void AccessorAssembler::HandleLoadICSmiHandlerCase( |
HandleLoadICSmiHandlerLoadNamedCase
1 | //vul function |
在这个函数中可以看到对应module export
的处理流程。首先调用DecodeWord
对handler
解密获得index
,这个index
将会用于从exports
中获取cell
的位置,这里就不需要再查找哈希表了,极大的节省了时间。
IC Feedback handler总结
这里对Feedback再做一个总结。在Feedback创建阶段,会将此时执行的访问类型type和属性在value中的偏移index调用encode函数进行加密然后封装为一个handler类型。在我们再次执行该属性访问时,会直接从handler中解密获得需要执行访问类型type来决定进入不同的处流流程,最后解密获得index来直接从value中获得属性。
接着上面的漏洞分析:
该函数接着会调用LoadObjectField
函数从p->receiver()
对象中获取module
属性。
注意:这里传入的对象为p->receiver
,而我们前面讲解No_feedback
流程时提到创建handler
时是从holder
中取出module
属性,对应到POC
中hodler
是exmodule
这个变量,其为JSModuleNamespace
对象。而这里的p->receiver
对象却是c
变量,其为JSObject_TYPE
。所以发生了一个JSModuleNamespace
和JSObject_TYPE
的类型混淆,最终调用LoadObjectField
去获取module
属性时,由于JSObject_TYPE
中对应module
属性偏移的地方存储的是我们自己设置的c.x0 = 0x42424242
的值,最终引发错误。
报错如下所示,此时R8
的地址低4字节为我们设置的c.x0
。
1 | RAX 0x0 |
漏洞原因总结
1、 当对JSModuleNamespace
对象的属性访问发生时,经过多次执行满足Lazy feedback allocation
机制后,IC
机制启动,此时会为每个属性访问建立feedback
向量。其会为JSModuleNamespace
对象建立feedback
,其handler
为LoadHandler::LoadModuleExport
2、 当再次发生对JSModuleNamespace
对象的属性访问时,程序会进入mononorphicase
处理流程,会根据每个feedback
中handler
的类型,进入不同的处理流程。对于JSModuleNamespace
对象的handler
为LoadModuleExport
类型,程序会进入export module
处理流程。在这个处理流程中,程序会调用LoadObjectField
去获取对象的module
属性,但是此时传入的参数却是p->receiver()
对象。
3、 由于我们可以将一个JSModuleNamespace
对象通过设置__proto__
属性的方式放入到一个JSObject_TYPE
对象的原型链中。当进入上述第1步时,可以使得holder
为JSModuleNamespace
对象。当进入第2步时,可以使得此时的p->receiver()
为JSObject_TYPE
,最终在此处形成了JSObject_TYPE
被当作JSModuleNamespace
的类型混淆。
漏洞利用
对象伪造
这里想要实现漏洞利用首先需要通过漏洞构造一个fake object
。前面的漏洞测试,可以知道,可以向任意地址分配一个object
出来,那么这里的难题就是在一个确定的地址处布置上合适的对象结构,使得我们能够通过漏洞先成功返回一个对象地址。
指针压缩 & 堆喷
由于在新的v8
(2020.3.30之后的版本)中引入了指针压缩技术Pointer Compression in V8,用以减少内存的消耗,其核心思想是:对于64的指针不存储其绝对地址而是存储其相对于某个基址的偏移,基址存储在某个寄存器中。
指针压缩给我们的利用方法带来的困难是难以直接泄漏出完整的地址,但是也给我们的利用带来的了便利——我们在覆盖修改某些指针地址时只需要修改低位的4字节,相当于减小了地址随机化的范围。
由于指针压缩的原因,我们堆喷的范围被限制在了4GB
的范围内,所以这里堆喷的空间是可以接受的。而且,经过测试,在Ubuntu20.04
下的v8
如果对于array
的length
设置为一个大值,那么其生成的array.elements
的地址低4字节是固定的。这里使用测试代码如下:
1 | var large_arr = [1.1]; |
这里测试,large_arr.elements
地址如下为0x33ba08242119
,其低4字节不会改变。
1 | DebugPrint: 0x33ba0804a551: [JSArray] |
然后经过多次测试,在V8
启动了压缩指针的版本中,对于同一个版本在同样的环境下分配同一对象,多次启动d8
其创建的对象Map
和Elements
的低4字节地址都是固定的。然后在Chrome
上也经过测试,发现其map
低4字节仍然不变,当然具体值与d8
中有区别。
这里的原因根据这篇文章中有提到的Custom startup snapshots机制,猜测可能是由于这个heap snapshot的优化,它使用一个现成的“快照”来初始化V8
的堆,以此来实现加快V8
的启动。这个“快照”包含所有常用对象结构,当我们每次启动V8
时都相当于加载这个快照来迅速完成启动,而由于快照的存储通常是一个二进制形式,那么在这个快照中存储的堆结构地址偏移就不会改变,则创建的对象在快照存储的偏移也就是固定的。
Fake Object
所以,我们可以将上面POC
中c.x0
的值设置为arr.elements
的地址范围,这里选择0x08248000
作为伪造的fake_object
对象
但是,这里还必须保证我们指向的fake_object
地址必须首先满足module
类型的查找链,所以这里先看看每个地址查找的偏移,先通过JSM
这里会通过module
找到exports
属性,偏移为0x4
1 | wndbg> job 0x2385081d3da9 |
接着会通过exports
找到y
的 Cell
对象,偏移为0x20
:
1 | pwndbg> job 0x2385086446f9 |
接着通过Cell
对象,找到value
,偏移为0x4
1 | pwndbg> job 0x2385081d3df9 |
最终取得了value
,并将其对象地址返回。
我们这里堆喷的内容如下:
1 | function heapspray(){ |
所以,主要是保证偏移0x08248000
的内存如下,我们伪造的fake_object = 0x08248020
:
1 | pwndbg> tel 0x238508248000 |
首先通过偏移0x3
为0x238508248003
得到fake_exports
地址为0x238508248001
1 | pwndbg> tel 0x238508248000+3 |
然后偏移0x1f
得到fake_cell
为0x238508248025
1 | pwndbg> tel 0x238508248001+0x1f |
然后偏移0x3
得到fake_value
为0x238508248025
1 | pwndbg> tel 0x238508248025+3 |
自此,我们成功返回了一个伪造的value
对象,起始地址为 0x238508248025
。
接下来,我们需要为这个对象写上合法的map
值和property
以及elements
地址。
这里调试的信息如下所示,此时r8
即为我们伪造的fake_object
地址0x238508248000
,然后通过偏移开始得到fake_value
的地址
1 | RAX 0x0 |
由下可以看到此时,会将我们伪造的fake_value
返回0x238508248025
1 | RAX 0x238508248025 ◂— 0x108248025082481 |
arbitray read/write & addressOf
经过前面的伪造,此时我们成功将fake_value
的值指向了低4字节为0x08248025
的地址。然后我们立即执行如下代码,如下所示:
1 | //get fake_value object |
首先对fake_value
进行了一个赋值,那么此时则会为这个fake_value
创建一个element
属性。而由于整个fake_value
的数据都位于large_arr.elements
中。所以,我们可以通过查看large_arr.elements
的值,来获得fake_value.elements
的地址。
然后,我们紧接着创建了victim
数组和leaked
对象数组。那么此时的victim
地址以及ab
的地址就会和fake_value.elements
的地址相邻,且偏移固定。那么此时通过将fake_value.elements
地址加上固定偏移,即可获得victim
对象的地址,如下所示:
1 | //change fake_value.element point to victim |
那么此时,fake_value.elements
就指向了victim
对象地址,通过对fake_value
的读写,就能读写victim
对象。那么,就能实现任意读写:
1 | function arb_read(address){ |
而且由于victim
对象和leaked.elements
地址相邻,通过读取fake_value
的值,也就能读取leakd
的值。那么最终实现了addressOf
:
1 | function addrof(object) { |
getshell
这里最终,仍然是通过addressOf
和read
得到wasm
的rwx
内存的地址。
然后劫持DataView
的backing_store
成员为wasm_addr
,向其中写入shellcode
再调用wasm
函数即可触发恶意代码执行,弹出计算器
1 | //1.mjs |
1 | //exp.mjs |
漏洞修复
漏洞修复了两处代码,一处是AccessorAssembler::HandleLoadICSmiHandlerLoadNamedCase
函数中,对于module export
处理时,调用LoadObjectField
函数获取module
对象时,传入的参数修复为了holder
,与创建handler
时传入的参数都保持为了holder
对象。
1 | @@ -836,8 +836,8 @@ void AccessorAssembler::HandleLoadICSmiHandlerLoadNamedCase( |
第2处是LoadIC::ComputeHandler
函数中,将原本简单的返回LoadModuleExport
。增加了一个判断,通过holder_is_lookup_start_object
判断holder
与lookup_start_object
对象是否相同,如果相同则仍然返回LoadModuleExport
;如果不相同,则返回LoadFromPrototype
。
1 | @@ -996,7 +996,13 @@ Handle<Object> LoadIC::ComputeHandler(LookupIterator* lookup) { |
这里第一处的修复直接了当,直接将原有的类型混淆p->receiver()
和holder
给修复成功。
而第二处的修复,则是当存在多层原型链遍历查找时,为了防止在feedback
创建时的holder
和使用时的holder
不一致。
这里结合调试来看看其对现在的POC
的防范,可以看到此时生成的smi_handler
与原漏洞版本的handler
一致。
1 | pwndbg> jh smi_handler |
但是由于holder
和lookup_start_object
对象并不一致,所以,其最终会返回LoadFromPrototype
对象,如下所示:
1 | pwndbg> jh handler |
可以看到此时返回的对象,不仅有smi_handler
的值,还包含了需要处理的holder
对象0x27640810d2ed
。通过将handler
进行如上封装后,相比于简单的smi_handler
,这里有了对象map
和cell
地址,那么在处理handler
时,就可以通过比较此时处理的对象是否一致来防止两次holder
前后不一致的问题。
总结
这个漏洞的发现,初步猜测是作者根据CVE-2021-30517
的漏洞成因,对LoadIC
相关代码进行深入审计所发现。
IC
的处理机制是V8
为了加快属性访问,所引入一种优化方法。其会为每一个对象的属性访问,都建立feedback
反馈向量,并在后续访问时直接通过feedback
来减少遍历属性所带来的开销。
在一个对象的属性访问过程中,由于继承的原因,会涉及到对父类进行遍历查找。这使得在对一个属性的访问处理过程中,会涉及到对多个对象的操作。V8
将这些对象都进行了统一的定义和命名,使用receiver
来作为home object
即当前调用的发起者,使用lookup_start_object
来作为原型链遍历的起点,使用holder
是真正处理该属性访问的对象。
而receiver
、lookup_start_object
和holder
这三个对象,很容易通过修改原型链的方式使得他们三个分别指向不同类型的对象。
但是,由于这些对象的定义理解不清,导致开发者在feedback
创建过程中和使用过程中,所处理的对象很容易不一致,最终造成了类型混淆漏洞。比如这个洞是由于将receiver
和holder
的混淆导致,而CVE-2021-30517
时由于将receiver
和lookup_start_object
混淆所导致。
漏洞的patch
仅仅是将这个漏洞的产生原因进行了修补,并没有总体解决这三个对象使用错误所带来的问题,而且IC
处理除了本文提到的Named property Load
,还包括keyed property Load
,这两种形式在实现方法上也有些区别。所以,这种由于将receiver
、holder
和lookup_start_object
对象前后使用矛盾的漏洞,可能仍然存在。
参考文献
V8 deconfuser part 5 - Data-driven ICs
[CVE-2021-38001](
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/12/02/cve-2021-38001-分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!