My previous post covered the development of an RCE exploit for PhantomJS using CVE-2014-1303, a heap overflow affecting its WebKit engine. Headless rendering services process attacker-controlled HTML and JavaScript using an embedded browser engine, which makes memory-corruption vulnerabilities in the renderer an interesting attack surface.
In this post I'll apply the same approach to Splash, which uses a newer version of WebKit and includes additional exploit mitigations. The final exploit is contained entirely within a single HTML file and works against a default Splash installation with ASLR enabled, without requiring a staging server or helper process. Unlike the PhantomJS exploit, this requires chaining three separate JavaScriptCore vulnerabilities to achieve code execution. As with the previous post, I worked through the exploit development process 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.)
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.
Compared to the version of WebKit used by PhantomJS, WebKit 602 introduces two important differences for exploitation:
- 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). This provides additional attack surface, but the newer JIT also includes exploit mitigations that were not present in the PhantomJS target.
- The JIT pages are W^X.
Before getting into the individual vulnerabilities, there are a few primitives and mitigations that are relevant to the exploit:
- 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 exploit uses three JavaScriptCore vulnerabilities, with each one providing a different primitive:
| 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 additional components are required to complete the exploit. The first is a libc address leak derived from libQt5WebKit's GOT (Global Offset Table), which contains the resolved addresses of external functions imported by the library. The second is a data-only setcontext payload that provides code execution under W^X without writing to executable memory. The following sections walk through the chain in roughly the order it was developed, beginning with CVE-2017-7005 and the arbitrary read/write primitive before moving on to the address leaks.
Bug selection
WebKit 602 has a number of publicly documented JavaScriptCore vulnerabilities and exploits, so I started by testing existing vulnerabilities against the Splash build. It is important to test these against the actual QtWebKit build rather than assuming vulnerabilities from the corresponding upstream WebKit version are present. Several of the initial candidates were already patched:
- 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) { |
Reviewing the fastSlice implementation confirmed that this bug provides fakeobj, but not addrof. The result array is always converted to SlowPutArrayStorage, which allows the double-to-object conversion but not the reverse object-to-double conversion. As a result, a separate information leak is still required to obtain an initial address. This differs from CVE-2016-4622, which preserves the indexing type and can provide addrof directly.
fakeobj to arbitrary read/write
Once fakeobj and one valid address are available, I used the container technique described in saelo's JavaScriptCore exploitation research to construct an arbitrary read/write primitive. A large Float64Array provides a backing store whose contents can be controlled directly, and a Uint32Array header can then be forged inside that memory:
var big = new Float64Array(0x2000000); // 256 MB, big[i] read/writes spray byte i*8 |
The main build-specific values are the JSCell header (type=0x26, flags=0x18 for a Uint32Array in this build) and the live structureID. By changing the fake array's m_vector, the array can be pointed at an arbitrary address and used for reads or writes through fake[i]. The vector has to be restored before additional allocations are performed because a garbage collection while it points outside a valid buffer will crash the process.
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) |
At this point I had arbitrary read/write within the render process. In the PhantomJS exploit this was enough to move directly to code execution, but the WebKit 602 build used by Splash enforces W^X, so the same payload technique cannot be reused.
W^X
I first attempted to reuse the JIT-based technique from the PhantomJS exploit: locate a JIT-compiled function, overwrite its executable page with shellcode, and then invoke the function. The JIT pages could be read successfully, including the compiled function prologue, but attempts to write to them caused a fault.
WebKit 602 protects its JIT pages using Memory Protection Keys (MPK/PKRU). Although the pages appear as rwxp in /proc/maps, writes are disabled outside the JIT compiler and are only enabled while the engine is emitting code. A write through the fake array therefore faults. Splash also blocks the file:// XHR technique I used with PhantomJS to read /proc/self/maps.
A data-only payload via setcontext
One common approach to code execution under W^X is return-oriented programming (ROP), which reuses existing executable code rather than injecting new instructions. For this exploit I found that glibc's setcontext provided a simpler way to restore the required register state. On x86-64, rip contains the next instruction address, rsp is the stack pointer, and rdi, rsi, and rdx contain the first three function arguments. The register-restore portion of setcontext provides the following sequence:
mov rsp,[rdi+0xa0] mov rbp,[rdi+0x78] mov r12,[rdi+0x48] ... |
setcontext restores rsp, rip, rdi, rsi, and rdx from a ucontext structure referenced by rdi. If I can invoke this sequence with rdi pointing to controlled memory, I can construct a fake ucontext containing the register state for execve("/bin/sh", argv, envp). This avoids the need to build a conventional multi-gadget ROP chain.
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 |
Because the fake object is located inside the controlled 256 MB spray, block = fakeobj & ~0x3fff also resolves to memory I control. This makes it possible to forge the objects used by the lookup path: a fake VM, StructureIDTable, Structure, ClassInfo, and MethodTable. I populate the MethodTable entries with the setcontext address so that when the garbage collector marks the fake object, the virtual call reaches setcontext with rdi pointing to the fake cell. The fake cell also contains the ucontext state required to invoke 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. My first attempt was to overwrite the JIT code pointer for a JavaScript function. I located candidate pointer slots by scanning the heap for the function's entry address, but the scan also matched unrelated copies of the same address inside Structure objects. Overwriting these values corrupted the associated structures and caused a crash in methodTable instead of redirecting execution.
A second dereference path. After the methodTable forge was working, garbage collection still crashed in JSC::speculationFromCell. This is a separate value-profile marking path that also dereferences the fake object. It obtains the table through [[block+0xe8]+0xc0], without the +0x4630 dereference used by methodTable. I resolved this by making the fake VM self-referential:
w(AH+0xe8, fakeVM); |
With this layout both dereference paths resolve to the same fake table. I also populated the additional fields inspected by speculationFromCell, including the relevant flag byte and the ClassInfo parent-chain terminator.
Execution
To complete the execution path, I placed the fake ucontext at the fake object's address with rdi=/bin/sh, rsi=argv, rdx=envp, rsp=scratch, and rip=execve. After forging the remaining object chain, allocating roughly 25 MB is enough to trigger garbage collection and reach the forged MethodTable:
*** ARBITRARY R/W ESTABLISHED *** |
PWNED marker present: YES |
Extending the chain: in-page leaks
At this point the exploit was functional, but it was not yet self-contained. The memory-corruption primitives (fakeobj, the fake-array read/write, and the setcontext forge) were implemented entirely in JavaScript, but three values were still being supplied externally during development: the spray base address, the live Uint32Array structureID, and the libc base address. I obtained these values from a /proc/<pid>/mem harness by locating recognizable markers placed in memory by the page.
The main issue was JavaScriptCore's value representation. JavaScript values, including numbers and object references, are stored in 64-bit slots using NaN-boxing. A raw pointer exposed through CVE-2017-7005 is therefore interpreted as an object reference rather than a JavaScript number, and attempting to use that value causes the engine to dereference the address. To point the fake array at useful memory under ASLR, I needed a separate primitive that could expose a real process address as a numeric JavaScript value.
Finding a numeric leak
I tested several vulnerabilities that could potentially provide the required numeric address leak. Most were either patched or not applicable to this version of JavaScriptCore:
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. I initially checked the fork's ChangeLog to determine whether fixes for these vulnerabilities had been backported. This turned out to be the wrong way to evaluate vulnerabilities against an older fork. Some vulnerabilities reported in 2018–2019 depend on code introduced after QtWebKit's mid-2016 branch point, meaning the vulnerable code was never present in this build. Testing CVE-2019-8518, an FTL abstract-interpreter bug, confirmed this. For an older fork, the relevant question is whether the vulnerable code existed at the branch point rather than whether a later patch is present.
- The array-runtime OOB bugs are patched, as established during bug selection.
CVE-2017-2547 was the first vulnerability I found that provided the required primitive. CVE-2017-2547 is 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 a working addrof primitive, the remaining values can be derived at runtime. The spray base requires one additional vulnerability that was not backported to this QtWebKit build:
- 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.
At this point every value previously supplied by the /proc harness can be derived from JavaScript. The setcontext forge itself does not change; it simply uses the addresses obtained from the in-page leaks. Running the exploit against a stock Splash container with ASLR enabled produces the following output without any externally supplied addresses:
SPRAYBASE=0x70aec6400000 ← leaked in-page |
I verified the same exploit file without modification on two independent deployments: a native x86_64 host and an emulated Docker environment with a different library layout. The libc base, which is the primary environment-dependent value used by the final stage, is derived at runtime.
During development I used a debugger to inspect process memory, disassemble methodTable and setcontext, and examine register state at the redirected call.
PoC
The proof of concept consists of two files. The Dockerfile starts Splash, serves the exploit page, and requests the page through Splash's renderer. The exploit sends the output of id back to the built-in HTTP server, where it appears in the container output as a GET /uid=... request. Exploitation is not completely deterministic because it depends on garbage-collection timing and StructureID reuse, so more than one run may be required.
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> |
Conclusion
This research demonstrates the same underlying risk as the PhantomJS exploit: a service that renders attacker-controlled HTML using an outdated browser engine can expose the renderer to memory-corruption vulnerabilities that lead to code execution.
The Splash target requires a more involved exploit chain than PhantomJS. The PhantomJS exploit relied on a single heap overflow and an executable JIT page that could be modified directly. In this version of WebKit, ASLR and W^X prevent that approach. The final exploit therefore combines three JavaScriptCore vulnerabilities to obtain the required address leaks and arbitrary read/write primitive, then uses a data-only setcontext payload to reach execve without writing to executable memory.
The result is a self-contained HTML payload that derives the required addresses at runtime and achieves code execution against the default Splash renderer under ASLR and W^X.
- Attacking JavaScript Engines saelo, Phrack 70. The reference writeup on JavaScriptCore exploitation and the
addrof/fakeobjprimitives. - Project Zero issue 1208 (CVE-2017-7005) lokihardt. The
haveABadTime()type confusion used here as a leak primitive. - js-vuln-db tunz. A collection of JavaScriptCore CVE writeups and PoCs, including the bugs tested in this post.
- Splash documentation The scriptable headless browser this exploit targets.
- QtWebKit The fork (WebKit 602) that Splash renders with.