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/controller/controller.py b/lib/controller/controller.py
index 21df0f75072..69bba347fc3 100644
--- a/lib/controller/controller.py
+++ b/lib/controller/controller.py
@@ -376,9 +376,9 @@ 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/StdDbOut.write)
+ if conf.reportJson or conf.api:
kb.reportTaskId = targetCount
try:
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 fed08b686c7..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.9.32"
+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/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/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:
diff --git a/lib/utils/api.py b/lib/utils/api.py
index 76cef47321e..6048047e56e 100644
--- a/lib/utils/api.py
+++ b/lib/utils/api.py
@@ -444,7 +444,9 @@ 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)
+ # 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 ""))
@@ -818,6 +820,12 @@ 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)
+ # Note: fold in per-target rows ('-', see StdDbOut.write) of multi-target scans, in target order
+ prefix = "%s-" % taskid
+ 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)
return jsonize(result)
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):
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)
diff --git a/tests/test_api.py b/tests/test_api.py
index 4360c3e7058..0a76bd7684a 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,63 @@ 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_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")
+ 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 +543,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."""
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_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):
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)