Skip to content

Commit 76ddd7a

Browse files
committed
Fixes #6097
1 parent 9f79887 commit 76ddd7a

5 files changed

Lines changed: 147 additions & 47 deletions

File tree

lib/core/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from thirdparty import six
2121

2222
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
23-
VERSION = "1.10.8.38"
23+
VERSION = "1.10.8.39"
2424
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
2525
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
2626
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)

lib/utils/keysetdump.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ def _lit(value):
149149
(e.g. 0x.. hex) form for string keys. Both forms are self-contained (no surrounding quotes).
150150
"""
151151

152-
if value is not None and re.match(r"\A-?[0-9]+\Z", value):
152+
if value is None:
153+
return NULL # unescaper.escape() passes None through, and a bare
154+
# None formatted into a predicate is not even SQL
155+
if re.match(r"\A-?[0-9]+\Z", value):
153156
return value
154157
return unescaper.escape(value, False)
155158

@@ -161,18 +164,24 @@ def _embed(template, value, *fixed):
161164
template = template.replace("'%s'", "%s")
162165
return template % (fixed + (_lit(value),))
163166

164-
def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
165-
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
166-
field = agent.preprocessField(tbl, cursor)
167+
def _target(count):
168+
"""Rows the walk is expected to produce, honouring --start/--stop."""
167169

168170
if conf.limitStart and conf.limitStop:
169-
target = max(0, conf.limitStop - conf.limitStart + 1)
171+
return max(0, conf.limitStop - conf.limitStart + 1)
170172
elif conf.limitStop:
171-
target = conf.limitStop
173+
return conf.limitStop
172174
elif conf.limitStart:
173-
target = max(0, count - conf.limitStart + 1)
174-
else:
175-
target = count
175+
return max(0, count - conf.limitStart + 1)
176+
177+
return count
178+
179+
def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
180+
"""False when the walk gave up mid-table (the caller then discards the partial result)."""
181+
182+
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
183+
field = agent.preprocessField(tbl, cursor)
184+
target = _target(count)
176185

177186
pivotValue = None
178187

@@ -182,7 +191,7 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
182191
seed = unArrayizeValue(inject.getValue(query))
183192

184193
if isNoneValue(seed) or seed == NULL:
185-
return
194+
return False # no seed, no walk - and an empty table is not that
186195

187196
pivotValue = safechardecode(seed)
188197

@@ -205,7 +214,7 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
205214
# safety latch against a non-advancing cursor (e.g. encoding edge cases)
206215
if value == pivotValue:
207216
singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
208-
break
217+
return False
209218

210219
pivotValue = value
211220

@@ -223,20 +232,17 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
223232

224233
produced += 1
225234

235+
return True
236+
226237
def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
238+
"""False when the walk gave up mid-table (the caller then discards the partial result)."""
239+
227240
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
228241
fields = [agent.preprocessField(tbl, _) for _ in cursorCols]
229242
orderExpr = ','.join(fields)
230243

231244
startSkip = (conf.limitStart - 1) if conf.limitStart else 0
232-
if conf.limitStart and conf.limitStop:
233-
target = max(0, conf.limitStop - conf.limitStart + 1)
234-
elif conf.limitStop:
235-
target = conf.limitStop
236-
elif conf.limitStart:
237-
target = max(0, count - conf.limitStart + 1)
238-
else:
239-
target = count
245+
target = _target(count)
240246

241247
prev = None
242248
produced = 0
@@ -263,11 +269,18 @@ def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
263269
tup.append(None if isNoneValue(value) else safechardecode(value))
264270

265271
if all(isNoneValue(_) for _ in tup):
266-
break
272+
break # nothing past the cursor: the table is walked
273+
274+
# A key column that did not come back (an error-channel miss, a blocked payload) cannot be
275+
# seeked on, and its equality would pin the rest of the row to a NULL - so the walk stops
276+
# here rather than emitting a row of empty cells and carrying the hole into the next seek
277+
if any(isNoneValue(_) for _ in tup):
278+
singleTimeWarnMessage("keyset cursor could not be retrieved for one of the key column(s)")
279+
return False
267280

268281
if prev is not None and tup == prev:
269282
singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
270-
break
283+
return False
271284

272285
prev = tup
273286
seen += 1
@@ -290,6 +303,8 @@ def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
290303

291304
produced += 1
292305

306+
return True
307+
293308
def keysetDumpTable(tbl, colList, count, cursor):
294309
"""
295310
Dumps a table one row at a time using keyset (seek) pagination on 'cursor' (a list of
@@ -298,6 +313,10 @@ def keysetDumpTable(tbl, colList, count, cursor):
298313
exact equality on the cursor (index point seek), so no row is skipped via OFFSET and no
299314
per-row ORDER BY filesort is needed. A deep --start uses a single OFFSET "seed" jump
300315
(single-column cursors), after which the walk is pure keyset.
316+
317+
Returns None when the walk gave up mid-table (a key value that did not come back, a cursor
318+
that stopped advancing): a short table is worse than a slow one, so the caller redoes it
319+
with the standard OFFSET dump instead of showing whatever was reached.
301320
"""
302321

303322
tableRef = _tableRef(tbl)
@@ -309,9 +328,16 @@ def keysetDumpTable(tbl, colList, count, cursor):
309328
entries[column] = BigArray()
310329

311330
if len(cursor) == 1:
312-
_dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths)
331+
complete = _dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths)
313332
else:
314-
_dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths)
333+
complete = _dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths)
334+
335+
if not complete:
336+
warnMsg = "keyset pagination did not complete for table '%s', " % unsafeSQLIdentificatorNaming(tbl)
337+
warnMsg += "falling back to the standard dump"
338+
logger.warning(warnMsg)
339+
340+
return None
315341

316342
debugMsg = "keyset pagination retrieved %d row(s) for table '%s'" % (len(entries[colList[0]]) if colList and colList[0] in entries else 0, unsafeSQLIdentificatorNaming(tbl))
317343
logger.debug(debugMsg)

plugins/generic/entries.py

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,17 @@ def _dumpCountQuery():
231231
logger.info(infoMsg)
232232

233233
try:
234-
entries, lengths = keysetDumpTable(tbl, colList, int(count), keysetCursor)
235-
for column, columnEntries in entries.items():
236-
length = max(lengths[column], getConsoleLength(column))
237-
kb.data.dumpedTable[column] = {"length": length, "values": columnEntries}
238-
entriesCount = len(columnEntries)
239-
keysetDone = bool(kb.data.dumpedTable)
234+
# None when the walk gave up mid-table: nothing is kept, so the
235+
# standard dump below redoes it rather than showing a short table
236+
result = keysetDumpTable(tbl, colList, int(count), keysetCursor)
237+
238+
if result is not None:
239+
entries, lengths = result
240+
for column, columnEntries in entries.items():
241+
length = max(lengths[column], getConsoleLength(column))
242+
kb.data.dumpedTable[column] = {"length": length, "values": columnEntries}
243+
entriesCount = len(columnEntries)
244+
keysetDone = bool(kb.data.dumpedTable)
240245
except KeyboardInterrupt:
241246
kb.dumpKeyboardInterrupt = True
242247
clearConsoleLine()
@@ -379,6 +384,26 @@ def _dumpCountQuery():
379384
lengths = {}
380385
entries = {}
381386

387+
# Attempted before the chain below so that a walk which gave up mid-table (None) can
388+
# fall through to the standard OFFSET paths - a short table is worse than a slow one.
389+
# An interrupted walk keeps the chain out of a re-dump by claiming the branch itself.
390+
keysetResult = None
391+
392+
if keysetCursor:
393+
infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor)
394+
infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl)
395+
logger.info(infoMsg)
396+
397+
try:
398+
keysetResult = keysetDumpTable(tbl, colList, count, keysetCursor)
399+
except KeyboardInterrupt:
400+
kb.dumpKeyboardInterrupt = True
401+
clearConsoleLine()
402+
warnMsg = "Ctrl+C detected in dumping phase"
403+
logger.warning(warnMsg)
404+
405+
keysetResult = (entries, lengths)
406+
382407
if count == 0:
383408
warnMsg = "table '%s' " % unsafeSQLIdentificatorNaming(tbl)
384409
warnMsg += "in database '%s' " % unsafeSQLIdentificatorNaming(conf.db)
@@ -399,18 +424,8 @@ def _dumpCountQuery():
399424

400425
continue
401426

402-
elif keysetCursor:
403-
infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor)
404-
infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl)
405-
logger.info(infoMsg)
406-
407-
try:
408-
entries, lengths = keysetDumpTable(tbl, colList, count, keysetCursor)
409-
except KeyboardInterrupt:
410-
kb.dumpKeyboardInterrupt = True
411-
clearConsoleLine()
412-
warnMsg = "Ctrl+C detected in dumping phase"
413-
logger.warning(warnMsg)
427+
elif keysetResult is not None:
428+
entries, lengths = keysetResult
414429

415430
elif Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.SYBASE, DBMS.MAXDB, DBMS.MSSQL, DBMS.INFORMIX, DBMS.MCKOI, DBMS.RAIMA):
416431
if Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.MCKOI, DBMS.RAIMA):

tests/test_entries.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,46 @@ def gv(query, *a, **k):
765765
self.assertEqual(list(dumped["id"]["values"]), ["1", "2"])
766766
self.assertEqual(list(dumped["name"]["values"]), ["alice", "bob"])
767767

768+
def test_dump_table_inference_keyset_giving_up_falls_back(self):
769+
# A keyset walk that gives up mid-table hands back None (issue #6097: a key value that did
770+
# not come back). The table must then be dumped by the standard OFFSET path IN FULL - a
771+
# short table would otherwise be presented as the whole thing.
772+
set_dbms("MySQL")
773+
e = self._entries(cols=("id", "name"))
774+
conf.db = "testdb"
775+
conf.tbl = "users"
776+
conf.col = None
777+
conf.noKeyset = False
778+
conf.keyset = True # keyset regardless of the row-count threshold
779+
780+
savedResolve, savedDump = emod.resolveKeysetCursor, emod.keysetDumpTable
781+
attempted = []
782+
emod.resolveKeysetCursor = lambda tbl, colList: ["id"]
783+
emod.keysetDumpTable = lambda *a, **k: attempted.append(a) or None # gave up, nothing kept
784+
785+
data = {0: {"id": "1", "name": "alice"}, 1: {"id": "2", "name": "bob"}}
786+
787+
def gv(query, *a, **k):
788+
if k.get("expected") == EXPECTED.INT:
789+
return "2"
790+
import re as _re
791+
idx = int(_re.search(r"LIMIT\s+(\d+)\s*,\s*1", query).group(1))
792+
proj = query.split(" FROM ", 1)[0]
793+
return data[idx]["name" if "name" in proj else "id"]
794+
795+
emod.inject.getValue = gv
796+
797+
try:
798+
e.dumpTable()
799+
finally:
800+
emod.resolveKeysetCursor, emod.keysetDumpTable = savedResolve, savedDump
801+
802+
self.assertEqual(len(attempted), 1) # the walk really was tried, then discarded
803+
dumped = conf.dumper.tableValues[-1]
804+
self.assertEqual(dumped["__infos__"]["count"], 2)
805+
self.assertEqual(list(dumped["id"]["values"]), ["1", "2"])
806+
self.assertEqual(list(dumped["name"]["values"]), ["alice", "bob"])
807+
768808
def test_dump_table_inference_empty_table(self):
769809
# A zero row count in the inference path yields empty per-column value
770810
# lists and no dbTableValues emission (dumpedTable stays effectively empty).

tests/test_keyset_engine.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,19 @@ def tearDown(self):
6868
kb.data.cachedColumns = self._s["cachedColumns"]
6969
inject.getValue = self._s["gv"]
7070

71-
def _install_oracle(self, rowValueSupported):
71+
def _install_oracle(self, rowValueSupported, dropped=()):
7272
def oracle(query=None, **kwargs):
7373
# a back-end without ANSI row-value support errors on (a,b)>(x,y) -> no result
7474
if re.search(r"\)\s*>\s*\(", query or "") and not rowValueSupported:
7575
return None
7676
m = re.search(r"SELECT (\w+) FROM .+? WHERE (.+) ORDER BY .+ LIMIT 1", query or "") # advance
7777
if m:
7878
cand = sorted(r for r in _ROWS if _condTrue(m.group(2), r))
79-
return None if not cand else str(cand[0][_COL_INDEX[m.group(1)]])
79+
if not cand:
80+
return None
81+
if (m.group(1), cand[0]) in dropped: # a key cell the channel did not bring back
82+
return None
83+
return str(cand[0][_COL_INDEX[m.group(1)]])
8084
m = re.search(r"SELECT MAX\((\w+)\) FROM .+? WHERE (.+)", query or "") # point fetch
8185
if m:
8286
cand = [r for r in _ROWS if _condTrue(m.group(2), r)]
@@ -85,9 +89,16 @@ def oracle(query=None, **kwargs):
8589

8690
inject.getValue = oracle
8791

88-
def _dump(self, rowValueSupported):
89-
self._install_oracle(rowValueSupported)
90-
entries, _ = ks.keysetDumpTable("users", ["a", "b", "d"], len(_ROWS), ["a", "b"])
92+
def _walk(self, rowValueSupported, dropped=()):
93+
"""The raw result: (entries, lengths), or None when the walk gave up and the caller must fall back."""
94+
95+
self._install_oracle(rowValueSupported, dropped)
96+
97+
return ks.keysetDumpTable("users", ["a", "b", "d"], len(_ROWS), ["a", "b"])
98+
99+
def _dump(self, rowValueSupported, dropped=()):
100+
entries, _ = self._walk(rowValueSupported, dropped)
101+
91102
return list(zip(entries["a"], entries["b"], entries["d"]))
92103

93104
def test_all_rows_when_row_value_supported(self):
@@ -100,6 +111,14 @@ def test_all_rows_when_row_value_rejected(self):
100111
self.assertEqual(len(rows), len(_ROWS))
101112
self.assertEqual([r[2] for r in rows], [r[2] for r in _ROWS])
102113

114+
def test_unretrieved_key_cell_is_handed_back_for_the_offset_fallback(self):
115+
# one key column of a row that does not come back (an error-channel miss, a blocked payload)
116+
# leaves a None in the cursor tuple. Seeking on it is impossible, so the walk must hand back
117+
# NOTHING - it used to format that None into the next seek predicate (a TypeError, issue
118+
# #6097) and, once the predicate became a plain '%s', to emit a row of empty cells and then
119+
# silently truncate the table
120+
self.assertIsNone(self._walk(rowValueSupported=True, dropped={("b", (2, 5, "gamma"))}))
121+
103122

104123
if __name__ == "__main__":
105124
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)