mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-06-29 04:50:58 +00:00
550 lines
20 KiB
Python
550 lines
20 KiB
Python
#!/usr/bin/env python
|
|
|
|
"""
|
|
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|
See the file 'LICENSE' for copying permission
|
|
|
|
Unit tests for plugins/generic/search.py (Search), exercising searchDb /
|
|
searchTable / searchColumn by MOCKING the injection layer
|
|
(lib.request.inject.getValue) and the dumper.
|
|
|
|
No network and no DBMS are involved: conf.direct=True selects the simple inband
|
|
branches (TestSearch), or conf.direct=False with a BOOLEAN injection state selects
|
|
the inference branches (TestSearchInference); inject.getValue is patched to return
|
|
canned rows in the exact shape the methods parse, and conf.dumper is replaced with
|
|
a recording stub so we can assert on what each method produced.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _testutils import bootstrap, set_dbms
|
|
|
|
bootstrap()
|
|
|
|
from lib.core.data import conf, kb
|
|
from lib.core.enums import EXPECTED, PAYLOAD
|
|
import plugins.generic.search as smod
|
|
import plugins.generic.entries as emod
|
|
from plugins.generic.search import Search
|
|
|
|
|
|
def _inference_gv(count, sequence):
|
|
"""Build an inject.getValue stub for blind inference branches.
|
|
|
|
Returns `count` (as str) whenever the caller asks for EXPECTED.INT, otherwise
|
|
yields the next item from `sequence` wrapped as a single-cell row ([value]),
|
|
cycling if exhausted. This mirrors the count-then-per-row contract of every
|
|
isInferenceAvailable() branch.
|
|
"""
|
|
state = {"i": 0}
|
|
|
|
def gv(query, *a, **k):
|
|
if k.get("expected") == EXPECTED.INT:
|
|
return str(count)
|
|
val = sequence[state["i"] % len(sequence)]
|
|
state["i"] += 1
|
|
return [val]
|
|
|
|
return gv
|
|
|
|
|
|
class _RecordingDumper(object):
|
|
"""Minimal stand-in for conf.dumper that records calls instead of printing/writing."""
|
|
|
|
def __init__(self):
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.listed = [] # (header, elements)
|
|
self.dbTablesArg = None
|
|
self.dbColumnsArg = None
|
|
self.dbTableColumnsArg = None
|
|
self.tableValues = []
|
|
|
|
def lister(self, header, elements, content_type=None, sort=True):
|
|
self.listed.append((header, list(elements) if elements else []))
|
|
|
|
def dbTables(self, dbTables):
|
|
self.dbTablesArg = dbTables
|
|
|
|
def dbColumns(self, dbColumnsDict, colConsider, dbs):
|
|
self.dbColumnsArg = (dbColumnsDict, colConsider, dbs)
|
|
|
|
def dbTableColumns(self, tableColumns, content_type=None):
|
|
self.dbTableColumnsArg = tableColumns
|
|
|
|
def dbTableValues(self, tableValues):
|
|
self.tableValues.append(tableValues)
|
|
|
|
|
|
class _TestSearch(Search):
|
|
"""Search with the cross-mixin collaborators it relies on stubbed out.
|
|
|
|
The real Search lives in a multiple-inheritance hierarchy; in isolation we
|
|
must supply likeOrExact/forceDbmsEnum/getCurrentDb/getColumns/dumpFoundTables/
|
|
excludeDbsList, mirroring the inputs the production mixins would provide.
|
|
"""
|
|
|
|
excludeDbsList = ["information_schema", "mysql"]
|
|
|
|
def __init__(self):
|
|
Search.__init__(self)
|
|
self.like = ('1', " LIKE '%%%s%%'")
|
|
self.dumpFoundTablesCalls = []
|
|
self.dumpFoundColumnCalls = []
|
|
self.getColumnsCalls = []
|
|
self._cannedColumns = {}
|
|
|
|
def likeOrExact(self, what):
|
|
return self.like
|
|
|
|
def forceDbmsEnum(self):
|
|
pass
|
|
|
|
def getCurrentDb(self):
|
|
return "testdb"
|
|
|
|
def dumpFoundTables(self, tables):
|
|
self.dumpFoundTablesCalls.append(tables)
|
|
|
|
def dumpFoundColumn(self, dbs, foundCols, colConsider):
|
|
self.dumpFoundColumnCalls.append((dbs, foundCols, colConsider))
|
|
|
|
def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False):
|
|
# Emulate column discovery by populating kb.data.cachedColumns for the
|
|
# currently-targeted conf.db/conf.tbl/conf.col, as the real plugin does.
|
|
self.getColumnsCalls.append((conf.db, conf.tbl, conf.col))
|
|
db, tbl, col = conf.db, conf.tbl, conf.col
|
|
if db and tbl:
|
|
kb.data.cachedColumns.setdefault(db, {}).setdefault(tbl, {})
|
|
kb.data.cachedColumns[db][tbl][col] = "varchar"
|
|
|
|
|
|
class _SearchEnumBase(unittest.TestCase):
|
|
def setUp(self):
|
|
# Save mutated globals
|
|
self._saved_conf = {k: conf.get(k) for k in (
|
|
"db", "tbl", "col", "direct", "excludeSysDbs", "exclude", "search",
|
|
"disableHashing", "noKeyset", "keyset", "forcePivoting",
|
|
)}
|
|
self._saved_dumper = conf.get("dumper")
|
|
self._search_getValue = smod.inject.getValue
|
|
self._entries_getValue = emod.inject.getValue
|
|
self._search_readInput = smod.readInput
|
|
self._entries_readInput = emod.readInput
|
|
self._saved_has_is = kb.data.get("has_information_schema")
|
|
self._saved_cachedColumns = kb.data.get("cachedColumns")
|
|
self._saved_cachedTables = kb.data.get("cachedTables")
|
|
self._saved_dumpedTable = kb.data.get("dumpedTable")
|
|
self._saved_dumpKbInt = kb.get("dumpKeyboardInterrupt")
|
|
self._saved_permissionFlag = kb.get("permissionFlag")
|
|
|
|
set_dbms("MySQL")
|
|
conf.direct = True
|
|
conf.excludeSysDbs = False
|
|
conf.exclude = None
|
|
conf.search = True
|
|
conf.disableHashing = True
|
|
conf.noKeyset = True
|
|
conf.keyset = False
|
|
conf.forcePivoting = False
|
|
conf.dumper = _RecordingDumper()
|
|
|
|
kb.data.has_information_schema = True
|
|
kb.data.cachedColumns = {}
|
|
kb.data.cachedTables = {}
|
|
kb.data.dumpedTable = {}
|
|
kb.dumpKeyboardInterrupt = False
|
|
kb.permissionFlag = False
|
|
|
|
# Non-interactive prompts: collapse readInput to its default.
|
|
def _readInput(message, default=None, checkBatch=True, boolean=False):
|
|
if boolean:
|
|
return True if (default in (None, 'Y', 'y', True)) else False
|
|
return default
|
|
smod.readInput = _readInput
|
|
emod.readInput = _readInput
|
|
|
|
def tearDown(self):
|
|
for k, v in self._saved_conf.items():
|
|
conf[k] = v
|
|
conf.dumper = self._saved_dumper
|
|
smod.inject.getValue = self._search_getValue
|
|
emod.inject.getValue = self._entries_getValue
|
|
smod.readInput = self._search_readInput
|
|
emod.readInput = self._entries_readInput
|
|
kb.data.has_information_schema = self._saved_has_is
|
|
kb.data.cachedColumns = self._saved_cachedColumns
|
|
kb.data.cachedTables = self._saved_cachedTables
|
|
kb.data.dumpedTable = self._saved_dumpedTable
|
|
kb.dumpKeyboardInterrupt = self._saved_dumpKbInt
|
|
kb.permissionFlag = self._saved_permissionFlag
|
|
|
|
|
|
class TestSearch(_SearchEnumBase):
|
|
# --- searchDb -----------------------------------------------------------
|
|
|
|
def test_search_db_found(self):
|
|
s = _TestSearch()
|
|
conf.db = "testdb"
|
|
# Feed identifiers that REQUIRE normalization: "select" is a reserved
|
|
# keyword and "weird db" contains a space; both force MySQL backtick
|
|
# quoting via safeSQLIdentificatorNaming. The plain "testdb" passes
|
|
# through unchanged. Asserting the quoted output proves the transform ran
|
|
# (a mock-echo would surface the raw, unquoted inputs instead).
|
|
smod.inject.getValue = lambda *a, **k: ["select", "weird db", "testdb"]
|
|
|
|
s.searchDb()
|
|
|
|
self.assertEqual(conf.dumper.listed[-1][0], "found databases")
|
|
self.assertEqual(conf.dumper.listed[-1][1], ["`select`", "`weird db`", "testdb"])
|
|
|
|
def test_search_db_multiple_terms(self):
|
|
s = _TestSearch()
|
|
conf.db = "foo,bar"
|
|
|
|
# Return a DISTINCT value per search term by keying off the query string:
|
|
# each term is folded into the generated query (search_db inband query),
|
|
# so "foo" vs "bar" produce different queries. This proves the method
|
|
# actually iterates over both terms and records the right match for each,
|
|
# rather than appending the same constant twice.
|
|
def gv(query, *a, **k):
|
|
if "foo" in query:
|
|
return "foo_db"
|
|
elif "bar" in query:
|
|
return "bar_db"
|
|
return None
|
|
|
|
smod.inject.getValue = gv
|
|
s.searchDb()
|
|
# Two search terms => one distinct value found per term, in order.
|
|
self.assertEqual(conf.dumper.listed[-1][1], ["foo_db", "bar_db"])
|
|
|
|
def test_search_db_none_value(self):
|
|
s = _TestSearch()
|
|
conf.db = "nope"
|
|
smod.inject.getValue = lambda *a, **k: None
|
|
s.searchDb()
|
|
self.assertEqual(conf.dumper.listed[-1][1], [])
|
|
|
|
def test_search_db_exclude_sys_dbs(self):
|
|
s = _TestSearch()
|
|
conf.db = "testdb"
|
|
conf.excludeSysDbs = True
|
|
seen = {}
|
|
|
|
def gv(query, *a, **k):
|
|
seen["query"] = query
|
|
return ["testdb"]
|
|
smod.inject.getValue = gv
|
|
|
|
s.searchDb()
|
|
# The exclusion clause for each system DB must be folded into the query.
|
|
self.assertIn("information_schema", seen["query"])
|
|
self.assertEqual(conf.dumper.listed[-1][1], ["testdb"])
|
|
|
|
# --- searchTable --------------------------------------------------------
|
|
|
|
def test_search_table_found_grouped_by_db(self):
|
|
s = _TestSearch()
|
|
conf.tbl = "users"
|
|
conf.db = None
|
|
smod.inject.getValue = lambda *a, **k: [["testdb", "users"], ["otherdb", "users"]]
|
|
|
|
s.searchTable()
|
|
|
|
self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"], "otherdb": ["users"]})
|
|
# dumpFoundTables is invoked with the same mapping.
|
|
self.assertEqual(s.dumpFoundTablesCalls[-1], {"testdb": ["users"], "otherdb": ["users"]})
|
|
|
|
def test_search_table_with_db_filter(self):
|
|
s = _TestSearch()
|
|
conf.tbl = "users"
|
|
conf.db = "testdb"
|
|
captured = {}
|
|
|
|
def gv(query, *a, **k):
|
|
captured["query"] = query
|
|
return [["testdb", "users"]]
|
|
smod.inject.getValue = gv
|
|
|
|
s.searchTable()
|
|
# conf.db present => a WHERE clause restricting to that db is appended.
|
|
self.assertIn("testdb", captured["query"])
|
|
self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"]})
|
|
|
|
def test_search_table_none_found(self):
|
|
s = _TestSearch()
|
|
conf.tbl = "ghost"
|
|
conf.db = None
|
|
smod.inject.getValue = lambda *a, **k: None
|
|
s.searchTable()
|
|
# No tables => dbTables/dumpFoundTables never called.
|
|
self.assertIsNone(conf.dumper.dbTablesArg)
|
|
self.assertEqual(s.dumpFoundTablesCalls, [])
|
|
|
|
# --- searchColumn -------------------------------------------------------
|
|
|
|
def test_search_column_db_and_tbl_provided(self):
|
|
s = _TestSearch()
|
|
conf.col = "password"
|
|
conf.db = "testdb"
|
|
conf.tbl = "users"
|
|
# With both db & tbl set, searchColumn does NOT call inject for table
|
|
# discovery; it assumes the provided db/tbl and calls getColumns.
|
|
smod.inject.getValue = lambda *a, **k: self.fail("getValue should not be called")
|
|
|
|
s.searchColumn()
|
|
|
|
self.assertEqual(conf.dumper.dbColumnsArg[1], '1') # colConsider
|
|
dbs = conf.dumper.dbColumnsArg[2]
|
|
self.assertIn("testdb", dbs)
|
|
self.assertIn("users", dbs["testdb"])
|
|
self.assertIn("password", dbs["testdb"]["users"])
|
|
self.assertEqual(s.dumpFoundColumnCalls[-1][2], '1')
|
|
# getColumns was consulted for the assumed db/tbl/col.
|
|
self.assertIn(("testdb", "users", "password"), s.getColumnsCalls)
|
|
|
|
def test_search_column_enumerate_tables(self):
|
|
s = _TestSearch()
|
|
conf.col = "password"
|
|
conf.db = None
|
|
conf.tbl = None
|
|
# db & tbl missing => inject returns (db, table) pairs to scan.
|
|
smod.inject.getValue = lambda *a, **k: [["testdb", "users"]]
|
|
|
|
s.searchColumn()
|
|
|
|
dbs = conf.dumper.dbColumnsArg[2]
|
|
self.assertIn("testdb", dbs)
|
|
self.assertIn("users", dbs["testdb"])
|
|
# The requested column must have actually landed under the discovered
|
|
# db/table (getColumns populated kb.data.cachedColumns, which searchColumn
|
|
# folds into dbs); db/table presence alone wouldn't prove that.
|
|
self.assertIn("password", dbs["testdb"]["users"])
|
|
|
|
def test_search_column_none_found(self):
|
|
s = _TestSearch()
|
|
conf.col = "password"
|
|
conf.db = None
|
|
conf.tbl = None
|
|
smod.inject.getValue = lambda *a, **k: None
|
|
s.searchColumn()
|
|
# Nothing discovered => dumper.dbColumns not called.
|
|
self.assertIsNone(conf.dumper.dbColumnsArg)
|
|
|
|
# --- search() dispatcher ------------------------------------------------
|
|
|
|
def test_search_dispatch_to_column(self):
|
|
s = _TestSearch()
|
|
conf.col = "password"
|
|
conf.tbl = "users"
|
|
conf.db = "testdb"
|
|
smod.inject.getValue = lambda *a, **k: None
|
|
# Should route to searchColumn (col takes precedence). With db & tbl both
|
|
# provided, searchColumn assumes them and consults getColumns for the
|
|
# requested db/tbl/col -> at least one recorded call, matching the request.
|
|
s.search()
|
|
self.assertGreaterEqual(len(s.getColumnsCalls), 1)
|
|
self.assertIn(("testdb", "users", "password"), s.getColumnsCalls)
|
|
|
|
def test_search_dispatch_missing_param(self):
|
|
s = _TestSearch()
|
|
conf.col = None
|
|
conf.tbl = None
|
|
conf.db = None
|
|
from lib.core.exception import SqlmapMissingMandatoryOptionException
|
|
self.assertRaises(SqlmapMissingMandatoryOptionException, s.search)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# search.py - inference (blind) paths
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class _TestSearchInf(Search):
|
|
excludeDbsList = ["information_schema", "mysql"]
|
|
|
|
def __init__(self):
|
|
Search.__init__(self)
|
|
self.like = ('2', "='%s'") # exact match (colConsider '2')
|
|
self.dumpFoundTablesCalls = []
|
|
self.dumpFoundColumnCalls = []
|
|
|
|
def likeOrExact(self, what):
|
|
return self.like
|
|
|
|
def forceDbmsEnum(self):
|
|
pass
|
|
|
|
def getCurrentDb(self):
|
|
return "testdb"
|
|
|
|
def dumpFoundTables(self, tables):
|
|
self.dumpFoundTablesCalls.append(tables)
|
|
|
|
def dumpFoundColumn(self, dbs, foundCols, colConsider):
|
|
self.dumpFoundColumnCalls.append((dbs, foundCols, colConsider))
|
|
|
|
def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False):
|
|
db, tbl, col = conf.db, conf.tbl, conf.col
|
|
if db and tbl:
|
|
kb.data.cachedColumns.setdefault(db, {}).setdefault(tbl, {})
|
|
kb.data.cachedColumns[db][tbl][col] = "varchar"
|
|
|
|
|
|
class _RecDumper(object):
|
|
def __init__(self):
|
|
self.listed = []
|
|
self.dbTablesArg = None
|
|
self.dbColumnsArg = None
|
|
|
|
def lister(self, header, elements, content_type=None, sort=True):
|
|
self.listed.append((header, list(elements) if elements else []))
|
|
|
|
def dbTables(self, dbTables):
|
|
self.dbTablesArg = dbTables
|
|
|
|
def dbColumns(self, dbColumnsDict, colConsider, dbs):
|
|
self.dbColumnsArg = (dbColumnsDict, colConsider, dbs)
|
|
|
|
|
|
class _SearchBase(unittest.TestCase):
|
|
_CONF_KEYS = ("db", "tbl", "col", "direct", "technique", "excludeSysDbs",
|
|
"exclude", "search")
|
|
|
|
def setUp(self):
|
|
self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS}
|
|
self._saved_dumper = conf.get("dumper")
|
|
self._gv = smod.inject.getValue
|
|
self._readInput = smod.readInput
|
|
self._saved_has_is = kb.data.get("has_information_schema")
|
|
self._saved_cachedColumns = kb.data.get("cachedColumns")
|
|
self._saved_hintValue = kb.get("hintValue")
|
|
self._saved_injection_data = kb.injection.data
|
|
|
|
set_dbms("MySQL")
|
|
conf.direct = False
|
|
conf.technique = None
|
|
conf.excludeSysDbs = False
|
|
conf.exclude = None
|
|
conf.search = True
|
|
conf.dumper = _RecDumper()
|
|
|
|
kb.data.has_information_schema = True
|
|
kb.data.cachedColumns = {}
|
|
kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}}
|
|
|
|
def tearDown(self):
|
|
for k, v in self._saved_conf.items():
|
|
conf[k] = v
|
|
conf.dumper = self._saved_dumper
|
|
smod.inject.getValue = self._gv
|
|
smod.readInput = self._readInput
|
|
kb.data.has_information_schema = self._saved_has_is
|
|
kb.data.cachedColumns = self._saved_cachedColumns
|
|
kb.hintValue = self._saved_hintValue
|
|
kb.injection.data = self._saved_injection_data
|
|
|
|
|
|
class TestSearchInference(_SearchBase):
|
|
def test_search_db_inference(self):
|
|
# Blind searchDb: count of matching dbs, then one db name per index.
|
|
s = _TestSearchInf()
|
|
conf.db = "testdb"
|
|
smod.inject.getValue = _inference_gv(2, ["testdb", "testdb2"])
|
|
s.searchDb()
|
|
self.assertEqual(conf.dumper.listed[-1][0], "found databases")
|
|
self.assertEqual(sorted(conf.dumper.listed[-1][1]), ["testdb", "testdb2"])
|
|
|
|
def test_search_db_inference_no_match(self):
|
|
# Count fails (non-numeric) => no databases appended, empty listing.
|
|
s = _TestSearchInf()
|
|
conf.db = "ghost"
|
|
smod.inject.getValue = lambda query, *a, **k: (None if k.get("expected") == EXPECTED.INT else self.fail("must not page when count fails"))
|
|
s.searchDb()
|
|
self.assertEqual(conf.dumper.listed[-1][1], [])
|
|
|
|
def test_search_table_inference_grouped(self):
|
|
# Blind searchTable, no conf.db: outer count of dbs holding the table, then
|
|
# per-db a name, then per-db a count of matching tables, then table names.
|
|
s = _TestSearchInf()
|
|
conf.tbl = "users"
|
|
conf.db = None
|
|
|
|
# Sequencing by the EXPECTED.INT counts + the per-index string results.
|
|
# 1st count: number of databases with the table -> 1
|
|
# 1st db name -> "testdb"
|
|
# 2nd count: number of tables in testdb -> 1
|
|
# table name -> "users"
|
|
seq = {"counts": ["1", "1"], "ci": 0, "vals": ["testdb", "users"], "vi": 0}
|
|
|
|
def gv(query, *a, **k):
|
|
if k.get("expected") == EXPECTED.INT:
|
|
v = seq["counts"][seq["ci"] % len(seq["counts"])]
|
|
seq["ci"] += 1
|
|
return v
|
|
v = seq["vals"][seq["vi"] % len(seq["vals"])]
|
|
seq["vi"] += 1
|
|
return [v]
|
|
|
|
smod.inject.getValue = gv
|
|
s.searchTable()
|
|
self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"]})
|
|
self.assertEqual(s.dumpFoundTablesCalls[-1], {"testdb": ["users"]})
|
|
|
|
def test_search_table_mysql_lt5_bruteforce_decline(self):
|
|
# MySQL < 5 forces the bruteforce path; declining the prompt returns None
|
|
# without any injection.
|
|
s = _TestSearchInf()
|
|
conf.tbl = "users"
|
|
conf.db = None
|
|
kb.data.has_information_schema = False
|
|
smod.readInput = lambda *a, **k: "N"
|
|
smod.inject.getValue = lambda *a, **k: self.fail("bruteforce decline must not query")
|
|
self.assertIsNone(s.searchTable())
|
|
|
|
def test_search_column_inference(self):
|
|
# Blind searchColumn, no db/tbl: count of dbs with the column, then db name;
|
|
# then per-db count of tables with the column, then table name -> getColumns
|
|
# folds the column into dbs.
|
|
s = _TestSearchInf()
|
|
conf.col = "password"
|
|
conf.db = None
|
|
conf.tbl = None
|
|
|
|
seq = {"counts": ["1", "1"], "ci": 0, "vals": ["testdb", "users"], "vi": 0}
|
|
|
|
def gv(query, *a, **k):
|
|
if k.get("expected") == EXPECTED.INT:
|
|
v = seq["counts"][seq["ci"] % len(seq["counts"])]
|
|
seq["ci"] += 1
|
|
return v
|
|
v = seq["vals"][seq["vi"] % len(seq["vals"])]
|
|
seq["vi"] += 1
|
|
return [v]
|
|
|
|
smod.inject.getValue = gv
|
|
s.searchColumn()
|
|
dbs = conf.dumper.dbColumnsArg[2]
|
|
self.assertIn("testdb", dbs)
|
|
self.assertIn("users", dbs["testdb"])
|
|
self.assertIn("password", dbs["testdb"]["users"])
|
|
|
|
def test_search_column_mysql_lt5_bruteforce_decline(self):
|
|
s = _TestSearchInf()
|
|
conf.col = "password"
|
|
conf.db = None
|
|
conf.tbl = None
|
|
kb.data.has_information_schema = False
|
|
smod.readInput = lambda *a, **k: "N"
|
|
smod.inject.getValue = lambda *a, **k: self.fail("bruteforce decline must not query")
|
|
# Declining returns None and never reaches dbColumns.
|
|
self.assertIsNone(s.searchColumn())
|
|
self.assertIsNone(conf.dumper.dbColumnsArg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|