Nightmare of the Javascript Optimization
Exploiting the LadyBird Browser w/ 0-day UAF
first, let me show you a RCE video
I'd like to appreciate for maintainers dealing in few days after I reported.
boring section
important: This bug has been patched.
GHSA-w89h-j2xg-c457
Security advisory: https://github.com/LadybirdBrowser/ladybird/security/advisories/GHSA-w89h-j2xg-c457

funny section
1. The caching layer that underpins the UAF
The bug lives at the seam between three data structures that LibJS keeps in sync by hand: AK::ByteBuffer (raw storage), JS::ArrayBuffer / JS::DataBlock (spec-level wrapper), and JS::TypedArrayBase (the view with a pre-computed base pointer). Every one of them participates in the invariant that gets broken.
1.1 AK::ByteBuffer — small-buffer-optimised heap storage
AK/Forward.h: 67 fixes the inline capacity of ByteBuffer at 32 bytes:
![]()
Its backing store is a union (AK/ByteBuffer.h:388-396):

data() picks the right arm of that union (AK/ByteBuffer.h:141-145):

Because one wasm page is 65536 bytes and the inline bound is 32, every wasm-backed ByteBuffer is always outline — data() is always m_outline_buffer. Resizing a ByteBuffer past its current capacity goes through the slow path at AK/ByteBuffer.h:362-386:

Two facts from this snippet drive the UAF:
The ByteBuffer object itself is stable. this never moves; only its internal m_outline_buffer pointer is replaced.
The previous outline is kfree'd synchronously during the reallocation. Every raw pointer that was remembered as old_bb.data() is now dangling.
1.2 JS::DataBlock and the two flavours of backing
Libraries/LibJS/Runtime/ArrayBuffer.h:35-71:

This is the whole type.
Variant::ByteBuffer — the ArrayBuffer owns its bytes. buffer() returns the owned storage; size() queries it directly.
Variant::UnownedFixedLengthByteBuffer — the bytes live elsewhere (wasm store, etc.). buffer is a pointer to an externally-owned AK::ByteBuffer, and size is a value captured at construction.
The deliberate asymmetry in size() — owned reads live from the buffer, unowned reads from the captured field — is what commit a2dc6c4bbb introduced so that old SharedArrayBuffer handles keep reporting the pre-grow byteLength forever (threads-proposal spec requirement, enforced by WPT grow.any.js subtest "Growing shared memory does not detach old buffer").
Two ArrayBuffer::create overloads pick between these variants (Libraries/LibJS/Runtime/ArrayBuffer.cpp:33-55):

Every wasm-backed ArrayBuffer/SharedArrayBuffer goes through the pointer overload, so it always gets the UnownedFixedLengthByteBuffer variant.

1.3 JS::TypedArrayBase::m_data
Libraries/LibJS/Runtime/TypedArray.h:45-110


The two rules the rest of the system depends on:
Who writes m_data? Only update_cached_data_ptr() (from set_byte_offset/set_viewed_array_buffer) and set_cached_data_ptr() (called from detach_buffer).
Who registers the view with the owning buffer? update_cached_data_ptr() itself, via m_viewed_array_buffer->register_cached_typed_array_view(*this).

The registry is a weak set on the buffer (Libraries/LibJS/Runtime/ArrayBuffer.h:161):

And the registration sink (Libraries/LibJS/Runtime/ArrayBuffer.cpp:264-267):

So the cache has exactly two life-cycle events: created on view construction, nulled on detach. There is no third site.
1.4 The ABI contract with the AsmInterpreter
m_data's offset is exported to the hand-written asm interpreter via ./Libraries/LibJS/Bytecode/AsmInterpreter/gen_asm_offsets.cpp:346-352:

asmint.asm consumes that symbol in two hot paths. GetByValue (asmint.asm:1719-1742)

And PutByValue (asmint.asm:1480-1509):


The fast path does not go through viewed_array_buffer(). It reads the cache, bounds-checks against m_array_length, and dereferences. That's three memory touches vs. a seven-hop chase through ArrayBuffer → DataBlock → Variant<…>::visit → UnownedFixedLengthByteBuffer → ByteBuffer* → m_outline_buffer.
There is no detach check and no buffer re-validation on this path. A null m_data sends it to the slow path (branch_zero t5, .try_typed_array_slow), and that is the only correctness handle the fast path has. If m_data is non-null but stale, the asmint executes the load/store against freed memory.

2. The shared path
The threads proposal forbids detaching a SAB. So the pre-patch shared-memory grow path sits inside the buffer->is_fixed_length() branch but cannot take the detach arm:
Libraries/LibWeb/WebAssembly/Memory.cpp:212-223

But by the time that line runs, successful_grow_hook was already called from inside MemoryInstance::grow(), and the grow itself lives in Libraries/LibWasm/AbstractMachine/AbstractMachine.h:487-520

Conclusion

does ASAN detect this bug?
Answer: NO
The memory access of Array Buffer has optimized, accessing with handwritten assembly won't trigger ASAN.
poc

leaked2==0xde, leaked3=0xad mean spray array reclaimed the uaf buffer, and a dangling pointer could read an overlapped object.
Also, corrupted_spray==0 means the first object sprayed overlapped with the uaf address, and that also proved the uaf write.
exploit
Let's get what we have straight. In nuts shell, we have completely uaf read+write primitives.
Learning from v8 exploit, Overlapping FixedArray is the most common way to escalate uaf to other primitives such as addrof, fakeobj, aar, aaw.
trigger UAF
v has the dangling pointer in m_data. Easily achievable uaf 32bit read/write via v.
1.1 reclaim critical object
new Array(6500).fill(target) puts 6500 Values into the Array's indexed-property buffer. In Ladybird that buffer is not Vector<Value>. It's a custom prefixed allocation at Libraries/LibJS/Runtime/Object.cpp:1725

By checking capacity and padding provides the confident of overlap
A JS::Value is 64 bits. Non-double values set the high 13 bits to a tag. Object tag = 0xFFF9_0000_0000_0000. The low 48 bits hold the raw Cell*. So storing target (a plain {_id, magic} object) into 64bit NaN-boxed value n writes:
1.2 addrof
now, assembling addrof is too easy.
1.3 fakeobj
addrof alone isn't enough; to get AAR/AAW we need a controlled cell address to forge into a TypedArrayBase. That's what the second reclaim is for:
vB's dangling region now spans rB's [cap=0x1E45][pad=0][Value 0]…. We overwrite vB[O4+0 .. O4+27] with a hand-rolled fake TypedArrayBase (128 B offset into the buffer, chosen to not clobber the capacity header and to land past any nearby sentinels). The key header words:
(vB is the second UAF Uint32Array)
1.4 Getting the address of the fake TA — the freelist trick
We still need to know where our planted bytes live so that we can NaN-box a Value pointing at them. That's what preSnapLo/Hi is for:
Right after kfree, mimalloc stores a freelist forward pointer in the first 8 bytes of each freed chunk. preSnap[k] snapshots that pointer through the dangling view — before we clobber it with a reclaim. Because mimalloc's freelist is LIFO and all 16 holes went back in one per-memory grow, the forward pointer from view kA's chunk points to a neighbouring freed chunk — empirically the one that ends up hosting rB (vB). So:
Planting vA[2] = fakeTA_lo; vA[3] = fakeTA_hiTag overwrites rA[0]'s NaN-boxed bits → next rA[0] read returns a forged Object value pointing at our fake TA. rA[0][i] now indexes into whatever vB[O4+26..27] points at -> escalating AAR/AAW.
1.6 RCE
1.6.1 get Pointer of Uin32Array Cell from addrof
1.6.2 leak storage pointer from m_data
1.6.3 leak libjs address from vtable for JS::Uint8Array
*(CELL+0x0) points to the vtable.

1.6.4 leak libc address by walking got
libjs has a got for libc in __cxa_atexit
1.6.5 falsify the vtable
In example, we can fire vtable->internal_get_prototype_of by `Object.getPrototypeOf()


So, which register can we control?

(register dump after falsify CELL->vtable=0xabad1deadeadbeef)
Ok, now we have vtable address in rax.
I used 0x0056eb13: mov rdi, [rax]; mov rax, [rax+8]; mov rax, [rax+0x18]; jmp rax; in libjs.
With falsifying vtable like
Now we have arbitrary command execution!!
Run & debug script
Full-chain exploit
Had a lot of fun
LadyBird is new era Browser, they are planning to release pre version in 2026, I cannot wait for it.
This bug was so educational, funny and provides me a depth knowledge of js/libjs internal.
0-day is fun!