From 65b6e7b4acb13a736614a1ab73b0908476f3c24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Mon, 5 Oct 2026 23:46:59 +0200 Subject: [PATCH 1/7] Fixes #6137 --- lib/core/settings.py | 2 +- lib/core/target.py | 6 +++++- tests/test_target_parsing.py | 26 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index fed08b686c7..239002253b7 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.9.32" +VERSION = "1.10.10.0" 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/core/target.py b/lib/core/target.py index f61cd56ea9d..87feb4d2af4 100644 --- a/lib/core/target.py +++ b/lib/core/target.py @@ -561,7 +561,11 @@ def _resumeDBMS(): dbms = value.lower() dbmsVersion = [UNKNOWN_DBMS_VERSION] _ = "(%s)" % ('|'.join(SUPPORTED_DBMS)) - _ = re.search(r"\A%s (.*)" % _, dbms, re.I) + try: + # Dirty patch for a Python 3.13/3.14 re module regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128, https://github.com/sqlmapproject/sqlmap/issues/6137) + _ = re.search(r"\A%s (.*)" % _, dbms, re.I) + except TypeError: + _ = None if _: dbms = _.group(1).lower() diff --git a/tests/test_target_parsing.py b/tests/test_target_parsing.py index 7a3c778da6d..f7b8897f534 100644 --- a/tests/test_target_parsing.py +++ b/tests/test_target_parsing.py @@ -51,6 +51,7 @@ from lib.core.settings import UNENCODED_ORIGINAL_VALUE from lib.core.threads import getCurrentThreadData from lib.utils.hashdb import HashDB +import lib.core.target as target from lib.core.target import _createDumpDir from lib.core.target import _createFilesDir from lib.core.target import _createTargetDirs @@ -354,6 +355,31 @@ def test_offline_without_session_raises(self): with self.assertRaises(SqlmapNoneDataException): _resumeDBMS() + def test_survives_broken_re_module(self): + # a Python 3.13/3.14 're' module regression can make re.search() raise TypeError on an + # otherwise-valid pattern (#6128, #6137) - must fall back to "no match" (the stored value + # used as-is) instead of crashing the whole resume + self._new_hashdb() + conf.dbms = None + conf.offline = False + hashDBWrite(HASHDB_KEYS.DBMS, "MySQL 5.0") + + originalRe = target.re + + class _BrokenRe(object): + I = originalRe.I + + def search(self, *args, **kwargs): + raise TypeError("list.append() takes exactly one argument (2 given)") + + target.re = _BrokenRe() + try: + _resumeDBMS() + finally: + target.re = originalRe + + self.assertIsNone(Backend.getIdentifiedDbms()) + class TestResumeOS(_TargetTestBase): def test_resumes_os(self): From 1b1f93ae5cba83e2596fad2e5d1313db1be60e39 Mon Sep 17 00:00:00 2001 From: "Mr.X" <94920917+MRX-72@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:24:39 +0530 Subject: [PATCH 2/7] Make test_ssti and test_xpath runnable standalone (#6136) Every tests/test_*.py file provides an if __name__ == "__main__": unittest.main(...) entrypoint so it can be run directly, except test_ssti.py and test_xpath.py. Running either of those directly produced no output and executed none of its tests; they only ran under `unittest discover`. Add the missing entrypoint to both so they behave like the rest of the suite. No test logic changed (62 and 48 tests respectively still pass). --- tests/test_ssti.py | 4 ++++ tests/test_xpath.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/test_ssti.py b/tests/test_ssti.py index 54c6419f1f2..c1d4bffb205 100644 --- a/tests/test_ssti.py +++ b/tests/test_ssti.py @@ -827,3 +827,7 @@ def test_s2045_command_rejects_reflected_header(self): # so no start/end marker is found -> no fabricated 'output' ssti._s2045Send = lambda url, action: "reflected: %s" % action self.assertIsNone(ssti._executeStruts2Header("http://target", "id")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_xpath.py b/tests/test_xpath.py index e65ac126162..1923cbb79ec 100644 --- a/tests/test_xpath.py +++ b/tests/test_xpath.py @@ -557,3 +557,7 @@ def test_all_extractable_boundaries_have_valid_extraction(self): self.fail("Boundary '%s' in '%s' with orig='%s' invalid: %s\n payload: %s" % (bk, tkey, original, e, payload)) self.assertIsInstance(count, int, "Boundary '%s' in '%s' produced no count" % (bk, tkey)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 2c6e4d85b5a1b854a6e0ad485e9ad1f8e038031d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sat, 10 Oct 2026 12:00:33 +0200 Subject: [PATCH 3/7] Fixes #6142 --- lib/core/settings.py | 2 +- lib/request/connect.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 239002253b7..18dd380bbbc 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.10.0" +VERSION = "1.10.10.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/request/connect.py b/lib/request/connect.py index 9cbe1708c1c..455f5ccfc12 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -1638,7 +1638,7 @@ def _randomizeParameter(paramString, randomParameter): match = re.search(r"['\"]", post) if match: quote = match.group(0) - post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry, quote))), post) + post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry.replace('\\', r'\\'), quote))), post) else: post += "%s%s=%s" % (delimiter, name, entry) elif get is not None: From 5b180533c1e83cad47fcd4f7795f8d78c8e2e518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sun, 11 Oct 2026 21:20:29 +0200 Subject: [PATCH 4/7] Fixes #6143 --- lib/core/settings.py | 2 +- lib/utils/hash.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 18dd380bbbc..a0fd2641eaf 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.10.1" +VERSION = "1.10.10.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) diff --git a/lib/utils/hash.py b/lib/utils/hash.py index 18b5ee0b8c2..ee3af594cfd 100644 --- a/lib/utils/hash.py +++ b/lib/utils/hash.py @@ -79,6 +79,7 @@ from lib.core.enums import MKSTEMP_PREFIX from lib.core.exception import SqlmapDataException from lib.core.exception import SqlmapUserQuitException +from lib.core.patch import dirtyPatches from lib.core.settings import COMMON_PASSWORD_SUFFIXES from lib.core.settings import COMMON_PASSWORDS from lib.core.settings import COMMON_USER_COLUMNS @@ -101,6 +102,16 @@ from thirdparty.colorama.initialise import init as coloramainit from thirdparty.six.moves import queue as _queue +# On Windows, multiprocessing's 'spawn' start method boots a fresh interpreter per cracking +# worker (_bruteProcessVariantA/B/Salted below) to unpickle its target function, which re-imports +# this module from scratch without ever running the main process's startup dirtyPatches() - so +# process-wide monkeypatches it installs (e.g. the 'reversible' codec error handler used by +# getUnicode()/openFile()) are missing in the worker, raising LookupError on every hashed entry +# (#6143). Placing the (idempotent) call here means that very re-import applies it. POSIX fork() +# workers inherit the parent's patches already, so this only needs to run on Windows. +if IS_WIN: + dirtyPatches() + def mysql_passwd(password, uppercase=True): """ Reference(s): From c94f6d0041eb0fe204540aaedc939292ab5f47b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 16 Oct 2026 12:15:59 +0200 Subject: [PATCH 5/7] Update regarding #6145 --- data/xml/payloads/boolean_blind.xml | 21 ++++++++++++++ lib/controller/checks.py | 6 ++-- lib/core/agent.py | 45 +++++++++++++++++++++++------ lib/core/enums.py | 23 +++++++-------- lib/core/settings.py | 2 +- tamper/between.py | 5 ++++ tests/test_agent.py | 15 ++++++++++ 7 files changed, 92 insertions(+), 25 deletions(-) diff --git a/data/xml/payloads/boolean_blind.xml b/data/xml/payloads/boolean_blind.xml index 1e5d378004a..a0afaf4fc41 100644 --- a/data/xml/payloads/boolean_blind.xml +++ b/data/xml/payloads/boolean_blind.xml @@ -1365,6 +1365,27 @@ Tag: + + + MySQL boolean-based blind - ORDER BY clause (RLIKE) + 1 + 3 + 1 + 3 + 3 + CASE WHEN [INFERENCE] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END + + CASE WHEN [RANDNUM]=[RANDNUM] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END + + + CASE WHEN [RANDNUM]=[RANDNUM1] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END + +
+ MySQL +
+
+ HAVING boolean-based blind - WHERE, GROUP BY clause diff --git a/lib/controller/checks.py b/lib/controller/checks.py index 0d0b858ae7f..3bdbc471aad 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -403,7 +403,7 @@ def checkSqlInjection(place, parameter, value): # Parse test's comment = agent.getComment(test.request) if len(conf.boundaries) > 1 else None - fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None) + fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause) for boundary in boundaries: injectable = False @@ -517,13 +517,13 @@ def checkSqlInjection(place, parameter, value): # payload was successful # Parse test's for method, check in test.response.items(): - check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None) + check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause) # In case of boolean-based blind SQL injection if method == PAYLOAD.METHOD.COMPARISON: # Generate payload used for comparison def genCmpPayload(): - sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None) + sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause) # Forge response payload by prepending with # boundary's prefix and appending the boundary's diff --git a/lib/core/agent.py b/lib/core/agent.py index 22184524c92..ab28d2bf4d5 100644 --- a/lib/core/agent.py +++ b/lib/core/agent.py @@ -61,6 +61,31 @@ from lib.core.unescaper import unescaper from thirdparty import six +def _isGroupOrderByClause(clause): + """ + Returns True if (test's/injection's) clause is exclusively GROUP BY and/or ORDER BY + + >>> _isGroupOrderByClause([PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY]) + True + >>> _isGroupOrderByClause([PAYLOAD.CLAUSE.WHERE, PAYLOAD.CLAUSE.ORDER_BY]) + False + """ + + return bool(clause) and set(clause) <= set((PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY)) + +def _markerSafeRandom(generator): + """ + Returns a random value (e.g. for [RANDNUM]) not containing the --string/--not-string page marker, + as a reflected one (e.g. echoed in a DBMS error message) would otherwise fake the comparison + """ + + markers = filterNone((conf.get("string"), conf.get("notString"))) + + while True: + retVal = getUnicode(generator()) + if not any(_ in retVal for _ in markers): + return retVal + class Agent(object): """ This class defines the SQL agent methods. @@ -184,7 +209,7 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N newValue = "%s%s" % (value, newValue) - newValue = self.cleanupPayload(newValue, origValue) or "" + newValue = self.cleanupPayload(newValue, origValue, clause=kb.injection.clause if kb.get("injection") else None) or "" if base64Encoding: _newValue = newValue @@ -296,9 +321,9 @@ def prefixQuery(self, expression, prefix=None, where=None, clause=None): # after the prefix or it is in GROUP BY / ORDER BY () elif getTechnique() == PAYLOAD.TECHNIQUE.STACKED: query = kb.injection.prefix - elif kb.injection.clause == [2, 3] or kb.injection.clause == [2] or kb.injection.clause == [3]: + elif _isGroupOrderByClause(kb.injection.clause): query = kb.injection.prefix - elif clause == [2, 3] or clause == [2] or clause == [3]: + elif _isGroupOrderByClause(clause): query = prefix # In any other case prepend with the full prefix @@ -356,7 +381,7 @@ def suffixQuery(self, expression, comment=None, suffix=None, where=None, trimEmp return re.sub(r";\W*;", ";", expression) if trimEmpty else expression - def cleanupPayload(self, payload, origValue=None): + def cleanupPayload(self, payload, origValue=None, clause=None): if not isinstance(payload, six.string_types): return @@ -375,16 +400,18 @@ def cleanupPayload(self, payload, origValue=None): payload = payload.replace(value, replacements[value]) for _ in set(re.findall(r"(?i)\[RANDNUM(?:\d+)?\]", payload)): - payload = payload.replace(_, str(randomInt())) + payload = payload.replace(_, _markerSafeRandom(randomInt)) for _ in set(re.findall(r"(?i)\[RANDSTR(?:\d+)?\]", payload)): - payload = payload.replace(_, randomStr()) + payload = payload.replace(_, _markerSafeRandom(randomStr)) if origValue is not None: origValue = getUnicode(origValue) if "[ORIGVALUE]" in payload: - payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if origValue.isdigit() else unescaper.escape("'%s'" % origValue)) + # Note: in a GROUP BY/ORDER BY payload a (plain identifier) original value is the column itself - quoting it would turn it into a constant + raw = origValue.isdigit() or (_isGroupOrderByClause(clause) and re.search(r"\A[\w.]+\Z", origValue) is not None) + payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if raw else unescaper.escape("'%s'" % origValue)) if "[ORIGINAL]" in payload: payload = getUnicode(payload).replace("[ORIGINAL]", origValue) @@ -426,10 +453,10 @@ def adjustLateValues(self, payload): payload = payload.replace(SINGLE_QUOTE_MARKER, "'") for _ in set(re.findall(r"\[RANDNUM(?:\d+)?\]", payload, re.I)): - payload = payload.replace(_, str(randomInt())) + payload = payload.replace(_, _markerSafeRandom(randomInt)) for _ in set(re.findall(r"\[RANDSTR(?:\d+)?\]", payload, re.I)): - payload = payload.replace(_, randomStr()) + payload = payload.replace(_, _markerSafeRandom(randomStr)) if hashDBRetrieve(HASHDB_KEYS.DBMS_FORK) in (FORK.MEMSQL, FORK.TIDB, FORK.DRIZZLE): payload = re.sub(r"(?i)\bORD\(", "ASCII(", payload) diff --git a/lib/core/enums.py b/lib/core/enums.py index 15c5763f69b..899bf51f747 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -352,18 +352,17 @@ class PAYLOAD(object): 3: "High risk", } - CLAUSE = { - 0: "Always", - 1: "WHERE", - 2: "GROUP BY", - 3: "ORDER BY", - 4: "LIMIT", - 5: "OFFSET", - 6: "TOP", - 7: "Table name", - 8: "Column name", - 9: "Pre-WHERE (non-query)", - } + class CLAUSE(object): + ALWAYS = 0 + WHERE = 1 + GROUP_BY = 2 + ORDER_BY = 3 + LIMIT = 4 + OFFSET = 5 + TOP = 6 + TABLE_NAME = 7 + COLUMN_NAME = 8 + PRE_WHERE = 9 class METHOD(object): COMPARISON = "comparison" diff --git a/lib/core/settings.py b/lib/core/settings.py index a0fd2641eaf..95e1469ec7e 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.10.2" +VERSION = "1.10.10.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/tamper/between.py b/tamper/between.py index 9999d5db1bd..00da028c65b 100644 --- a/tamper/between.py +++ b/tamper/between.py @@ -38,6 +38,8 @@ def tamper(payload, **kwargs): '1 AND A BETWEEN B AND B--' >>> tamper('1 AND LAST_INSERT_ROWID()=LAST_INSERT_ROWID()') '1 AND LAST_INSERT_ROWID() BETWEEN LAST_INSERT_ROWID() AND LAST_INSERT_ROWID()' + >>> tamper('CASE WHEN 1=2 THEN id ELSE 0 END') + 'CASE WHEN 1 BETWEEN 2 AND 2 THEN id ELSE 0 END' """ retVal = payload @@ -58,4 +60,7 @@ def tamper(payload, **kwargs): _ = "%s %s BETWEEN %s AND %s" % (match.group(2), match.group(4), match.group(5), match.group(5)) retVal = retVal.replace(match.group(0), _) + # e.g. CASE WHEN 1=1 THEN ... (expression-position payloads with no AND/OR to anchor on) + retVal = re.sub(r"(?i)(\bWHEN\s+)([\w.]+)\s*(?!])=(?!=)\s*([\w.]+)(?=\s)", r"\g<1>\g<2> BETWEEN \g<3> AND \g<3>", retVal) + return retVal diff --git a/tests/test_agent.py b/tests/test_agent.py index ed7463ba69d..1a6f6882ef1 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -556,6 +556,21 @@ def test_original_marker_raw_substitution(self): out = agent.cleanupPayload("p=[ORIGINAL]", origValue="raw") self.assertEqual(out, "p=raw") + def test_origvalue_identifier_raw_for_group_order_by(self): + self.assertEqual(agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[2, 3]), "p.name") + self.assertIn("'p.name'", agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[1, 2, 3])) + + def test_random_values_avoid_string_marker(self): + # a reflected [RANDNUM] (e.g. quoted back in a DBMS error message) containing the --string + # marker would fake a True response (e.g. --string=98 vs. 8988) + saved = conf.string + conf.string = "9" + try: + for _ in range(200): + self.assertNotIn("9", agent.cleanupPayload("[RANDNUM] [RANDNUM1]")) + finally: + conf.string = saved + def test_space_replace_marker(self): out = agent.cleanupPayload("a[SPACE_REPLACE]b") self.assertEqual(out, "a%sb" % kb.chars.space) From 0cf2f6db07dfea774d9d2453e0d7955d1a9dc476 Mon Sep 17 00:00:00 2001 From: Bharath Vemula Date: Sat, 17 Oct 2026 04:02:34 -0700 Subject: [PATCH 6/7] Fix REST API multi-target scans returning only the last target's results (#2503) (#6146) A scan covering multiple targets inside a single API task (crawl, '-m' bulk file, forms) ran every target through one StdDbOut keyed by the single taskid, so _storeData()'s COMPLETE branch deleted each earlier target's TARGET/TECHNIQUES rows - /scan//data returned only the last target's results. Mirror the --report-json fix for #6123: controller.start() now sets kb.reportTaskId per target for API scans too, StdDbOut.write() keys data rows '-' per target, and scan_data() folds the per-target rows back into the task's flat result in target order. Single-target scans keep the exact same response shape. Regression tests in tests/test_api.py (TestStdDbOutMultiTarget, TestScanDataMultiTarget). --- lib/controller/controller.py | 7 ++-- lib/utils/api.py | 24 ++++++++++- tests/test_api.py | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/lib/controller/controller.py b/lib/controller/controller.py index 21df0f75072..0bd5cc64dd8 100644 --- a/lib/controller/controller.py +++ b/lib/controller/controller.py @@ -376,9 +376,10 @@ def start(): for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets: targetCount += 1 - # --report-json: give each target its own taskid in the shared collector, so a multi-target - # run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones (see _reportData) - if conf.reportJson: + # --report-json / REST API: give each target its own taskid in the shared collector, so a + # multi-target run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones + # (see _reportData for the report collector and StdDbOut.write for the REST API IPC database) + if conf.reportJson or conf.api: kb.reportTaskId = targetCount try: diff --git a/lib/utils/api.py b/lib/utils/api.py index 76cef47321e..d58ab26dbef 100644 --- a/lib/utils/api.py +++ b/lib/utils/api.py @@ -444,7 +444,17 @@ def __init__(self, taskid, messagetype="stdout"): def write(self, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None): if self.messagetype == "stdout": - _storeData(conf.databaseCursor, self.taskid, value, status, content_type) + # A multi-target scan (e.g. crawling or a '-m' bulk file inside a single API task) runs + # every target through this one StdDbOut instance. Key each target's data rows by + # kb.reportTaskId (set per target in controller.start()) so that a later target's + # COMPLETE row cannot delete an earlier target's rows of the same content_type in + # _storeData() - the REST API counterpart of the --report-json fix for #6123, and the + # reason a crawl via sqlmapapi used to return only the last target's results (#2503). + # scan_data() folds the per-target rows back into the task's flat result. + taskid = self.taskid + if kb.get("reportTaskId"): + taskid = "%s-%s" % (self.taskid, kb.reportTaskId) + _storeData(conf.databaseCursor, taskid, value, status, content_type) else: conf.databaseCursor.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (self.taskid, str(value) if value else "")) @@ -818,6 +828,18 @@ def scan_data(taskid): # Read all data and error messages from the IPC database (shared assembler - same output as --report-json) result = _assembleData(DataStore.current_db, taskid) + # Multi-target scans store each target's data rows under a '-' key (see + # StdDbOut.write); fold them into this task's flat result, in target order. Real taskids + # are dash-less hex strings, so a '-' prefix can only match this task's own rows. + prefix = "%s-" % taskid + subtaskids = [] + for row in DataStore.current_db.execute("SELECT DISTINCT taskid FROM data"): + stored = str(row[0]) + if stored.startswith(prefix) and stored[len(prefix):].isdigit(): + subtaskids.append(stored) + for subtaskid in sorted(subtaskids, key=lambda _: int(_.rsplit("-", 1)[1])): + result["data"].extend(_assembleData(DataStore.current_db, subtaskid)["data"]) + logger.debug("(%s) Retrieved scan data and error messages" % taskid) return jsonize(result) diff --git a/tests/test_api.py b/tests/test_api.py index 4360c3e7058..8668559e24c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -30,6 +30,7 @@ import lib.utils.api as api from lib.core.data import conf +from lib.core.data import kb from lib.core.convert import encodeBase64 from lib.core.enums import CONTENT_STATUS, CONTENT_TYPE from thirdparty.bottle.bottle import default_app @@ -308,6 +309,62 @@ def test_logrecorder_emit_stores_log(self): self.assertEqual(rows[0][2], "hello world") +class TestStdDbOutMultiTarget(unittest.TestCase): + """ + Regression coverage for issue #2503: a multi-target scan (crawl or '-m' bulk + file) inside a single REST API task runs every target through one StdDbOut + instance. StdDbOut.write() keys each target's data rows by kb.reportTaskId + ('-'), so a later target's COMPLETE row cannot delete an earlier + target's rows of the same content_type in _storeData() - mirroring the + --report-json fix for #6123 (see test_report.py's TestMultiTargetTaskId). + """ + + def setUp(self): + self.db = api.Database(":memory:") + self.db.connect("client") + self.db.init() + self._saved_stdout = sys.stdout + self._saved_cursor = conf.get("databaseCursor") + self._saved_partRun = kb.get("partRun") + self._had_reportTaskId = "reportTaskId" in kb + self._saved_reportTaskId = kb.get("reportTaskId") + conf.databaseCursor = self.db + kb.partRun = None + + def tearDown(self): + sys.stdout = self._saved_stdout + conf.databaseCursor = self._saved_cursor + kb.partRun = self._saved_partRun + if self._had_reportTaskId: + kb.reportTaskId = self._saved_reportTaskId + elif "reportTaskId" in kb: + del kb.reportTaskId + self.db.disconnect() + + def test_second_target_does_not_overwrite_first(self): + std = api.StdDbOut("t1", messagetype="stdout") + sys.stdout = self._saved_stdout # StdDbOut hijacked it; call write() directly + + kb.reportTaskId = 1 + std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET) + kb.reportTaskId = 2 + std.write({"url": "http://host2/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET) + + rows = self.db.execute("SELECT taskid FROM data ORDER BY id") + self.assertEqual([row[0] for row in rows], ["t1-1", "t1-2"]) # neither target's row was deleted + + def test_single_target_without_report_task_id_keeps_plain_taskid(self): + if "reportTaskId" in kb: + del kb.reportTaskId + std = api.StdDbOut("t1", messagetype="stdout") + sys.stdout = self._saved_stdout + + std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET) + + rows = self.db.execute("SELECT taskid FROM data") + self.assertEqual([row[0] for row in rows], ["t1"]) + + # --------------------------------------------------------------------------- # HTTP routes (WSGI test client) # --------------------------------------------------------------------------- @@ -485,6 +542,26 @@ def test_scan_kill_invalid_when_not_running(self): self.assertFalse(parsed["success"]) +class TestScanDataMultiTarget(_ApiServerCase): + """scan_data() folds the per-target '-' rows (see StdDbOut.write) back into the task's flat result, in target order.""" + + def test_data_merges_per_target_rows(self): + taskid = self._new_task() + api._storeData(self.db, "%s-2" % taskid, {"url": "http://host2/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET) + api._storeData(self.db, "%s-1" % taskid, {"url": "http://host1/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET) + code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertEqual([d["value"]["url"] for d in parsed["data"]], ["http://host1/?id=1", "http://host2/?id=1"]) + + def test_data_ignores_other_tasks_rows(self): + taskid = self._new_task() + api._storeData(self.db, "othertask-1", {"url": "http://other/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET) + code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["data"], []) + + class TestScanStart(_ApiServerCase): """scan_start, with the subprocess-spawning seam (engine_start) monkeypatched.""" From 9a1660aa120c526f68b637e5f232137722353522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Sat, 17 Oct 2026 13:06:38 +0200 Subject: [PATCH 7/7] Minor cleaning after #6146 --- lib/controller/controller.py | 5 ++--- lib/core/settings.py | 2 +- lib/utils/api.py | 24 +++++------------------- tests/test_api.py | 3 ++- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/lib/controller/controller.py b/lib/controller/controller.py index 0bd5cc64dd8..69bba347fc3 100644 --- a/lib/controller/controller.py +++ b/lib/controller/controller.py @@ -376,9 +376,8 @@ def start(): for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets: targetCount += 1 - # --report-json / REST API: give each target its own taskid in the shared collector, so a - # multi-target run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones - # (see _reportData for the report collector and StdDbOut.write for the REST API IPC database) + # --report-json / REST API: give each target its own taskid in the shared collector, so a multi-target + # run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones (see _reportData/StdDbOut.write) if conf.reportJson or conf.api: kb.reportTaskId = targetCount diff --git a/lib/core/settings.py b/lib/core/settings.py index 95e1469ec7e..5e0accc99fe 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.10.3" +VERSION = "1.10.10.4" 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/api.py b/lib/utils/api.py index d58ab26dbef..6048047e56e 100644 --- a/lib/utils/api.py +++ b/lib/utils/api.py @@ -444,16 +444,8 @@ def __init__(self, taskid, messagetype="stdout"): def write(self, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None): if self.messagetype == "stdout": - # A multi-target scan (e.g. crawling or a '-m' bulk file inside a single API task) runs - # every target through this one StdDbOut instance. Key each target's data rows by - # kb.reportTaskId (set per target in controller.start()) so that a later target's - # COMPLETE row cannot delete an earlier target's rows of the same content_type in - # _storeData() - the REST API counterpart of the --report-json fix for #6123, and the - # reason a crawl via sqlmapapi used to return only the last target's results (#2503). - # scan_data() folds the per-target rows back into the task's flat result. - taskid = self.taskid - if kb.get("reportTaskId"): - taskid = "%s-%s" % (self.taskid, kb.reportTaskId) + # Note: per-target key in multi-target scans, so a later target's COMPLETE row doesn't delete an earlier one's (#2503) + taskid = "%s-%s" % (self.taskid, kb.reportTaskId) if kb.get("reportTaskId") else self.taskid _storeData(conf.databaseCursor, taskid, value, status, content_type) else: conf.databaseCursor.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (self.taskid, str(value) if value else "")) @@ -828,16 +820,10 @@ def scan_data(taskid): # Read all data and error messages from the IPC database (shared assembler - same output as --report-json) result = _assembleData(DataStore.current_db, taskid) - # Multi-target scans store each target's data rows under a '-' key (see - # StdDbOut.write); fold them into this task's flat result, in target order. Real taskids - # are dash-less hex strings, so a '-' prefix can only match this task's own rows. + # Note: fold in per-target rows ('-', see StdDbOut.write) of multi-target scans, in target order prefix = "%s-" % taskid - subtaskids = [] - for row in DataStore.current_db.execute("SELECT DISTINCT taskid FROM data"): - stored = str(row[0]) - if stored.startswith(prefix) and stored[len(prefix):].isdigit(): - subtaskids.append(stored) - for subtaskid in sorted(subtaskids, key=lambda _: int(_.rsplit("-", 1)[1])): + subtaskids = [_[0] for _ in DataStore.current_db.execute("SELECT DISTINCT taskid FROM data WHERE taskid LIKE ?", (prefix + '%',)) if _[0][len(prefix):].isdigit()] + for subtaskid in sorted(subtaskids, key=lambda _: int(_[len(prefix):])): result["data"].extend(_assembleData(DataStore.current_db, subtaskid)["data"]) logger.debug("(%s) Retrieved scan data and error messages" % taskid) diff --git a/tests/test_api.py b/tests/test_api.py index 8668559e24c..0a76bd7684a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -353,7 +353,8 @@ def test_second_target_does_not_overwrite_first(self): rows = self.db.execute("SELECT taskid FROM data ORDER BY id") self.assertEqual([row[0] for row in rows], ["t1-1", "t1-2"]) # neither target's row was deleted - def test_single_target_without_report_task_id_keeps_plain_taskid(self): + def test_without_report_task_id_keeps_plain_taskid(self): + # e.g. output written before controller.start() reaches its target loop if "reportTaskId" in kb: del kb.reportTaskId std = api.StdDbOut("t1", messagetype="stdout")