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 3,200 stars). Splash ships as the scrapy-splash plugin (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.)
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 |
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.
fakeobjandaddrof:fakeobjfabricates a JavaScript object at an address you choose;addrofleaks 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. |
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.sliceOOB, the canonical JSC exploit): patched.addrof({})returnsNaN. - qwertyoruiopz's
peek_stack(uninitializedarguments.lengthviaapply): patched.arguments[0xFF00]returnsundefined. - Array-runtime OOB bugs (
concat/slice/spliceoverflows): patched. The fork source carries theChecked<>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) { |
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 |
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.
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] ... |
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 |
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.
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); |
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 *** |
PWNED marker present: YES |
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:
Proxydoes not exist in this build, which rules out theProxy-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. Takingaddrofof that object yields a number, soaddrof(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 realUint32Array's header back through the R/W primitive:0x61.- libc. With arbitrary read,
addrof(Math.sin)→+0x18(itsNativeExecutable) →+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 formalloc(resolved at load time, always mapped) minusmalloc'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 |
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 . |
Dockerfile download
# Vulnerable target: scrapinghub/splash (QtWebKit 5.212 / WebKit 602.1). |
splash-exploit.html download
<html><body><script> |
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.