diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt
index 14288ba5a..8dc1cb622 100644
--- a/data/txt/sha256sums.txt
+++ b/data/txt/sha256sums.txt
@@ -189,7 +189,7 @@ ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch
48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
-fcb89f3b6474c6201fe2a77417c5c422e4f81a5f44567a51fb05eb6f6df22e93 lib/core/settings.py
+8411f42e10133c779cff837c6e51698cfebe0796f93ca9e3575a5644d64a3e04 lib/core/settings.py
cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py
bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py
70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py
@@ -253,6 +253,7 @@ a94958be0ec3e9d28d8171813a6a90655a9ad7e6aa33c661e8d8ebbfcf208dbb lib/utils/deps
0cd3860c03e39bacd1d0fe4cf1a0c605de48ff82f70441319f21d47e38e7e3a9 lib/utils/hashdb.py
71a66ff766a2921106770b26acff380de469222dc893816a7b970b384c927666 lib/utils/hash.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/utils/__init__.py
+1bbf57e43f921d4132e6e5a336ff39454a9506b36de94ebcc45879d0abcac56a lib/utils/keysetdump.py
04b28ad98340a589eb9b21d014c435e8193c2bea3a21af9875b6f23c9b270f1f lib/utils/pivotdumptable.py
c1dfc3bed0fed9b181f612d1d747955dd2b506dbe99bc9fd481495602371473a lib/utils/progress.py
c442e9ef8324fd6fdf7bc334d765f0a6ce4037397eb3d79d59b5ce3e9a043855 lib/utils/prove.py
diff --git a/data/xml/queries.xml b/data/xml/queries.xml
index cc26298ea..64e8823cc 100644
--- a/data/xml/queries.xml
+++ b/data/xml/queries.xml
@@ -61,7 +61,8 @@
-
+
+
@@ -136,7 +137,8 @@
-
+
+
@@ -207,7 +209,8 @@
-
+
+
@@ -302,7 +305,8 @@
-
+
+
@@ -362,7 +366,7 @@
-
+
@@ -726,7 +730,8 @@
-
+
+
@@ -790,7 +795,8 @@
-
+
+
@@ -1445,7 +1451,8 @@
-
+
+
diff --git a/lib/core/settings.py b/lib/core/settings.py
index da96022a6..d1a9cbae9 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.6.129"
+VERSION = "1.10.6.130"
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/testing.py b/lib/core/testing.py
index e082dce19..a640c9779 100644
--- a/lib/core/testing.py
+++ b/lib/core/testing.py
@@ -84,6 +84,7 @@ def vulnTest():
("-u --banner --schema --dump -T users --binary-fields=surname --where \"id>3\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "27 entries", "6E616D6569736E756C6C")),
("-u --technique=U --fresh-queries --force-partial --dump -T users --dump-format=HTML --answers=\"crack=n\" -v 3", ("performed 31 queries", "nameisnull", "~using default dictionary", "dumped to HTML file")),
("-u --flush-session --technique=BU --all", ("30 entries", "Type: boolean-based blind", "Type: UNION query", "luther", "blisset", "fluffy", "179ad45c6ce2cb97cf1029e212046e81", "NULL", "nameisnull", "testpass")),
+ ("-u --flush-session --technique=B --keyset --dump -T users", ("using keyset (seek) pagination", "30 entries", "luther", "nameisnull")), # keyset/seek dump via the SQLite rowid cursor
("-u -z \"tec=B\" --hex --fresh-queries --threads=4 --sql-query=\"SELECT * FROM users\"", ("SELECT * FROM users [30]", "nameisnull")),
("-u \"&echo=foobar*\" --flush-session", ("might be vulnerable to cross-site scripting",)),
("-u \"&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
diff --git a/lib/utils/keysetdump.py b/lib/utils/keysetdump.py
new file mode 100644
index 000000000..2b38f2c57
--- /dev/null
+++ b/lib/utils/keysetdump.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python
+
+"""
+Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
+See the file 'LICENSE' for copying permission
+"""
+
+import re
+
+from lib.core.agent import agent
+from lib.core.bigarray import BigArray
+from lib.core.common import Backend
+from lib.core.common import isNoneValue
+from lib.core.common import singleTimeWarnMessage
+from lib.core.common import unArrayizeValue
+from lib.core.common import unsafeSQLIdentificatorNaming
+from lib.core.compat import xrange
+from lib.core.convert import getConsoleLength
+from lib.core.convert import getUnicode
+from lib.core.data import conf
+from lib.core.data import logger
+from lib.core.data import queries
+from lib.core.dicts import DUMP_REPLACEMENTS
+from lib.core.enums import CHARSET_TYPE
+from lib.core.enums import DBMS
+from lib.core.enums import EXPECTED
+from lib.core.settings import NULL
+from lib.core.unescaper import unescaper
+from lib.request import inject
+from lib.utils.safe2bin import safechardecode
+
+# back-end DBMSes whose dump table reference is schema/database-qualified (db.table).
+# Note: for MSSQL the table identifier already carries its schema (e.g. dbo.users), so the
+# plain db.table form yields the correct db.schema.table (e.g. [master].dbo.users).
+KEYSET_SCHEMA_QUALIFIED = (DBMS.MYSQL, DBMS.PGSQL, DBMS.CRATEDB, DBMS.MSSQL, DBMS.H2, DBMS.HSQLDB)
+
+def _tableRef(tbl):
+ dbms = Backend.getIdentifiedDbms()
+ if dbms in (DBMS.ORACLE,) and conf.db:
+ return "%s.%s" % (conf.db.upper(), tbl.upper())
+ if dbms in KEYSET_SCHEMA_QUALIFIED and conf.db:
+ return "%s.%s" % (conf.db, tbl)
+ return tbl
+
+def keysetSupported():
+ """
+ Whether the back-end DBMS declares the keyset (seek) pagination queries and a
+ cursor source (a physical row-id pseudo-column or a primary-key catalog lookup)
+ """
+
+ dumpNode = queries[Backend.getIdentifiedDbms()].dump_table
+ return "keyset_next" in dumpNode.blind and ("rowid" in dumpNode.blind or "primary_key" in dumpNode)
+
+def _integerCursor(tbl, cursor):
+ """
+ Whether every cursor column holds integer values, probed via MIN(col).
+
+ Only integer keys are accepted: _embed() emits them as bare numeric literals, giving a
+ numeric comparison that matches MIN/ORDER BY. String (and even decimal) keys would be
+ escaped to a binary/hex literal whose order can differ from MIN's collation and silently
+ skip rows, so they are rejected here and fall back to the OFFSET dump.
+ """
+
+ blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
+ ref = _tableRef(tbl)
+
+ for column in cursor:
+ query = agent.whereQuery(blind.keyset_first % (agent.preprocessField(tbl, column), ref))
+ value = unArrayizeValue(inject.getValue(query))
+
+ # empty/NULL MIN (e.g. empty table) is not disqualifying; the walk just yields no rows
+ if not isNoneValue(value) and re.match(r"\A-?[0-9]+\Z", getUnicode(value).strip()) is None:
+ return False
+
+ return True
+
+def resolveKeysetCursor(tbl, colList):
+ """
+ Returns the list of column(s) forming a stable, indexed cursor for keyset (seek)
+ pagination of the table: a declared physical row-id pseudo-column when available,
+ otherwise the indexed primary key (single or composite) resolved from the catalog.
+ Returns None when neither applies or a key column is not part of the dumped columns.
+ """
+
+ if not keysetSupported():
+ return None
+
+ dumpNode = queries[Backend.getIdentifiedDbms()].dump_table
+
+ # 1) a declared physical row-id pseudo-column (always unique + indexed where supported)
+ if "rowid" in dumpNode.blind:
+ return [dumpNode.blind.rowid]
+
+ # 2) the indexed primary key (single-column, or composite when keyset_ordered is declared)
+ pkNode = dumpNode.primary_key
+
+ # Note: schema/table are string literals in the catalog lookups, so the unquoted
+ # (identifier-unescaped) names are used (the dump queries keep the quoted form)
+ unsafeDb = unsafeSQLIdentificatorNaming(conf.db)
+ unsafeTbl = unsafeSQLIdentificatorNaming(tbl)
+
+ # Note: no whereQuery() here - these are catalog (schema) lookups, so the data-row
+ # filter from --where must not be appended to them
+ query = pkNode.count % (unsafeDb, unsafeTbl)
+ count = inject.getValue(query, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
+
+ try:
+ count = int(count)
+ except (ValueError, TypeError):
+ return None
+
+ if count < 1:
+ return None
+
+ # composite keys require the row-value/ordered keyset form
+ if count > 1 and "keyset_ordered" not in dumpNode.blind:
+ return None
+
+ cursor = []
+ for index in xrange(count):
+ query = pkNode.query % (unsafeDb, unsafeTbl, index)
+ column = unArrayizeValue(inject.getValue(query))
+
+ if not column:
+ return None
+
+ match = None
+ for _ in colList:
+ if _ and _.lower() == column.lower():
+ match = _
+ break
+
+ if match is None:
+ return None
+
+ cursor.append(match)
+
+ # restrict to integer cursors: a string key's escaped-literal comparison may order
+ # differently than MIN/ORDER BY and silently skip rows (such keys fall back to OFFSET)
+ if not _integerCursor(tbl, cursor):
+ return None
+
+ return cursor
+
+def _lit(value):
+ """
+ Type-correct SQL literal for a cursor value: a bare numeric literal for numeric keys
+ (so the index is still used and the comparison is numeric), otherwise the DBMS-escaped
+ (e.g. 0x.. hex) form for string keys. Both forms are self-contained (no surrounding quotes).
+ """
+
+ if value is not None and re.match(r"\A-?[0-9]+\Z", value):
+ return value
+ return unescaper.escape(value, False)
+
+def _embed(template, value, *fixed):
+ """
+ Fills a single-column keyset template whose trailing placeholder is the cursor value.
+ """
+
+ template = template.replace("'%s'", "%s")
+ return template % (fixed + (_lit(value),))
+
+def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
+ blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
+ field = agent.preprocessField(tbl, cursor)
+
+ if conf.limitStart and conf.limitStop:
+ target = max(0, conf.limitStop - conf.limitStart + 1)
+ elif conf.limitStop:
+ target = conf.limitStop
+ elif conf.limitStart:
+ target = max(0, count - conf.limitStart + 1)
+ else:
+ target = count
+
+ pivotValue = None
+
+ # hybrid: a single OFFSET jump to seed the cursor just before --start, then pure keyset
+ if conf.limitStart and conf.limitStart > 1 and "keyset_seed" in blind:
+ query = agent.whereQuery(blind.keyset_seed % (field, tableRef, field, conf.limitStart - 2))
+ seed = unArrayizeValue(inject.getValue(query))
+
+ if isNoneValue(seed) or seed == NULL:
+ return
+
+ pivotValue = safechardecode(seed)
+
+ produced = 0
+
+ while produced < target:
+ if pivotValue is None:
+ query = blind.keyset_first % (field, tableRef)
+ else:
+ query = _embed(blind.keyset_next, pivotValue, field, tableRef, field)
+
+ query = agent.whereQuery(query)
+ value = unArrayizeValue(inject.getValue(query))
+
+ if isNoneValue(value) or value == NULL:
+ break
+
+ value = safechardecode(value)
+
+ # safety latch against a non-advancing cursor (e.g. encoding edge cases)
+ if value == pivotValue:
+ singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
+ break
+
+ pivotValue = value
+
+ for column in colList:
+ if column == cursor:
+ colValue = pivotValue
+ else:
+ query = _embed(blind.keyset_by, pivotValue, agent.preprocessField(tbl, column), tableRef, field)
+ query = agent.whereQuery(query)
+ colValue = unArrayizeValue(inject.getValue(query, dump=True))
+
+ colValue = "" if isNoneValue(colValue) else colValue
+ lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(colValue), getUnicode(colValue))))
+ entries[column].append(colValue)
+
+ produced += 1
+
+def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
+ blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
+ fields = [agent.preprocessField(tbl, _) for _ in cursorCols]
+ orderExpr = ','.join(fields)
+
+ startSkip = (conf.limitStart - 1) if conf.limitStart else 0
+ if conf.limitStart and conf.limitStop:
+ target = max(0, conf.limitStop - conf.limitStart + 1)
+ elif conf.limitStop:
+ target = conf.limitStop
+ elif conf.limitStart:
+ target = max(0, count - conf.limitStart + 1)
+ else:
+ target = count
+
+ prev = None
+ produced = 0
+ seen = 0
+
+ while produced < target and seen < count:
+ if prev is None:
+ condition = "1=1"
+ else:
+ # ANSI row-value (tuple) comparison advances the composite cursor lexicographically
+ condition = "(%s)>(%s)" % (orderExpr, ','.join(_lit(_) for _ in prev))
+
+ tup = []
+ for field in fields:
+ query = agent.whereQuery(blind.keyset_ordered % (field, tableRef, condition, orderExpr))
+ value = unArrayizeValue(inject.getValue(query))
+ tup.append(None if isNoneValue(value) else safechardecode(value))
+
+ if all(isNoneValue(_) for _ in tup):
+ break
+
+ if prev is not None and tup == prev:
+ singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
+ break
+
+ prev = tup
+ seen += 1
+
+ if seen <= startSkip:
+ continue
+
+ equals = " AND ".join("%s=%s" % (field, _lit(value)) for field, value in zip(fields, tup))
+
+ for column in colList:
+ if column in cursorCols:
+ colValue = tup[cursorCols.index(column)]
+ else:
+ query = agent.whereQuery(blind.keyset_where % (agent.preprocessField(tbl, column), tableRef, equals))
+ colValue = unArrayizeValue(inject.getValue(query, dump=True))
+
+ colValue = "" if isNoneValue(colValue) else colValue
+ lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(colValue), getUnicode(colValue))))
+ entries[column].append(colValue)
+
+ produced += 1
+
+def keysetDumpTable(tbl, colList, count, cursor):
+ """
+ Dumps a table one row at a time using keyset (seek) pagination on 'cursor' (a list of
+ one or more indexed key columns): the next row is reached with a >/row-value comparison
+ against the previous cursor (index range scan) and every other column is fetched with an
+ exact equality on the cursor (index point seek), so no row is skipped via OFFSET and no
+ per-row ORDER BY filesort is needed. A deep --start uses a single OFFSET "seed" jump
+ (single-column cursors), after which the walk is pure keyset.
+ """
+
+ tableRef = _tableRef(tbl)
+ lengths = {}
+ entries = {}
+
+ for column in colList:
+ lengths[column] = 0
+ entries[column] = BigArray()
+
+ if len(cursor) == 1:
+ _dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths)
+ else:
+ _dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths)
+
+ 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))
+ logger.debug(debugMsg)
+
+ return entries, lengths
diff --git a/lib/utils/pivotdumptable.py b/lib/utils/pivotdumptable.py
index d1f3b9eec..b1a10adf2 100644
--- a/lib/utils/pivotdumptable.py
+++ b/lib/utils/pivotdumptable.py
@@ -14,6 +14,7 @@ from lib.core.common import filterNone
from lib.core.common import getSafeExString
from lib.core.common import isNoneValue
from lib.core.common import isNumPosStrValue
+from lib.core.common import prioritySortColumns
from lib.core.common import singleTimeWarnMessage
from lib.core.common import unArrayizeValue
from lib.core.common import unsafeSQLIdentificatorNaming
@@ -29,7 +30,6 @@ from lib.core.enums import CHARSET_TYPE
from lib.core.enums import EXPECTED
from lib.core.exception import SqlmapConnectionException
from lib.core.exception import SqlmapNoneDataException
-from lib.core.settings import MAX_INT
from lib.core.settings import NULL
from lib.core.settings import SINGLE_QUOTE_MARKER
from lib.core.unescaper import unescaper
@@ -71,7 +71,7 @@ def pivotDumpTable(table, colList, count=None, blind=True, alias=None):
lengths[column] = 0
entries[column] = BigArray()
- colList = filterNone(sorted(colList, key=lambda x: len(x) if x else MAX_INT))
+ colList = prioritySortColumns(filterNone(colList))
if conf.pivotColumn:
for _ in colList:
diff --git a/plugins/generic/entries.py b/plugins/generic/entries.py
index 0c6f3ea4f..917814779 100644
--- a/plugins/generic/entries.py
+++ b/plugins/generic/entries.py
@@ -42,12 +42,15 @@ from lib.core.exception import SqlmapNoneDataException
from lib.core.exception import SqlmapUnsupportedFeatureException
from lib.core.settings import CHECK_ZERO_COLUMNS_THRESHOLD
from lib.core.settings import CURRENT_DB
+from lib.core.settings import KEYSET_MIN_ROWS
from lib.core.settings import METADB_SUFFIX
from lib.core.settings import NULL
from lib.core.settings import PLUS_ONE_DBMSES
from lib.core.settings import UPPER_CASE_DBMSES
from lib.request import inject
from lib.utils.hash import attackDumpedTable
+from lib.utils.keysetdump import keysetDumpTable
+from lib.utils.keysetdump import resolveKeysetCursor
from lib.utils.pivotdumptable import pivotDumpTable
from thirdparty import six
from thirdparty.six.moves import zip as _zip
@@ -309,6 +312,9 @@ class Entries(object):
count = inject.getValue(query, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
+ # keyset (seek) pagination: forced with --keyset, automatic for large tables, off with --no-keyset
+ keysetCursor = resolveKeysetCursor(tbl, colList) if (not conf.noKeyset and isNumPosStrValue(count) and (conf.keyset or int(count) >= KEYSET_MIN_ROWS)) else None
+
lengths = {}
entries = {}
@@ -332,6 +338,19 @@ class Entries(object):
continue
+ elif keysetCursor:
+ infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor)
+ infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl)
+ logger.info(infoMsg)
+
+ try:
+ entries, lengths = keysetDumpTable(tbl, colList, count, keysetCursor)
+ except KeyboardInterrupt:
+ kb.dumpKeyboardInterrupt = True
+ clearConsoleLine()
+ warnMsg = "Ctrl+C detected in dumping phase"
+ logger.warning(warnMsg)
+
elif Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.SYBASE, DBMS.MAXDB, DBMS.MSSQL, DBMS.INFORMIX, DBMS.MCKOI, DBMS.RAIMA):
if Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.MCKOI, DBMS.RAIMA):
table = tbl
@@ -411,17 +430,17 @@ class Entries(object):
entries[column] = BigArray()
if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE, DBMS.SPANNER):
- query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, conf.tbl, sorted(colList, key=len)[0], index)
+ query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, conf.tbl, prioritySortColumns(colList)[0], index)
elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE,):
query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), index)
elif Backend.getIdentifiedDbms() in (DBMS.MIMERSQL,):
- query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), sorted(colList, key=len)[0], index)
+ query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), prioritySortColumns(colList)[0], index)
elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.EXTREMEDB):
query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl, index)
elif Backend.isDbms(DBMS.FIREBIRD):
query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), tbl)
elif Backend.getIdentifiedDbms() in (DBMS.INFORMIX, DBMS.VIRTUOSO):
- query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl, sorted(colList, key=len)[0])
+ query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl, prioritySortColumns(colList)[0])
elif Backend.isDbms(DBMS.FRONTBASE):
query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl)
else: