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 |
# official "with patched qt" build |
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-javascriptis 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.
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
u32pairs 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.
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 |
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 - IMPLVTis 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.
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) |
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 |
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.
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 |
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.
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 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> |
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 |
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.