From 6e459d66f264ce27aa928d0c1f965d168b015b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Wed, 1 Jul 2026 10:34:53 +0200 Subject: [PATCH] Couple of optimizations --- data/txt/sha256sums.txt | 8 +-- lib/core/common.py | 50 ++++++++------ lib/core/settings.py | 2 +- lib/utils/dialect.py | 97 +++++++++++++++----------- tests/test_dialectdbms.py | 141 +++++++++++++++++++++----------------- 5 files changed, 168 insertions(+), 130 deletions(-) diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index 25692070e..342609ae6 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -168,7 +168,7 @@ d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py 9c5764c92ce536d1f0f96200359ee5ef1f37f9128769bf990cb77f1d1f8e17b1 lib/core/agent.py c51c33501cc905586a9aaac93b06f2ac6f71628d032a7dc39fd0ef05d7ee3856 lib/core/bigarray.py -d143df718fbaacb617b6046c73cf4e47932e1a25928a4e1ecb87ea77a3b154ed lib/core/common.py +751c3bf178e91e60b25e3b01ce7636029804dd78f64e9ee0418bdb126889a7bc lib/core/common.py 8f1272487e1adfcc8c755a2f56f0c6d21eac5e685a73a9a159482f9dc9142bc5 lib/core/compat.py 5301ba2204404d086e9a67271cde00fc10214c63b018a95fc5aa90ff9e0b2ad9 lib/core/convert.py c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data.py @@ -189,7 +189,7 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor 9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -516d6b40efa04a5a25b0aa317ea49771f6964a57581777761f82d36d1b1b78b0 lib/core/settings.py +b38aa7769be9d31f2d55172a992732f506f05fba49d6a170eb9485f78da7c360 lib/core/settings.py c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py 19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py @@ -257,7 +257,7 @@ aeefb42ea0c68f72744bc1bfd7194ec1bc06480d8a7e23f4b8d3d23fbba2b014 lib/utils/api. 442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py a94958be0ec3e9d28d8171813a6a90655a9ad7e6aa33c661e8d8ebbfcf208dbb lib/utils/deps.py -b0d8ae8513c1f5ffcaa4bf0398790f26bc2180a6acf07bf5b2c86555bf9113f6 lib/utils/dialect.py +bd9267d94390ba87d6c5a35c90f2406d6a4135a7c8ea01db76dd9e6519eee2ed lib/utils/dialect.py 51cfab194cd5b6b24d62706fb79db86c852b9e593f4c55c15b35f175e70c9d75 lib/utils/getch.py 3c4ad819589fe4fca303706dc87969273a07a04dee85e23f064b39caf1fb80e9 lib/utils/gui.py 972c5db9c9e30ac0f91c0f8d4df4531d0304e151dac99f1399c37c952ba9f935 lib/utils/har.py @@ -602,7 +602,7 @@ c17544be5e945dc8c4fbb5c3b922da8eceec30b0fb239c32fb5f40e1660a197f tests/test_dat 8a1edb6dbc000e412ba5cc598e024b669fc76ec0a8fc32136808e6325a018f70 tests/test_dbms_enum.py 3804eb2d730220360f9dc07d5994eb64e9f65acf3b0d8648df8df2a2177ba8fd tests/test_decodepage.py 180e5fd3f75fadf7ac1135f99797314e2cf1f8ae6dced02edfb18ccba43c0148 tests/test_deps.py -b01343eb8aa42ea5c2c483ec028a24f6451aa6f668fdc0c289d5ff9554c277d7 tests/test_dialectdbms.py +fa85881aa8d082a65aeacb2b03fcb5d2abb1daa9a02ee24ff048d54fbc904b90 tests/test_dialectdbms.py e40a49cfa73c45b3c3c6d1d1d00738861e270cb7a07b28f5a5356f9c7c800cf2 tests/test_dialect.py 993a2d4d87c4fbaf261663b069629acc95ee4405aa0c42cf5a8f39649fdb0fff tests/test_dicts.py 7f9180a53dbf0bb3e52801fdbfffd31f365a0bff77bf90e58d2ef63a0c23026f tests/test_dns_engine.py diff --git a/lib/core/common.py b/lib/core/common.py index 937064d70..ec7db6ff9 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -3310,7 +3310,16 @@ def isNumPosStrValue(value): return retVal -@cachedmethod +# DBMS_DICT is static, so the alias -> enum resolution is precomputed once into a +# lookup table (replacing a per-call @cachedmethod + linear scan). aliasToDbmsEnum() +# is a hot path (Backend.getIdentifiedDbms() calls it constantly). Building via +# setdefault in dict order preserves the original first-match-wins semantics. +_DBMS_ALIAS_MAP = {} +for _dbmsKey, _dbmsItem in DBMS_DICT.items(): + for _dbmsAlias in _dbmsItem[0]: + _DBMS_ALIAS_MAP.setdefault(_dbmsAlias, _dbmsKey) + _DBMS_ALIAS_MAP.setdefault(_dbmsKey.lower(), _dbmsKey) + def aliasToDbmsEnum(dbms): """ Returns major DBMS name from a given alias @@ -3319,15 +3328,7 @@ def aliasToDbmsEnum(dbms): 'Microsoft SQL Server' """ - retVal = None - - if dbms: - for key, item in DBMS_DICT.items(): - if dbms.lower() in item[0] or dbms.lower() == key.lower(): - retVal = key - break - - return retVal + return _DBMS_ALIAS_MAP.get(dbms.lower()) if dbms else None def findDynamicContent(firstPage, secondPage, merge=False): """ @@ -4414,7 +4415,11 @@ def safeSQLIdentificatorNaming(name, isTable=False): if isinstance(name, six.string_types): retVal = getUnicode(name) - _ = isTable and Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE) + # Resolve the identified DBMS once; it is invariant within this call and + # Backend.getIdentifiedDbms() (which scans DBMS_DICT) was otherwise + # re-evaluated several times below. + dbms = Backend.getIdentifiedDbms() + _ = isTable and dbms in (DBMS.MSSQL, DBMS.SYBASE) if _: retVal = re.sub(r"(?i)\A\[?%s\]?\." % DEFAULT_MSSQL_SCHEMA, "%s." % DEFAULT_MSSQL_SCHEMA, retVal) @@ -4424,13 +4429,13 @@ def safeSQLIdentificatorNaming(name, isTable=False): if not conf.noEscape: retVal = unsafeSQLIdentificatorNaming(retVal) - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): # Note: in SQLite double-quotes are treated as string if column/identifier is non-existent (e.g. SELECT "foobar" FROM users) + if dbms in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): # Note: in SQLite double-quotes are treated as string if column/identifier is non-existent (e.g. SELECT "foobar" FROM users) retVal = "`%s`" % retVal - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): + elif dbms in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): retVal = "\"%s\"" % retVal - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): retVal = "\"%s\"" % retVal.upper() - elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + elif dbms in (DBMS.MSSQL, DBMS.SYBASE): if isTable: parts = retVal.split('.', 1) for i in xrange(len(parts)): @@ -4463,16 +4468,21 @@ def unsafeSQLIdentificatorNaming(name): retVal = name if isinstance(name, six.string_types): - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): + # Resolve the identified DBMS once; it is invariant within this call, and + # Backend.getIdentifiedDbms() is not cheap (it scans DBMS_DICT). Previously + # it was re-evaluated up to five times per call. + dbms = Backend.getIdentifiedDbms() + + if dbms in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): retVal = name.replace("`", "") - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): + elif dbms in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): retVal = name.replace("\"", "") - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): retVal = name.replace("\"", "").upper() - elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + elif dbms in (DBMS.MSSQL, DBMS.SYBASE): retVal = name.replace("[", "").replace("]", "") - if Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + if dbms in (DBMS.MSSQL, DBMS.SYBASE): retVal = re.sub(r"(?i)\A\[?%s\]?\." % DEFAULT_MSSQL_SCHEMA, "", retVal) return retVal diff --git a/lib/core/settings.py b/lib/core/settings.py index 88b61ddd4..29ce4db15 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.0" +VERSION = "1.10.7.1" 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) diff --git a/lib/utils/dialect.py b/lib/utils/dialect.py index 3be67eac8..47f973edc 100644 --- a/lib/utils/dialect.py +++ b/lib/utils/dialect.py @@ -28,10 +28,10 @@ from lib.request.inject import checkBooleanExpression # OTHER valid rows, which sqlmap's fuzzy page comparison conflates with the anchor row, producing # false positives. See PROVE_DESIGN.md.) # -# Truth table measured on a live OWASP-CRS platform across 16 engines (MySQL/MySQL5, MariaDB/TiDB, -# PostgreSQL, CockroachDB, CrateDB, Microsoft SQL Server, SQLite, Firebird, ClickHouse, H2, HSQLDB, -# Derby, MonetDB, IRIS, Trino); only the zero-false-positive rules are kept (see _classify). With -# anchor value 2: +# 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) @@ -52,57 +52,69 @@ DIALECT_PROBES = ( ("shift", "1<<2=4"), ) +# 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 ' ' no-operator +# form already exercised by sqlmap's earlier false-positive check, so it adds new information. +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). +_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 +} + def _classify(signature): """ - Maps a measured (xor, pgpow, intdiv, bitor) operator-dialect signature to a back-end - DBMS, or returns None when the signature does not *uniquely* identify a major DBMS (so - detection proceeds unchanged - the heuristic never wrong-foots the scan). + 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. - Rules below are the subset of the measured 11-engine truth table that maps with zero - false positives. Engines whose operator profile is not distinctive enough (Oracle's - all-false signature, which a minimal engine like ClickHouse/H2/Firebird/HSQLDB/Derby or - a fully WAF-blocked channel also produces) deliberately fall through to None: - - >>> _classify((True, False, False, True, True)) # MySQL / MariaDB / TiDB + >>> _classify((True, False, False, True, True)) # MySQL / MariaDB / TiDB 'MySQL' - >>> _classify((True, False, True, True, False)) # Microsoft SQL Server (no bit-shift) + >>> _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) 'Microsoft SQL Server' - >>> _classify((True, False, True, True, True)) # MonetDB (same xor/intdiv as MSSQL, but has '<<') + >>> _classify((True, False, True, True, True)) # MonetDB (as MSSQL but has '<<') 'MonetDB' - >>> _classify((False, True, True, True, False)) # PostgreSQL - 'PostgreSQL' - >>> _classify((False, True, False, True, False)) # CockroachDB (pgwire) -> PostgreSQL family - 'PostgreSQL' - >>> _classify((False, False, True, True, True)) # SQLite + >>> _classify((False, False, True, True, True)) # SQLite 'SQLite' - >>> _classify((False, False, True, False, False)) is None # Firebird/HSQLDB/Derby/H2/Trino -> no prior + >>> _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) -> no prior + >>> _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 True """ - xor, pgpow, intdiv, bitor, shift = signature - - if pgpow: # '^' is exponentiation -> PostgreSQL family - return DBMS.PGSQL - if xor and intdiv: # '^' is XOR AND integer division -> SQL Server ... - # ... except MonetDB shares this exact signature; it alone has a working bit-shift operator - # ('1<<2=4'), SQL Server has none -> split the collision (measured zero-FP across 16 engines). - return DBMS.MONETDB if shift else DBMS.MSSQL - if xor and not intdiv: # '^' is XOR AND real division -> MySQL family - return DBMS.MYSQL - if not xor and intdiv and bitor: # no '^', integer division, bitwise '|' -> SQLite - return DBMS.SQLITE - - return None + return _SIGNATURE_DBMS.get(tuple(bool(_) for _ in signature)) 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 or - WAF-blocked channel yields None, leaving the scan unchanged. + 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. """ retVal = None @@ -114,9 +126,12 @@ def dialectCheckDbms(injection): kb.injection = injection try: - # channel sanity: a tautology must read TRUE and a contradiction FALSE, otherwise the - # boolean oracle is unreliable and the all-false signature (Oracle-like) would be meaningless - if checkBooleanExpression("2=2") and not checkBooleanExpression("2=3"): + # 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. + 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) finally: diff --git a/tests/test_dialectdbms.py b/tests/test_dialectdbms.py index 5dc28ac98..040d80b1a 100644 --- a/tests/test_dialectdbms.py +++ b/tests/test_dialectdbms.py @@ -4,13 +4,13 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) See the file 'LICENSE' for copying permission -Operator-dialect DBMS heuristic (lib/utils/dialect.py). These lock in the empirical truth -table: the (xor, intdiv, pgcast, bitor) operator signatures measured across 11 live engines -on an OWASP-CRS test platform, asserting that _classify() maps each to the expected back-end -DBMS - and, just as importantly, that the engines whose signatures collide or are ambiguous -map to None (no prior), so the heuristic never wrong-foots detection. The end-to-end behaviour -(the probes producing these signatures through a real boolean injection) is exercised against -the live platform, not here. +Operator-dialect DBMS heuristic (lib/utils/dialect.py). These lock in the empirical truth table: +the full 5-probe (2^0=2, 2^3=8, 5/2=2, 2|0=2, 1<<2=4) operator signatures measured across the live +SQL engines on an OWASP-CRS test platform, asserting _classify() maps each EXACT signature to the +expected back-end DBMS via its whitelist - and, just as importantly, that anything else (an +unmeasured engine, an ambiguous signature, or a physically-impossible / noise signature) maps to +None, so the heuristic never wrong-foots detection. The end-to-end behaviour (the probes producing +these signatures through a real boolean injection) is exercised against the live platform, not here. """ import os @@ -26,78 +26,80 @@ from lib.core.data import kb from lib.core.enums import DBMS from lib.utils.dialect import _classify from lib.utils.dialect import dialectCheckDbms +from lib.utils.dialect import DIALECT_CANARY -# measured 2026-06 across the sqli-platform (boolean form "id=2 AND ", anchor value 2); -# base signature = (2^0=2, 2^3=8, 5/2=2, 2|0=2). The 5th probe (1<<2=4, bit-shift) is the MonetDB-vs- -# SQL Server disambiguator and is asserted separately (SHIFT_SENSITIVE); for every other engine the -# shift flag does NOT change the classification, which the test proves by trying it both ways. +# Full 5-probe signature (2^0=2, 2^3=8, 5/2=2, 2|0=2, 1<<2=4) measured live -> expected DBMS. +# Every bit is significant now (whitelist): e.g. MySQL/PostgreSQL/... all have a working '<<', so +# shift=True is part of their signature; a one-bit-off variant is simply not a known fingerprint. MEASURED = { - "mysql": ((True, False, False, True), DBMS.MYSQL), - "mysql5": ((True, False, False, True), DBMS.MYSQL), - "tidb": ((True, False, False, True), DBMS.MYSQL), # MySQL wire-compatible - "postgres": ((False, True, True, True), DBMS.PGSQL), - "cockroach": ((False, True, False, True), DBMS.PGSQL), # pgwire (exponent '^', decimal division) - "cratedb": ((False, True, True, True), DBMS.PGSQL), # pgwire family - "sqlite": ((False, False, True, True), DBMS.SQLITE), + "mysql": ((True, False, False, True, True), DBMS.MYSQL), + "mysql5": ((True, False, False, True, True), DBMS.MYSQL), + "tidb": ((True, False, False, True, True), DBMS.MYSQL), # MySQL wire-compatible + "postgres": ((False, True, True, True, True), DBMS.PGSQL), + "cockroach": ((False, True, False, True, True), DBMS.PGSQL), # pgwire (exponent '^', decimal division, has '<<') + "cratedb": ((False, True, True, True, False), DBMS.PGSQL), # pgwire family (no '<<') + "mssql": ((True, False, True, True, False), DBMS.MSSQL), # '^' XOR, integer division, NO bit-shift + "monetdb": ((True, False, True, True, True), DBMS.MONETDB), # shares MSSQL base but HAS '<<' + "sqlite": ((False, False, True, True, True), DBMS.SQLITE), # not distinctive enough -> deliberately no prior (operators alone can't safely separate these) - "firebird": ((False, False, True, False), None), - "hsqldb": ((False, False, True, False), None), # collides with firebird/derby/h2 - "derby": ((False, False, True, False), None), - "h2": ((False, False, True, False), None), - "trino": ((False, False, True, False), None), - "iris": ((False, False, False, False), None), # all-error, like Oracle/broken channel - "clickhouse": ((False, False, False, False), None), # all-error, like Oracle/broken channel -} - -# engines whose full 5-probe signature (incl. 1<<2=4) is needed because they share base-4 (xor,intdiv) -# and only the bit-shift probe separates them: SQL Server has no shift operator, MonetDB does. -SHIFT_SENSITIVE = { - "mssql": ((True, False, True, True, False), DBMS.MSSQL), - "monetdb": ((True, False, True, True, True), DBMS.MONETDB), + "firebird": ((False, False, True, False, False), None), + "hsqldb": ((False, False, True, False, False), None), # collides with firebird/derby/h2/trino + "derby": ((False, False, True, False, False), None), + "h2": ((False, False, True, False, False), None), + "trino": ((False, False, True, False, False), None), + "iris": ((False, False, False, False, False), None), # all-error, like Oracle/broken channel + "clickhouse": ((False, False, False, False, False), None), # all-error, like Oracle/broken channel } class TestDialectClassification(unittest.TestCase): - def test_shift_sensitive_engines_split_correctly(self): - # MonetDB shared MSSQL's (xor, intdiv) signature exactly (a false positive before the shift - # probe); 1<<2=4 (MonetDB only) now separates them. - for engine, (signature, expected) in SHIFT_SENSITIVE.items(): + def test_measured_engines_map_as_expected(self): + # each engine's exact measured 5-probe signature maps to its expected DBMS (or None) + for engine, (signature, expected) in MEASURED.items(): self.assertEqual(_classify(signature), expected, "engine %r misclassified" % engine) - def test_measured_engines_map_as_expected(self): - # for non-shift-sensitive engines the shift flag is irrelevant: assert BOTH values map to the - # expected DBMS (proves the new probe never perturbs the existing classifications). - for engine, (base, expected) in MEASURED.items(): - for shift in (False, True): - self.assertEqual(_classify(base + (shift,)), expected, "engine %r misclassified (shift=%s)" % (engine, shift)) + def test_shift_splits_monetdb_from_mssql(self): + # MonetDB shares MSSQL's (xor, intdiv) base exactly (a false positive before the shift probe); + # 1<<2=4 (MonetDB has it, SQL Server never does) is the sole separator. + self.assertEqual(_classify((True, False, True, True, False)), DBMS.MSSQL) + self.assertEqual(_classify((True, False, True, True, True)), DBMS.MONETDB) - def test_no_false_positive_across_measured_set(self): - # non-collision property: every measured engine maps to EXACTLY its expected DBMS (or None), - # never to some other back-end. The shift flag is irrelevant for these (non-shift-sensitive) - # engines, so assert it both ways. - for engine, (base, expected) in MEASURED.items(): - for shift in (False, True): - result = _classify(base + (shift,)) - self.assertEqual(result, expected, "engine %r misclassified (shift=%s): got %r, expected %r" % (engine, shift, result, expected)) - # the only non-None DBMS priors the measured set can yield (sanity on the mapping itself) - produced = set(expected for _, expected in MEASURED.values() if expected is not None) - self.assertEqual(produced, {DBMS.MYSQL, DBMS.PGSQL, DBMS.SQLITE}) + def test_whitelist_is_exact_no_false_positive(self): + # only the measured classifying signatures may yield a DBMS; everything else -> None. + classifying = set(sig for sig, exp in MEASURED.values() if exp is not None) + produced = set(exp for _, exp in MEASURED.values() if exp is not None) + self.assertEqual(produced, {DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.MONETDB, DBMS.SQLITE}) + # exhaustively sweep all 32 signatures: a non-None result is allowed ONLY for a measured one + for bits in range(32): + sig = tuple(bool(bits & (1 << i)) for i in range(5)) + result = _classify(sig) + if sig not in classifying: + self.assertIsNone(result, "unmeasured signature %r wrongly mapped to %r" % (sig, result)) + + def test_all_true_noise_is_rejected(self): + # a channel that reads EVERY probe true (a static/reflected page, or a WAF/false-positive + # oracle) produces the all-true signature - physically impossible ('^' cannot be XOR and + # exponentiation at once). It must NOT be guessed (previously it mis-read as PostgreSQL). + self.assertIsNone(_classify((True, True, True, True, True))) def test_all_error_signature_yields_no_prior(self): - # an all-error signature (Oracle, ClickHouse, IRIS, or simply a WAF-blocked channel) is not - # distinctive enough - it must NOT be guessed as any DBMS + # an all-error signature (Oracle, ClickHouse, IRIS, or a WAF-blocked channel) is not + # distinctive - it must NOT be guessed as any DBMS self.assertIsNone(_classify((False, False, False, False, False))) self.assertIsNone(_classify((False, False, False, False, True))) - def test_pgpow_dominates_as_postgres_marker(self): - # exponentiation '^' is a positive PostgreSQL-family marker regardless of division flavour - self.assertEqual(_classify((False, True, True, True, False)), DBMS.PGSQL) - self.assertEqual(_classify((False, True, False, True, False)), DBMS.PGSQL) + def test_pgpow_alone_is_not_enough(self): + # exponentiation '^' is a PostgreSQL marker, but pgpow ALONE no longer classifies: the full + # signature must match a measured PostgreSQL fingerprint (this is what stops the all-true noise + # from riding the old 'pgpow dominates' rule into a bogus PostgreSQL claim). + self.assertEqual(_classify((False, True, True, True, True)), DBMS.PGSQL) # real PostgreSQL + self.assertIsNone(_classify((True, True, False, False, False))) # pgpow set, but not a real signature class TestDialectCheckDbmsGuard(unittest.TestCase): - """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good - channel, and None (no prior) whenever the channel is unreliable - the safety contract.""" + """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good channel, + and None (no prior) whenever the channel is unreliable - the safety contract, including the + canary that turns a trashy false-positive channel into a true negative.""" def _run(self, truth): # truth: {expression: bool} simulating checkBooleanExpression through a confirmed injection @@ -111,11 +113,13 @@ class TestDialectCheckDbmsGuard(unittest.TestCase): kb.injection = saved def test_identifies_mysql_on_good_channel(self): - truth = {"2=2": True, "2=3": False, "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True} + truth = {"2=2": True, "2=3": False, DIALECT_CANARY: False, + "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True, "1<<2=4": True} self.assertEqual(self._run(truth), DBMS.MYSQL) def test_identifies_postgres_on_good_channel(self): - truth = {"2=2": True, "2=3": False, "2^0=2": False, "2^3=8": True, "5/2=2": True, "2|0=2": True} + truth = {"2=2": True, "2=3": False, DIALECT_CANARY: False, + "2^0=2": False, "2^3=8": True, "5/2=2": True, "2|0=2": True, "1<<2=4": True} self.assertEqual(self._run(truth), DBMS.PGSQL) def test_none_on_blocked_channel(self): @@ -124,7 +128,16 @@ class TestDialectCheckDbmsGuard(unittest.TestCase): def test_none_on_static_channel(self): # a static page reads everything True, so the contradiction 2=3 is True -> sanity fails -> None - self.assertIsNone(self._run({"2=2": True, "2=3": True, "2^0=2": True, "2^3=8": True, "5/2=2": True, "2|0=2": True})) + self.assertIsNone(self._run({"2=2": True, "2=3": True, DIALECT_CANARY: True, + "2^0=2": True, "2^3=8": True, "5/2=2": True, "2|0=2": True, "1<<2=4": True})) + + def test_none_when_canary_reads_true(self): + # THE canary contract: a channel can look like a clean oracle (2=2 true, 2=3 false) and even + # yield a DBMS-shaped signature, but if the syntactically-invalid canary also reads TRUE the + # channel accepts garbage -> it is a false positive -> return None (true negative), never a DBMS. + truth = {"2=2": True, "2=3": False, DIALECT_CANARY: True, + "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True, "1<<2=4": True} # would be MySQL + self.assertIsNone(self._run(truth)) if __name__ == "__main__":