diff --git a/.gitignore b/.gitignore index 7eeed3d31ec..ea3e495851c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ thirdparty/.DS_Store CLAUDE.md .coverage .codegraph/ +.claude/ diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index dada8fb47e0..8991e235258 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,3 +1,93 @@ +# Version 2.0 (WIP) + +* [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.10...master) + +## Injection techniques + +* Added the switch `--nosql`. It tests for NoSQL injection. It also dumps the data that it finds. +* Added the switch `--xpath`. It tests for XPath injection. +* Added the switch `--ldap`. It tests for LDAP injection. +* Added the switch `--ssti`. It tests for server-side template injection. It also covers Struts2 and OGNL. +* Added the switch `--graphql`. It tests for GraphQL injection. +* Added the switch `--hql`. It tests for HQL and JPQL (Hibernate ORM) injection. +* Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels. +* Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims. + +## Speed + +* Added the switch `--timeless`. It reads each blind bit from the HTTP/2 response order. It does not use a delay. sqlmap calibrates the target first, and it uses the usual time-based technique if the target is not applicable. +* Added set-membership (Huffman) retrieval for blind dumps. It needs fewer requests for each character. Use `--no-huffman` to stop it. +* Added keyset (seek) pagination for blind table dumps. Use `--no-keyset` to stop it. +* Added parallel retrieval of values in blind mode. Each thread retrieves a different value. +* Made Keep-Alive the default. Use the switch `--no-keep-alive` to stop it. +* Added the reuse of HTTP/2 connections. +* Added the switch `--lengths`. It compares the pages only by the content length. +* Made the HashDB operations faster. +* Made the tamper script `luanginxmore` much faster. + +## Targets and results + +* Added the option `--openapi`. It makes the list of targets from an OpenAPI or Swagger document. The options `--openapi-base` and `--openapi-tags` limit that list. +* Added the switch `--mine-params`. It finds hidden GET parameters. +* Added the switch `--proof`. It proves the exploitation of each injection point that it finds. +* Added the option `--report-json`. It writes the results of the run to a JSON file. +* Added the switch `--procs`. It retrieves the stored procedures and their source code. +* Added the option `--exclude`. It gives the databases that sqlmap must not enumerate. +* Added JSONL as a dump format. +* Added the use of sqlmap as a library. +* Added experimental support for gRPC-Web (text) requests. +* Improved the crawler. It now finds the endpoints in JavaScript files. + +## Back-end DBMS + +* Added support for SAP HANA. +* Added support for Snowflake. +* Added support for Google Cloud Spanner. +* Added support for DuckDB and Trino as forks. +* Added the switch `--esperanto`. It enumerates a back-end DBMS that sqlmap cannot identify. +* Added error-based payloads for CUBRID, InterSystems Cache, Virtuoso, H2, Firebird, and Vertica. +* Added time-based payloads for CUBRID. +* Added out-of-band DNS channels for H2 and ClickHouse. +* Added PostgreSQL command execution through a PL extension. +* Added the tamper scripts `blindbinary`, `dollarquote`, `infoschema2innodb`, `oraclequote`, and `sign`. + +## Fewer dependencies + +* Added an HTTP/2 client. It uses only the standard library. +* Added WebSocket support. It uses only the standard library. +* Added the decoding of Brotli and Zstandard responses. It uses only the standard library. +* Added Kerberos and Negotiate authentication. It uses only the standard library. +* Added NTLM authentication. This removed a deprecated third-party library. +* Rewrote the Keep-Alive handler. This removed the third-party package `keepalive`. +* Removed the third-party packages `multipart`, `odict`, and `prettyprint`. +* Replaced SocksiPy with PySocks. + +## Security + +* Removed all use of `pickle`. sqlmap now uses JSON for the session data and for the other serialized data. +* The REST API now requires authentication credentials. +* Put the `eval` behavior behind the environment variable `SQLMAP_UNSAFE_EVAL`. +* Put the option `--alert` behind the environment variable `SQLMAP_UNSAFE_ALERT`. +* Hardened the Brotli and the Zstandard decoders against hostile input. + +## Correctness + +* Made the boolean inference more reliable when the network has much jitter. +* Added automatic recovery when the page charset and the data charset do not agree. +* sqlmap now finds binary fields automatically in blind mode. +* Added support for the response code 429 (rate limit). +* Corrected the retrieval of UTF8MB4 characters from MySQL. +* Corrected the Set-Cookie behavior in redirections. Added support for domain cookies. +* sqlmap now keeps the value of an injected Host header. +* Removed the time outliers from the time statistics. +* Improved the detection of the SQL dialect of the target. + +## Quality + +* Added a unit test suite. It has more than 90 modules, and it runs on Python 2 and Python 3. +* Added pyflakes and more self-test stages to the CI/CD pipeline. +* Added `doc/ARCHITECTURE.md`. + # Version 1.10 (2026-01-01) * [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.9...1.10) diff --git a/extra/dbwire/__init__.py b/extra/dbwire/__init__.py index e48a84e4d53..e809c201ddb 100644 --- a/extra/dbwire/__init__.py +++ b/extra/dbwire/__init__.py @@ -48,3 +48,44 @@ class InternalError(DatabaseError): class NotSupportedError(DatabaseError): pass + +def http_origin(host, port): + """ + 'http://host:port', with a literal IPv6 address bracketed as RFC 3986 requires. Without the brackets + the colons in the address are parsed as the port separator and the URL is simply wrong. + """ + + host = host or "localhost" + if ":" in host and not host.startswith("["): + host = "[%s]" % host + return "http://%s:%d" % (host, int(port)) + + +def connection_lost(ex): + """ + Turn a raw socket/OS failure into the DB-API hierarchy above. + + Callers of a PEP 249 driver only ever catch Error and its subclasses, so a bare socket.error escaping + from a send/recv leaves them with an unhandled traceback instead of a handled connection failure. + """ + + return OperationalError("connection lost (%s)" % ex) + +def keepalive(sock): + """ + Ask the kernel to probe an idle connection, so a peer that dies without a FIN is eventually detected. + + Deliberately NOT a read timeout: a legitimate query can take minutes on a big table, and a fixed + deadline would kill it. Keepalive distinguishes a dead peer from a slow one, which is the actual + failure being guarded against. Best-effort - the options are not portable everywhere. + """ + + import socket as _socket + + try: + sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1) + for name, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 5)): + if hasattr(_socket, name): + sock.setsockopt(_socket.IPPROTO_TCP, getattr(_socket, name), value) + except Exception: + pass diff --git a/extra/dbwire/clickhouse.py b/extra/dbwire/clickhouse.py index b6a9cae588b..c39dcfed0a6 100644 --- a/extra/dbwire/clickhouse.py +++ b/extra/dbwire/clickhouse.py @@ -19,10 +19,13 @@ try: from urllib.request import Request, urlopen # Python 3 from urllib.error import HTTPError, URLError + from urllib.parse import quote except ImportError: from urllib2 import Request, urlopen, HTTPError, URLError # Python 2 + from urllib import quote from extra.dbwire import OperationalError +from extra.dbwire import http_origin from extra.dbwire import ProgrammingError # TabSeparated backslash escapes -> the literal byte they denote @@ -87,7 +90,8 @@ def close(self): class Connection(object): def __init__(self, host, port, user, password, database, timeout): - self._url = "http://%s:%d/?database=%s&default_format=TabSeparatedWithNames" % (host, port, database or "default") + # quote the database: a name with a reserved character would otherwise inject into the query string + self._url = "%s/?database=%s&default_format=TabSeparatedWithNames" % (http_origin(host, port), quote(database or "default", safe="")) self._headers = {} if user or password: token = base64.b64encode(("%s:%s" % (user or "", password or "")).encode("utf-8")).decode("ascii") diff --git a/extra/dbwire/cubrid.py b/extra/dbwire/cubrid.py index ae6f95f5e7c..750d7578b59 100644 --- a/extra/dbwire/cubrid.py +++ b/extra/dbwire/cubrid.py @@ -23,6 +23,8 @@ from extra.dbwire import IntegrityError from extra.dbwire import NotSupportedError from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import keepalive from extra.dbwire import ProgrammingError _MAGIC = b"CUBRK" @@ -257,7 +259,10 @@ def _safe_close(self): def _recvn(self, n): buf = b"" while len(buf) < n: - chunk = self._sock.recv(n - len(buf)) + try: + chunk = self._sock.recv(n - len(buf)) + except (socket.error, OSError) as ex: + raise connection_lost(ex) if not chunk: raise InterfaceError("connection closed by server") buf += chunk @@ -267,6 +272,7 @@ def _open(self): # broker handshake (may redirect to a dedicated CAS worker port), then cleartext OPEN_DATABASE login try: sock = socket.create_connection((self._host, self._port), timeout=self._timeout) + keepalive(sock) sock.settimeout(None) sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00") self._sock = sock @@ -276,6 +282,7 @@ def _open(self): if port > 0: # redirected to a CAS worker: reconnect there, no second handshake self._safe_close() sock = socket.create_connection((self._host, port), timeout=self._timeout) + keepalive(sock) sock.settimeout(None) self._sock = sock except (socket.error, socket.timeout) as ex: @@ -299,7 +306,10 @@ def _fixed(value, length): def _send(self, payload): # frame: [payload_len(4)][cas_info(4)][payload] - self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload) + try: + self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload) + except (socket.error, OSError) as ex: + raise connection_lost(ex) def _read_response(self): (data_length,) = struct.unpack(">i", self._recvn(4)) @@ -344,6 +354,17 @@ def _raise(errno, message): def _query(self, query): reader = self._call(_Writer(_FC_PREPARE).arg_nts(query).arg_byte(0).arg_byte(0)) handle = reader.int() + try: + return self._execute(handle, reader) + finally: + # release the broker-side request handle even when execute/fetch raised: a connection that + # survives a few failed statements would otherwise hold every one of them until it closes + try: + self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle)) + except Exception: + pass + + def _execute(self, handle, reader): reader.int() # result cache lifetime stmt_type = reader.byte() reader.int() # bind count @@ -374,7 +395,6 @@ def _query(self, query): rows += self._fetch_remaining(handle, columns, len(rows), total) elif result_infos: rowcount = result_infos[0] - self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle)) return description, rows, rowcount def _fetch_remaining(self, handle, columns, fetched, total): diff --git a/extra/dbwire/firebird.py b/extra/dbwire/firebird.py index 647070723d0..a7a56da2e51 100644 --- a/extra/dbwire/firebird.py +++ b/extra/dbwire/firebird.py @@ -26,6 +26,8 @@ from extra.dbwire import InterfaceError from extra.dbwire import NotSupportedError from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import keepalive # operation codes _op_connect = 1 @@ -286,12 +288,18 @@ def set_ciphers(self, rc, wc): self._rc, self._wc = rc, wc def send(self, data): - self._sock.sendall(self._wc.translate(data) if self._wc else data) + try: + self._sock.sendall(self._wc.translate(data) if self._wc else data) + except (socket.error, OSError) as ex: + raise connection_lost(ex) def _recv_raw(self, n): buf = b"" while len(buf) < n: - chunk = self._sock.recv(n - len(buf)) + try: + chunk = self._sock.recv(n - len(buf)) + except (socket.error, OSError) as ex: + raise connection_lost(ex) if not chunk: raise InterfaceError("connection closed by server") buf += chunk @@ -494,23 +502,36 @@ def _run(self, query): self._send(_pack_int(_op_allocate_statement) + _pack_int(self._db_handle)) stmt = self._response()[0] - desc_items = bytes(bytearray([_isc_info_sql_stmt_type])) + _INFO_SQL_SELECT_DESCRIBE_VARS - self._send(_pack_int(_op_prepare_statement) + _pack_int(self._trans_handle) + _pack_int(stmt) + - _pack_int(3) + _pack_bytes(qbytes) + _pack_bytes(desc_items) + _pack_int(1024)) - buf = self._response()[2] - stmt_type, columns = self._parse_describe(stmt, buf) - - exec_msg = (_pack_int(_op_execute) + _pack_int(stmt) + _pack_int(self._trans_handle) + - _pack_bytes(b"") + _pack_int(0) + _pack_int(0) + _pack_int(0)) - self._send(exec_msg) - self._response() + try: + desc_items = bytes(bytearray([_isc_info_sql_stmt_type])) + _INFO_SQL_SELECT_DESCRIBE_VARS + self._send(_pack_int(_op_prepare_statement) + _pack_int(self._trans_handle) + _pack_int(stmt) + + _pack_int(3) + _pack_bytes(qbytes) + _pack_bytes(desc_items) + _pack_int(1024)) + buf = self._response()[2] + stmt_type, columns = self._parse_describe(stmt, buf) + + exec_msg = (_pack_int(_op_execute) + _pack_int(stmt) + _pack_int(self._trans_handle) + + _pack_bytes(b"") + _pack_int(0) + _pack_int(0) + _pack_int(0)) + self._send(exec_msg) + self._response() - description, rows = None, [] - if stmt_type == _isc_info_sql_stmt_select and columns: - description = [(c.name, c.sqltype, None, None, None, None, None) for c in columns] - rows = self._fetch(stmt, columns) - self._send(_pack_int(_op_free_statement) + _pack_int(stmt) + _pack_int(_DSQL_drop)) - self._response() + description, rows = None, [] + if stmt_type == _isc_info_sql_stmt_select and columns: + description = [(c.name, c.sqltype, None, None, None, None, None) for c in columns] + rows = self._fetch(stmt, columns) + finally: + # release the server-side handle even when prepare/execute/fetch raised: a connection that + # survives a few failed statements would otherwise hold every one of them until it detaches + try: + self._send(_pack_int(_op_free_statement) + _pack_int(stmt) + _pack_int(_DSQL_drop)) + self._response() + except Exception: + pass + + # dbwire statements are autonomous (see README), but Firebird has no auto-commit mode: everything + # runs inside the one transaction opened at attach. Without this, DML is lost when the caller + # closes the connection without an explicit commit(). commit-retaining makes the work durable and + # keeps the transaction handle valid, so the connection stays usable for the next statement. + self.commit() return description, rows def _parse_describe(self, stmt, buf): @@ -737,6 +758,7 @@ def connect(host=None, port=3050, user=None, password=None, database=None, conne try: sock = socket.create_connection((host or "localhost", int(port or 3050)), timeout=connect_timeout) + keepalive(sock) sock.settimeout(None) except (socket.error, socket.timeout) as ex: raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) diff --git a/extra/dbwire/monetdb.py b/extra/dbwire/monetdb.py index 37f6db5c020..90dd4451fda 100644 --- a/extra/dbwire/monetdb.py +++ b/extra/dbwire/monetdb.py @@ -22,6 +22,8 @@ from extra.dbwire import InterfaceError from extra.dbwire import NotSupportedError from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import keepalive from extra.dbwire import ProgrammingError _MAX_BLOCK = 0xffff >> 1 @@ -29,7 +31,10 @@ def _recvn(sock, n): buf = b"" while len(buf) < n: - chunk = sock.recv(n - len(buf)) + try: + chunk = sock.recv(n - len(buf)) + except (socket.error, OSError) as ex: + raise connection_lost(ex) if not chunk: raise InterfaceError("connection closed by server") buf += chunk @@ -52,7 +57,10 @@ def _putblock(sock, text): chunk = data[off:off + _MAX_BLOCK] off += _MAX_BLOCK last = off >= len(data) - sock.sendall(struct.pack("= 16 MB into 0xffffff-sized packets (with a trailing short packet) chunk = payload[:0xffffff] - sock.sendall(struct.pack("= 2.7.8 (hashlib.pbkdf2_hmac)") - nonce = base64.b64encode(os.urandom(18)).decode("ascii") - cfirst_bare = "n=,r=%s" % nonce + client_nonce = base64.b64encode(os.urandom(18)).decode("ascii") + cfirst_bare = "n=,r=%s" % client_nonce client_first = "n,," + cfirst_bare _send(sock, b"p", b"SCRAM-SHA-256\x00" + struct.pack("!I", len(client_first)) + client_first.encode("ascii")) elif code == 11: # SASLContinue (server-first) @@ -259,6 +267,13 @@ def _authenticate(sock, user, password): snonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"]) except (KeyError, ValueError, binascii.Error, UnicodeDecodeError) as ex: raise OperationalError("malformed SCRAM server-first message (%s)" % ex) + # RFC 5802 5.1: the server nonce MUST start with the client nonce and MUST add material of its + # own. Skipping this lets anything that can answer the TCP connection replay a recorded + # server-first and drive the exchange - and dbwire has no TLS layer underneath to catch it. + if not client_nonce or not snonce.startswith(client_nonce) or len(snonce) <= len(client_nonce): + raise OperationalError("SCRAM server nonce does not extend the client nonce (rogue server?)") + if iterations < 4096: # RFC 5802 recommends >= 4096; a tiny count cheapens an offline attack + raise OperationalError("SCRAM iteration count %d is too low" % iterations) salted = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations) client_key = hmac.new(salted, b"Client Key", hashlib.sha256).digest() stored_key = hashlib.sha256(client_key).digest() @@ -267,8 +282,26 @@ def _authenticate(sock, user, password): client_sig = hmac.new(stored_key, auth_message.encode("ascii"), hashlib.sha256).digest() proof = base64.b64encode(_xor(client_key, client_sig)).decode("ascii") _send(sock, b"p", ("%s,p=%s" % (client_final_noproof, proof)).encode("ascii")) - elif code == 12: # SASLFinal - pass + elif code == 12: # SASLFinal (server-final): verify the server too, or the handshake is one-way + # Without this the client proves itself to the server and simply trusts whatever answers back. + # ServerSignature = HMAC(ServerKey, AuthMessage) can only be produced by a peer that holds the + # stored credentials, so comparing it is what makes the exchange mutual (RFC 5802 5, 5.1). + if salted is None or auth_message is None: + raise OperationalError("unexpected SCRAM server-final message") + try: + attrs = dict(kv.split("=", 1) for kv in payload[4:].decode("ascii").split(",")) + except (ValueError, UnicodeDecodeError) as ex: + raise OperationalError("malformed SCRAM server-final message (%s)" % ex) + if "e" in attrs: + raise OperationalError("SCRAM authentication failed (%s)" % attrs["e"]) + try: + signature = base64.b64decode(attrs["v"]) + except (KeyError, binascii.Error, ValueError) as ex: + raise OperationalError("malformed SCRAM server signature (%s)" % ex) + server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest() + expected = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected): + raise OperationalError("SCRAM server signature mismatch (rogue server?)") else: raise InterfaceError("unsupported authentication request %d" % code) @@ -279,6 +312,7 @@ def _raise_server_error_as_operational(payload): def connect(host=None, port=5432, user=None, password=None, database=None, connect_timeout=None, **kwargs): try: sock = socket.create_connection((host or "localhost", int(port or 5432)), timeout=connect_timeout) + keepalive(sock) sock.settimeout(None) except (socket.error, socket.timeout) as ex: raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) diff --git a/extra/dbwire/presto.py b/extra/dbwire/presto.py index 7deede433ae..7ec447f0c88 100644 --- a/extra/dbwire/presto.py +++ b/extra/dbwire/presto.py @@ -25,6 +25,7 @@ from extra.dbwire import InterfaceError from extra.dbwire import NotSupportedError from extra.dbwire import OperationalError +from extra.dbwire import http_origin from extra.dbwire import ProgrammingError def _convert(value, coltype): @@ -72,9 +73,16 @@ def fetchone(self): def close(self): self._rows = [] +def _split_pair(item): + """'key=value' -> (key, value); a bare 'key' keeps a None value (Trino sends both forms).""" + + key, sep, value = item.strip().partition("=") + return key.strip(), (value.strip() if sep else None) + + class Connection(object): def __init__(self, host, port, user, password, catalog, schema, timeout): - self._statement_url = "http://%s:%d/v1/statement" % (host, port) + self._statement_url = "%s/v1/statement" % http_origin(host, port) self._timeout = timeout self._headers = {"Content-Type": "text/plain"} for prefix in ("X-Presto-", "X-Trino-"): @@ -84,8 +92,8 @@ def __init__(self, host, port, user, password, catalog, schema, timeout): # request ("Schema is set but catalog is not"), so never force a "default" schema if catalog: self._headers[prefix + "Catalog"] = catalog - if schema: - self._headers[prefix + "Schema"] = schema + if schema: # only inside the catalog branch: a Schema alone is rejected + self._headers[prefix + "Schema"] = schema if password: token = base64.b64encode(("%s:%s" % (user or "", password)).encode("utf-8")).decode("ascii") self._headers["Authorization"] = "Basic %s" % token @@ -102,16 +110,54 @@ def rollback(self): def close(self): pass # HTTP is stateless + def _apply_state(self, info): + """ + Carry the session state the server hands back into the headers of every later request. + + The client protocol is stateless on the wire, so the SERVER cannot remember anything: it reports + each change as a response header and the client is required to echo it back. Ignoring them makes + 'USE', 'SET SESSION', 'SET ROLE' and 'START TRANSACTION' appear to succeed and then silently have + no effect on the next statement. + """ + + for prefix in ("X-Presto-", "X-Trino-"): + for suffix, header in (("Catalog", "Catalog"), ("Schema", "Schema"), ("Path", "Path")): + value = info.get((prefix + "Set-" + suffix).lower()) + if value: + self._headers[prefix + header] = value + started = info.get((prefix + "Started-Transaction-Id").lower()) + if started: + self._headers[prefix + "Transaction-Id"] = started + if info.get((prefix + "Clear-Transaction-Id").lower()): + self._headers.pop(prefix + "Transaction-Id", None) + # Set-Session / Set-Role accumulate as comma-separated 'key=value' pairs, and the matching + # Clear-* header removes one by name + for kind in ("Session", "Role"): + current = dict(_split_pair(_) for _ in (self._headers.get(prefix + kind) or "").split(",") if _) + for item in (info.get((prefix + "Set-" + kind).lower()) or "").split(","): + if item.strip(): + key, value = _split_pair(item) + current[key] = value + for key in (info.get((prefix + "Clear-" + kind).lower()) or "").split(","): + current.pop(key.strip(), None) + if current: + self._headers[prefix + kind] = ",".join("%s=%s" % (k, v) if v is not None else k for k, v in sorted(current.items())) + else: + self._headers.pop(prefix + kind, None) + def _request(self, url, data=None): req = Request(url, data=data.encode("utf-8") if data is not None else None, headers=self._headers) try: - body = urlopen(req, timeout=self._timeout).read().decode("utf-8", "replace") + response = urlopen(req, timeout=self._timeout) + body = response.read().decode("utf-8", "replace") except HTTPError as ex: raise ProgrammingError("(remote) HTTP %s: %s" % (ex.code, ex.read().decode("utf-8", "replace")[:200])) except URLError as ex: raise OperationalError("(remote) %s" % ex) except (socket.timeout, socket.error) as ex: raise OperationalError("(remote) %s" % ex) + info = response.info() + self._apply_state(dict((k.lower(), v) for k, v in (info.items() if hasattr(info, "items") else []))) try: return json.loads(body) except ValueError as ex: diff --git a/extra/dbwire/tds.py b/extra/dbwire/tds.py index fe4a4dbb6ab..3cad7d70af7 100644 --- a/extra/dbwire/tds.py +++ b/extra/dbwire/tds.py @@ -22,9 +22,12 @@ from extra.dbwire import InterfaceError from extra.dbwire import NotSupportedError from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import keepalive from extra.dbwire import ProgrammingError _MAX_MESSAGE_LENGTH = 0x40000000 +_DONE_COUNT = 0x0010 # DONE status bit: DoneRowCount carries a valid affected-row count # packet types _PKT_SQL_BATCH = 0x01 @@ -38,7 +41,10 @@ def _u8(data, off): def _recvn(sock, n): buf = b"" while len(buf) < n: - chunk = sock.recv(n - len(buf)) + try: + chunk = sock.recv(n - len(buf)) + except (socket.error, OSError) as ex: + raise connection_lost(ex) if not chunk: raise InterfaceError("connection closed by server") buf += chunk @@ -54,23 +60,32 @@ def _send_message(sock, mtype, data): off += chunk_size last = off >= len(data) header = struct.pack(">BBHHBB", mtype, _STATUS_EOM if last else 0x00, len(chunk) + 8, 0, packet_id & 0xff, 0) - sock.sendall(header + chunk) + try: + sock.sendall(header + chunk) + except (socket.error, OSError) as ex: + raise connection_lost(ex) packet_id += 1 if last: break def _read_message(sock): - # reassemble a full TDS message across packets (EOM status bit marks the last) - body = b"" + # reassemble a full TDS message across packets (EOM status bit marks the last). The packet length is a + # 16-bit field, so bounding IT against _MAX_MESSAGE_LENGTH can never trigger - a hostile peer simply + # never sets EOM and streams packets forever. Bound the accumulated message instead, and collect the + # chunks in a list so reassembly stays linear rather than re-copying a growing immutable buffer. + chunks, total = [], 0 while True: header = _recvn(sock, 8) mtype, status, length = struct.unpack(">BBH", header[:4]) - if length < 8 or length > _MAX_MESSAGE_LENGTH: + if length < 8: raise InterfaceError("invalid TDS packet length (%d)" % length) - body += _recvn(sock, length - 8) + total += length - 8 + if total > _MAX_MESSAGE_LENGTH: + raise InterfaceError("TDS message exceeds the maximum allowed length (%d bytes)" % _MAX_MESSAGE_LENGTH) + chunks.append(_recvn(sock, length - 8)) if status & _STATUS_EOM: break - return body + return b"".join(chunks) # ---- PRELOGIN ---------------------------------------------------------------------------------------- @@ -465,12 +480,12 @@ def _decode_value(col, data, off): def _parse_tokens(sock, login=False): data = _read_message(sock) - off, columns, rows, description, error = 0, [], [], None, None + off, columns, rows, description, error, affected = 0, [], [], None, None, None while off < len(data): token = _u8(data, off); off += 1 if token == 0x81: # COLMETADATA (a new result set: drop any prior rows so only the last is returned) (count,) = struct.unpack("...) -VERSION = "1.10.8.8" +VERSION = "1.10.8.12" 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/core/testing.py b/lib/core/testing.py index f7efb96fc6a..2e064806c64 100644 --- a/lib/core/testing.py +++ b/lib/core/testing.py @@ -65,7 +65,7 @@ def vulnTest(tests=None, label="vuln"): ("-u --data=\"security_level=3\" -p id --flush-session --technique=B", ("bypassed the WAF/IPS by using tamper script", "Type: boolean-based blind")), # automatic WAF-bypass: SQL-tamper dimension at a stricter signature threshold ("-u --data=\"security_level=4\" -p id --flush-session --technique=B --banner", ("random (non-scanner) User-Agent and browser-like headers to bypass the WAF/IPS", "Type: boolean-based blind", "banner: '3.")), # automatic WAF-bypass against a libinjection-class WAF: tampers cannot help, only the non-scanner User-Agent does ("-u --data=\"security_level=5\" -p id --flush-session --technique=B", ("unable to automatically bypass the WAF/IPS", "does not seem to be injectable")), # automatic WAF-bypass honest bail: a libinjection-class WAF that no User-Agent or tamper can defeat - ("-u -p id --flush-session --technique=B --proof", ("sqlmap proved exploitation of the following injection point", "Parameter: id (GET)", "Technique: boolean-based blind", "TRUE (5/5)", "repeatably", "Retrieved: back-end DBMS banner '3.")), # --proof: report-grade proof in the injection-point style - forces the boolean technique (so a multi-technique point still proves), and actively reads a value out as the strongest proof + ("-u -p id --flush-session --technique=B --proof", ("sqlmap proved exploitation of the following injection point", "Parameter: id (GET)", "drawn at random after the scan started", "boolean-based blind:", "PASS confirmed", "Read-back: back-end DBMS banner", "TRUE (", "5/5", "separated by:", "PROVEN - the back-end executed injected SQL")), # --proof: every claim is an experiment with a control - an unpredictable product the back-end must compute (per technique), a real datum read back, and the TRUE/FALSE differential with its wire facts ("-u --mine-params --flush-session --technique=B", ("mining for hidden GET parameters", "found hidden parameter 'id'", "held back parameter(s) that break the base request", "Parameter: id (GET)", "Type: boolean-based blind")), # --mine-params: discover an injectable parameter absent from a bare URL, hold back the raw-SQL sink that would shadow it, then confirm the injection on the mined 'id' ("-u \"ratelimit?id=1\" --flush-session --technique=B", ("target appears to be rate-limiting", "Parameter: id (GET)", "Type: boolean-based blind")), # adaptive rate-limit handling: the endpoint answers 429 with 'Retry-After' first, so detection only succeeds if sqlmap honors the backoff, throttles, and retries rather than treating 429 as a hard block ("-r --flush-session -v 5 --test-skip=\"heavy\" --save=", ("CloudFlare", "web application technology: Express", "possible DBMS: 'SQLite'", "User-Agent: foobar", "~Type: time-based blind", "saved command line options to the configuration file")), diff --git a/lib/utils/prove.py b/lib/utils/prove.py index fccd275dc98..3e661341d3a 100644 --- a/lib/utils/prove.py +++ b/lib/utils/prove.py @@ -6,9 +6,13 @@ """ import os +import time from lib.core.common import Backend from lib.core.common import average +from lib.core.common import getCurrentThreadData +from lib.core.common import getSafeExString +from lib.core.common import getUnicode from lib.core.common import openFile from lib.core.common import randomInt from lib.core.common import stdev @@ -25,17 +29,36 @@ from lib.core.enums import PLACE from lib.core.settings import INFERENCE_MARKER from lib.core.settings import SLEEP_TIME_MARKER -from lib.request.inject import getValue -# how many times a true/false condition is re-evaluated to demonstrate repeatability (kills false positives) +# how many times the differential control is repeated, to show it is stable rather than a coincidence PROVE_REPETITIONS = 5 +# characters of a datum read through an INFERENTIAL technique (one request per bit), so a time-based point +# does not spend minutes reading a banner it has already proven it can read +INFERENTIAL_DATUM_CHARS = 12 + # comparison knobs that decide true/false at request time (lib/request/comparison.py reads these globals, # not injection.conf); they must be re-pointed at the injection being proven or the oracle returns None _COMPARISON_ATTRS = ("string", "notString", "regexp", "code", "textOnly", "titles") -# width the field labels are padded to, so the values line up in a clean column -_LABEL_WIDTH = 9 +_LABEL_WIDTH = 10 + +# getValue() gates, so each experiment runs through ONE named technique and the evidence can be attributed +# to it. Without this the report would credit whatever technique getValue() happened to pick as fastest. +_GATES = { + PAYLOAD.TECHNIQUE.UNION: {"union": True, "error": False, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.ERROR: {"union": False, "error": True, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.QUERY: {"union": False, "error": True, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.BOOLEAN: {"union": False, "error": False, "blind": True, "time": False}, + PAYLOAD.TECHNIQUE.TIME: {"union": False, "error": False, "blind": False, "time": True}, + PAYLOAD.TECHNIQUE.STACKED: {"union": False, "error": False, "blind": False, "time": True}, +} + +# techniques that return the value inside the response body; the rest infer it one bit at a time +_INBAND = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY) + +# order the experiments run in: cheapest and most demonstrative first +_ORDER = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY, PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.TIME, PAYLOAD.TECHNIQUE.STACKED) def _field(label, value): @@ -75,118 +98,191 @@ def _restoreInjection(saved): setattr(conf, attr, value) -def _booleanOracle(expression): +def _exchange(): """ - Evaluates a boolean expression strictly through the boolean (inferential) technique. UNION/error are - forced off on purpose: for a multi-technique injection getValue() would try those first, and a WAF/IPS - that blocks their function-heavy payloads makes them return None, which (with expectingNone) short- - circuits the whole call before the boolean technique is ever reached - the real cause of a 0/0 reading. + The wire facts of the request that was just sent: (request line, HTTP code, response bytes, seconds). + An evidence line without them is an assertion; with them the reader can check the work. """ - return getValue(expression, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, suppressOutput=True, expectingNone=True, union=False, error=False, time=False) + threadData = getCurrentThreadData() + parts = (threadData.lastRequestMsg or "").replace("\r\n", "\n").split("\n") + line = urldecode(parts[1].strip(), convall=True) if len(parts) > 1 else "" + return line, threadData.lastCode, len(threadData.lastPage or ""), threadData.lastQueryDuration + + +def _techniques(injection): + return [_ for _ in _ORDER if _ in injection.data] + +def _name(stype): + return PAYLOAD.SQLINJECTION.get(stype) or "unknown" -def _signalArtifacts(expression): + +def _outcome(ok, detail, exchange): + """One evidence row: 'PASS HTTP 200, 402 bytes, 0.045s' plus the request line beneath it.""" + + line, code, length, duration = exchange + facts = [] + if code is not None: + facts.append("HTTP %s" % code) + if length: + facts.append("%d bytes" % length) + if duration: + facts.append("%.3fs" % duration) + retVal = ["%-4s %s%s" % ("PASS" if ok else "FAIL", detail, (" [%s]" % ", ".join(facts)) if facts else "")] + if line: + retVal.append(" %s" % line) + return retVal + + +def _challenge(injection, a, b): """ - Evaluates 'expression' through the boolean oracle and reads back the (HTTP code, page ) of the - response it produced (queryPage stores both in thread data), so the boolean proof can quote the actual - TRUE/FALSE codes and titles rather than a generic flag. Returns (None, None) on any error. + The decisive experiment, run once through EVERY confirmed technique. + + The back-end is asked for a*b, where both operands were drawn at random after the scan started. That + product exists in no page, cache, log or reflection, and no amount of pattern matching in front of the + application can produce it - only something that evaluates SQL can. An in-band technique must return + the product itself; an inferential one must answer TRUE to 'a*b=product' AND FALSE to 'a*b=product+1', + which costs two requests instead of reading the digits back one bit at a time. + + Running it per technique is the point of the report: on a filtered target it shows exactly which + channels the protection closed and which one still carries data. """ - from lib.core.common import extractRegexResult, getCurrentThreadData - from lib.core.settings import HTML_TITLE_REGEX + from lib.request import inject - try: - _booleanOracle(expression) - threadData = getCurrentThreadData() - return threadData.lastCode, (extractRegexResult(HTML_TITLE_REGEX, threadData.lastPage or "") or "").strip() - except Exception: + expected = a * b + retVal = [] + + for stype in _techniques(injection): + gate = _GATES[stype] + try: + if stype in _INBAND: + value = inject.getValue("%d*%d" % (a, b), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS, resumeValue=False, suppressOutput=True, **gate) + exchange = _exchange() + ok = value is not None and ("%s" % value).strip() == str(expected) + detail = "returned %s" % value if value is not None else "no value returned" + else: + hit = inject.getValue("%d*%d=%d" % (a, b, expected), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate) + exchange = _exchange() # quote the TRUE probe: on a time-based point that is where the delay shows + miss = inject.getValue("%d*%d=%d" % (a, b, expected + 1), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate) + ok = bool(hit) and miss is False + detail = "confirmed %d, rejected %d" % (expected, expected + 1) if ok else "inconclusive (%s/%s)" % (hit, miss) + retVal.append((stype, ok, _outcome(ok, detail, exchange))) + except Exception as ex: + retVal.append((stype, False, ["FAIL %s" % getSafeExString(ex)])) + + return retVal + + +def _datumQuery(stype): + """ + A real datum to read out of the back-end, and its label. Inferential techniques pay one request per + bit, so their datum is bounded with the DBMS' own SUBSTRING template instead of a whole banner. + """ + + dbms = Backend.getIdentifiedDbms() + entry = queries.get(dbms) if dbms else None + if entry is None: return None, None + for attr, label in (("banner", "back-end DBMS banner"), ("current_db", "current database"), ("current_user", "current database user")): + query = getattr(getattr(entry, attr, None), "query", None) + if not query: + continue + if stype in _INBAND: + return query, label + template = getattr(getattr(entry, "substring", None), "query", None) + if template: + return template % (query, 1, INFERENTIAL_DATUM_CHARS), "%s (first %d characters)" % (label, INFERENTIAL_DATUM_CHARS) + return query, label + + return None, None -def _proveBoolean(injection, signal=None): + +def _datum(passing): """ - Demonstrates deterministic boolean control, rendered with the distinguishing signal sqlmap already - auto-selected (--string / --code / --title), repeated to show it is stable (not a fluke). The signal - line quotes the actual distinguishing artifact: the matched string, the two HTTP codes, or the two - page titles - so a reader sees exactly what tells TRUE from FALSE. - - When a mutable 'signal' dict is supplied it is filled with the distinguishing artifact (code-based? - and the TRUE/FALSE HTTP codes) so the caller can tell a genuine signal from a blocked-response (WAF) - artifact - a TRUE condition that yields an HTTP 4xx is a block, not a database answer. + Reads a real value out of the back-end through a technique the challenge already proved, and reports + which one returned it. The challenge proves execution; this proves data egress. """ - retVal = [] - n = randomInt() + from lib.request import inject + + for stype in passing: + query, label = _datumQuery(stype) + if not query: + continue + started = kb.requestCounter + try: + value = unArrayizeValue(inject.getValue(query, safeCharEncode=False, suppressOutput=True, resumeValue=False, **_GATES[stype])) + except Exception: + value = None + if not value: + continue + line, code, length, duration = _exchange() + head = "%s = %s [%s]" % (label, repr(getUnicode(value)).lstrip('u'), _name(stype)) + if stype in _INBAND: + head += " [HTTP %s, %d bytes, %.3fs]" % (code, length, duration or 0.0) + return [_field("Read-back", [head, " %s" % line] if line else [head])] + # inferred one bit at a time: a single request line would not represent the exchange + return [_field("Read-back", [head, " recovered bit by bit over %d requests" % (kb.requestCounter - started)])] + + return [] + + +def _booleanControl(injection): + """ + The TRUE/FALSE differential, quoted with the artifact that separates them AND with the response facts + behind it, so the reader can see what the oracle actually looked at. + """ + + from lib.request.inject import getValue - trues = sum(1 for _ in range(PROVE_REPETITIONS) if _booleanOracle("%d=%d" % (n, n))) - falses = sum(1 for _ in range(PROVE_REPETITIONS) if _booleanOracle("%d=%d" % (n, n + 1)) is False) + def _ask(expression): + result = getValue(expression, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, suppressOutput=True, expectingNone=True, union=False, error=False, time=False) + _line, code, length, _duration = _exchange() + return result, code, length - line = "condition %d=%d returns TRUE (%d/%d) while %d=%d returns FALSE (%d/%d)" % (n, n, trues, PROVE_REPETITIONS, n, n + 1, falses, PROVE_REPETITIONS) - if trues == PROVE_REPETITIONS and falses == PROVE_REPETITIONS: - line += ", repeatably" # only claim repeatability when every repetition agreed - retVal.append(line) + n = randomInt() + trues = falses = 0 + trueCode = falseCode = trueLength = falseLength = None - trueCode = trueTitle = falseCode = falseTitle = None - if injection.conf.code or injection.conf.titles: # fetch the real artifacts only when the signal needs them - trueCode, trueTitle = _signalArtifacts("%d=%d" % (n, n)) - falseCode, falseTitle = _signalArtifacts("%d=%d" % (n, n + 1)) + for _ in range(PROVE_REPETITIONS): + result, trueCode, trueLength = _ask("%d=%d" % (n, n)) + trues += bool(result) + result, falseCode, falseLength = _ask("%d=%d" % (n, n + 1)) + falses += result is False - if signal is not None: - signal["codeBased"] = bool(injection.conf.code) - signal["trueCode"], signal["falseCode"] = trueCode, falseCode + retVal = ["TRUE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n, trues, PROVE_REPETITIONS, trueCode, trueLength), + "FALSE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n + 1, falses, PROVE_REPETITIONS, falseCode, falseLength)] if injection.conf.string: - retVal.append("the response contains %s only when the condition is TRUE" % repr(injection.conf.string).lstrip('u')) + retVal.append("separated by: the response contains %s only when TRUE" % repr(injection.conf.string).lstrip('u')) elif injection.conf.notString: - retVal.append("the response contains %s only when the condition is FALSE" % repr(injection.conf.notString).lstrip('u')) + retVal.append("separated by: the response contains %s only when FALSE" % repr(injection.conf.notString).lstrip('u')) elif injection.conf.code: - if trueCode and falseCode and trueCode != falseCode: - retVal.append("the response returns HTTP %s when the condition is TRUE and HTTP %s when it is FALSE" % (trueCode, falseCode)) - else: - retVal.append("the response returns HTTP %s only when the condition is TRUE (a different code otherwise)" % injection.conf.code) + retVal.append("separated by: the HTTP status code") elif injection.conf.titles: - if trueTitle and falseTitle and trueTitle != falseTitle: - retVal.append("the page title is %s when the condition is TRUE and %s when it is FALSE" % (repr(trueTitle).lstrip('u'), repr(falseTitle).lstrip('u'))) - else: - retVal.append("the page <title> differs between the TRUE and FALSE responses") + retVal.append("separated by: the page title") else: - retVal.append("the TRUE response matches the original page while the FALSE one differs (content similarity)") + retVal.append("separated by: response content similarity") - return retVal + # a TRUE condition answered by a 4xx is a block, not a database answer - the caller needs to know + return retVal, (bool(injection.conf.code) and (trueCode or 0) >= 400) -def _proveTime(injection): +def _timeControl(injection, stype): """ - Demonstrates time-based blind in plain IT language (jitter / latency / controlled delay), keeping the - statistics under the hood. Where the payload uses a parameterizable delay (SLEEP(n)/pg_sleep(n)/WAITFOR), - it sweeps the injected delay (0 / T / 2T seconds) and shows the response time tracks it ~1:1 - a controlled - delay that network latency or a slow page cannot reproduce. Otherwise (heavy-query delays) it falls back to - a baseline-vs-jitter statement. + Sweeps the injected delay (0 / T / 2T seconds) and shows the response time follows it. The 0s case is + the control: a slow application or a congested network cannot switch itself off on command. """ from lib.core.agent import agent - from lib.core.common import getCurrentThreadData, popValue, pushValue + from lib.core.common import popValue, pushValue from lib.request.connect import Connect as Request - retVal = [] - stype = PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED vector = (injection.data.get(stype) or {}).get("vector") - def _baselineStatement(): - baseline = kb.responseTimes.get(kb.responseTimeMode) or [] - if len(baseline) >= 2: - return "a TRUE condition delays the response well beyond the target's normal latency ~%.3fs (jitter ~%.3fs), repeatably" % (average(baseline), stdev(baseline)) - return "a TRUE condition delays the response well beyond the target's normal latency and jitter, repeatably" - - if not (vector and SLEEP_TIME_MARKER in vector): - retVal.append(_baselineStatement()) - return retVal - - n = randomInt() - base = conf.timeSec or 5 - measurements = [] - benign = [] for _ in range(3): try: @@ -194,6 +290,17 @@ def _baselineStatement(): benign.append(getCurrentThreadData().lastQueryDuration) except Exception: pass + baseAvg = average(benign) if benign else 0.0 + baseStd = stdev(benign) if len(benign) >= 2 else 0.0 + + if not (vector and SLEEP_TIME_MARKER in vector): + # a heavy-query delay carries no parameterizable seconds, so there is nothing to sweep + return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)] + + n = randomInt() + base = conf.timeSec or 5 + measurements = [] + for k in (0, base, 2 * base): pushValue(conf.timeSec) conf.timeSec = k @@ -207,179 +314,162 @@ def _baselineStatement(): conf.timeSec = popValue() if any(d is None for _, d in measurements): - retVal.append(_baselineStatement()) - return retVal + return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)] d0, dT, d2T = (measurements[0][1], measurements[1][1], measurements[2][1]) - baseAvg = average(benign) if benign else d0 - baseStd = stdev(benign) if len(benign) >= 2 else 0.0 - - # only claim 1:1 scaling if the measurements actually track the injected seconds: 0s stays near baseline, - # Ts ~ T, 2Ts ~ 2T, monotonic. A heavy-query delay (e.g. SQLite RANDOMBLOB) also rides [SLEEPTIME] but - # does NOT scale linearly, so it must NOT be rendered as 1:1 (its sweep is noisy / non-monotonic) - linear = d0 < max(0.5, base * 0.5) and abs(dT - base) <= base * 0.5 and abs(d2T - 2 * base) <= base * 0.6 and d2T > dT - - if linear: - retVal.append("normal response ~%.3fs (jitter ~%.3fs); injected delay %s" % (baseAvg, baseStd, " ".join("%ds -> %.2fs" % (k, d) for k, d in measurements))) - retVal.append("the response slows ~1:1 with the injected delay - a controlled delay that network latency or a slow page cannot reproduce (the 0s case returns at normal speed)") - else: - retVal.append("a TRUE condition makes the response take ~%.2fs versus ~%.3fs normal (jitter ~%.3fs), repeatably" % (max(dT, d2T), baseAvg, baseStd)) - retVal.append("a FALSE condition returns at normal speed - a sustained delay neither network latency nor a slow page reproduces") + retVal = ["unmodified request: %.3fs (jitter ~%.3fs)" % (baseAvg, baseStd), + "injected delay: %s" % " ".join("%ds -> %.2fs" % (k, d) for k, d in measurements)] + # only claim 1:1 scaling when the measurements really track the injected seconds. A heavy-query delay + # also rides [SLEEPTIME] but does not scale linearly, so it must not be rendered as a controlled delay. + if d0 < max(0.5, base * 0.5) and abs(dT - base) <= base * 0.5 and abs(d2T - 2 * base) <= base * 0.6 and d2T > dT: + retVal.append("the delay follows the injected value ~1:1, and 0s returns at normal speed") return retVal -def _retrieveProof(): - """ - Reads values back through the injection to prove it - DBMS-agnostic, weakest-to-strongest: +# response codes a protection returns when it drops a request, rather than the application answering +_BLOCKED_CODES = (403, 406, 419, 429, 501, 503) - 1. a random arithmetic product (e.g. 48391*60128): every SQL engine evaluates it, it needs no - table/function/FROM (valid even on Oracle), so its WAF surface is tiny - yet the operands are - random, so reading the exact product back proves the back-end actually executed injected SQL - (not a reflected constant); - 2. the DBMS banner: a real datum the application never returns on its own (the strongest proof). - Whatever evasion the run already adopted (tamper scripts) applies here too - this is not tied to any one - DBMS or tamper. Returns a list of (label, text) rungs; both, one, or none may be present. +def _evasion(): + """ + What the proof had to get through. On a filtered target this is the part that matters: the evidence + above is worth much more when the report also states that a protection was in the path and what was + needed to carry data past it. """ - - from lib.request import inject retVal = [] - - a, b = randomInt(4), randomInt(4) # 4-digit operands: product stays < 2^31 so it never overflows a 32-bit INT (e.g. PostgreSQL int4), yet is unguessable - try: - result = inject.getValue("%d*%d" % (a, b), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS, resumeValue=False, suppressOutput=True) - except Exception: - result = None - if result is not None and ("%s" % result).strip() == str(a * b): - retVal.append(("Computed", "%d*%d = %d returned by the back-end - it executed the injected SQL (works on any DBMS)" % (a, b, a * b))) - - label = value = None - for requested, candidate, lbl in ( # reuse a value the user's own switches already pulled - (conf.getBanner, getattr(kb.data, "banner", None), "back-end DBMS banner"), - (conf.getCurrentUser, getattr(kb.data, "currentUser", None), "current database user"), - (conf.getCurrentDb, getattr(kb.data, "currentDb", None), "current database"), - ): - if requested and candidate: - label, value = lbl, unArrayizeValue(candidate) - break - - if value is None: - dbms = Backend.getIdentifiedDbms() - banner = getattr(queries.get(dbms), "banner", None) if dbms else None - query = getattr(banner, "query", None) if banner else None - if query: - try: - value = unArrayizeValue(inject.getValue(query, safeCharEncode=False, suppressOutput=True)) - label = "back-end DBMS banner" - except Exception: - value = None - - if value: - retVal.append(("Retrieved", "%s %s - a real value read out of the back-end (the strongest proof)" % (label, repr(value).lstrip('u')))) - + if kb.identifiedWafs: + retVal.append("protection identified: %s" % ", ".join(sorted(kb.identifiedWafs))) + elif kb.wafBypass is not None: + retVal.append("protection detected in front of the application (not fingerprinted)") + if kb.wafBypass: + retVal.append("automatic bypass applied: non-scanner User-Agent and browser-like headers") + names = ", ".join(sorted(_.__name__.rsplit('.', 1)[-1] for _ in (kb.tamperFunctions or []))) + if names or conf.tamper: + retVal.append("tamper scripts in effect: %s" % (names or conf.tamper)) + blocked = ", ".join("%d x%d" % (code, count) for code, count in sorted((kb.httpErrorCodes or {}).items()) if code in _BLOCKED_CODES) + if blocked: + retVal.append("responses refused by the protection during the run: %s" % blocked) + if kb.droppingRequests: + retVal.append("the target dropped or reset requests during the scan (retried)") + if conf.delay: + retVal.append("requests were delayed by %.2fs each" % conf.delay) return retVal -def proveExploitation(): +def _proveInjection(injection): """ - Renders a report-grade, best-effort demonstration of exploitation for the confirmed injection point - (option '--proof'), in the same style as sqlmap's injection-point summary so it reads naturally: the - target URL and the confirmed injection point (parameter / type / title / payload), then the strongest - proof first - an actual value read out of the back-end (drilling from the plain read to a more evasive - one so a WAF/IPS does not stop it) - backed by a deterministic boolean differential (rendered with the - distinguishing --string/--code/--title signal) or a statistical time-based demonstration. Written both - to stdout and to '<output>/proof.txt'. + Runs every experiment for one injection point and renders its block. Returns (fields, proven). """ - if not kb.injections or not any(getattr(_, "place", None) for _ in kb.injections): - return - - injection = kb.injection if getattr(kb.injection, "place", None) else kb.injections[0] - - signal = {} saved = _activateInjection(injection) + started = kb.requestCounter + try: + a, b = randomInt(4), randomInt(4) # 4-digit operands: the product stays inside a 32-bit INT on every DBMS, yet is unguessable + rows = _challenge(injection, a, b) + passing = [stype for stype, ok, _ in rows if ok] + + blocked = None + control = [] + stype = passing[0] if passing else (_techniques(injection) or [None])[0] + if PAYLOAD.TECHNIQUE.BOOLEAN in injection.data: - stype = PAYLOAD.TECHNIQUE.BOOLEAN - proof = _proveBoolean(injection, signal) + control, blocked = _booleanControl(injection) + controlLabel = "boolean differential" elif PAYLOAD.TECHNIQUE.TIME in injection.data or PAYLOAD.TECHNIQUE.STACKED in injection.data: - stype = PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED - proof = _proveTime(injection) - elif PAYLOAD.TECHNIQUE.ERROR in injection.data: - stype = PAYLOAD.TECHNIQUE.ERROR - proof = ["the back-end error message returns the requested value directly"] - elif PAYLOAD.TECHNIQUE.UNION in injection.data: - stype = PAYLOAD.TECHNIQUE.UNION - proof = ["the requested value is rendered inside the application response"] + control = _timeControl(injection, PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED) + controlLabel = "timing control" else: - stype = next(iter(injection.data), None) - proof = [] + controlLabel = None - rungs = _retrieveProof() + readback = _datum(passing) finally: _restoreInjection(saved) - from lib.core.agent import agent + paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else injection.place + fields = [_field("Parameter", "%s (%s)" % (injection.parameter, paramType)), + _field("Techniques", ", ".join(_name(_) for _ in _techniques(injection)) or "none")] + + challenge = ["the back-end must compute %d*%d = %d, drawn at random after the scan started" % (a, b, a * b), + "(the product is in no page, cache or reflection - only something that evaluates SQL can return it)"] + for stype, _ok, lines in rows: + challenge.append("%s:" % _name(stype)) + challenge.extend(" %s" % _ for _ in lines) + fields.append(_field("Challenge", challenge)) + + fields.extend(readback) + + if control: + fields.append(_field("Control", ["%s" % controlLabel] + [" %s" % _ for _ in control])) + + evasion = _evasion() + if evasion: + fields.append(_field("Evasion", evasion)) + + proven = bool(passing) + if proven: + through = " through the protection in front of the application" if (kb.identifiedWafs or kb.wafBypass) else "" + verdict = ["PROVEN - the back-end executed injected SQL and returned the result%s" % through, + "channels that carry data: %s" % ", ".join(_name(_) for _ in passing)] + failed = [_name(stype) for stype, ok, _ in rows if not ok] + if failed: + verdict.append("channels that did NOT answer: %s" % ", ".join(failed)) + else: + verdict = ["NOT PROVEN - no technique returned the computed value"] + if blocked: + verdict.append("a TRUE condition answers with an HTTP error - that is a block, not a database answer") + if kb.identifiedWafs or kb.droppingRequests or blocked: + verdict.append("a protection is interfering, so this may be a real injection whose data channel is blocked") + verdict.append("=> re-test without the protection, or with '--tamper', then prove again") + else: + verdict.append("the reported injection point reproduces a differential but cannot execute SQL") + verdict.append("=> treat it as a FALSE POSITIVE unless a side effect proves otherwise (e.g. '--os-shell')") + + verdict.append("%d requests spent on this proof" % (kb.requestCounter - started)) + fields.append(_field("Verdict", verdict)) + + return fields, proven + + +def proveExploitation(): + """ + Renders a verifiable demonstration of exploitation for every confirmed injection point (switch + '--proof'). It does not restate what detection reported: each claim is an experiment with a control, + an unpredictable expected value, and the request line, HTTP status, response size and timing that + produced it - and every claim is attributed to the technique that produced it. Written to stdout and + to '<output>/proof.txt'. + """ + + injections = [_ for _ in (kb.injections or []) if getattr(_, "place", None)] + if not injections: + return target = conf.url or "" if conf.parameters.get(PLACE.GET) and "?" not in target: # spell out the full GET target, not just the path target += "?%s" % conf.parameters[PLACE.GET] - paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else injection.place - sdata = injection.data.get(stype) - fields = [_field("Target", target)] if conf.parameters.get(PLACE.POST): fields.append(_field("Data", conf.parameters[PLACE.POST])) - fields.append(_field("Parameter", "%s (%s)" % (injection.parameter, paramType))) - if sdata is not None: - fields.append(_field("Technique", PAYLOAD.SQLINJECTION[stype])) - if sdata.payload: - payload = urldecode(agent.adjustLateValues(sdata.payload), unsafe="&", spaceplus=(injection.place != PLACE.GET and kb.postSpaceToPlus)) - fields.append(_field("Payload", payload)) - # Reading a value back out of the back-end is the GATE, not a bonus: it is the only thing that - # distinguishes a real injection from a differential that merely correlates with the payload. A - # WAF/IPS that answers blocked payloads with a distinct HTTP status (e.g. 403 when TRUE, 200 when - # FALSE) reproduces a perfect, repeatable boolean differential WITHOUT any SQL ever executing - so - # the differential alone is exactly the signal detection already (mis)read. If nothing could be read - # back, exploitation is NOT proven; say so plainly instead of echoing the detection verdict. - proven = bool(rungs) - - # whether ANY confirmed technique here can return data inline; a stacked-query-only point cannot, so a - # failed read-back below is expected there and must NOT be spun into a "false positive" verdict - canReadBack = any(_ in injection.data for _ in (PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.TIME)) - - if proven: - if proof: - fields.append(_field("Proof", proof)) - for label, text in rungs: - fields.append(_field(label, text)) - header = "sqlmap proved exploitation of the following injection point" + if Backend.getIdentifiedDbms(): + fields.append(_field("Back-end", Backend.getIdentifiedDbms())) + fields.append(_field("Verified", time.strftime("%Y-%m-%d %H:%M:%S"))) + + proven = 0 + for injection in injections: + block, ok = _proveInjection(injection) + proven += int(ok) + fields.append("") + fields.extend(block) + + if proven == len(injections): + header = "sqlmap proved exploitation of the following injection point(s)" + elif proven: + header = "sqlmap proved exploitation of %d of %d reported injection point(s)" % (proven, len(injections)) else: - if proof: - fields.append(_field("Observed", proof)) # the differential is observed, but unconfirmed - suspectWaf = bool(signal.get("codeBased")) and (signal.get("trueCode") or 0) >= 400 - wafInterfering = suspectWaf or kb.droppingRequests or bool(kb.identifiedWafs) - verdict = ["no value could be read back through the injection (tried a random arithmetic product and the DBMS banner)"] - if suspectWaf: - verdict.append("the TRUE/FALSE difference is only an HTTP %s (blocked) response - characteristic of a WAF/IPS, not a database answer" % signal.get("trueCode")) - if not canReadBack: - # e.g. stacked-query-only: no confirmed technique returns data inline, so a value cannot be read - # back here - that is expected by design and is NOT evidence of a false positive - verdict.append("this injection point exposes no data-returning channel (e.g. stacked queries), so a value cannot be read back inline - expected here, not a false positive") - verdict.append("=> confirm exploitation through a side effect instead (e.g. '--os-shell', or '--sql-query' run with '--technique=S')") - elif wafInterfering: - # behind a WAF, an unconfirmed read-back is ambiguous: a genuine injection whose data-retrieval - # payloads are being blocked looks the same as a pure WAF artifact - so don't assert "false - # positive", point the user at the way to disambiguate instead - verdict.append("a WAF/IPS is interfering: this may be a real injection whose data-retrieval is blocked, or a false positive") - verdict.append("=> exploitation is NOT proven; re-test directly (no WAF) or with --tamper, then re-prove") - else: - verdict.append("=> exploitation is NOT proven; the reported injection is likely a FALSE POSITIVE") - fields.append(_field("Verdict", verdict)) - header = "sqlmap could NOT prove exploitation of the reported injection point" + header = "sqlmap could NOT prove exploitation of the reported injection point(s)" data = "\n".join(fields) conf.dumper.string(header, data) diff --git a/tests/test_dbwire.py b/tests/test_dbwire.py new file mode 100644 index 00000000000..57dd32a8880 --- /dev/null +++ b/tests/test_dbwire.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Protocol-transcript coverage for the dependency-free wire clients in extra/dbwire: PostgreSQL SCRAM +server verification, MySQL capability negotiation, TDS framing and affected-row counts, Trino session +state, and the shared DB-API error/URL helpers. + +Network-free - a fake socket replays a recorded server transcript, so a hostile or malformed peer can be +expressed exactly. These are the cases that are awkward to reach against a real server: a rogue server +that does not know the password, a peer that never terminates a message, a server missing a mandatory +capability. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import base64 +import hashlib +import hmac +import os +import socket +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import extra.dbwire as dbwire +from extra.dbwire import connection_lost +from extra.dbwire import http_origin +from extra.dbwire import mysql as _mysql +from extra.dbwire import postgres as _postgres +from extra.dbwire import presto as _presto +from extra.dbwire import tds as _tds + + +class FakeSocket(object): + """Replays `inbound` to the client and records everything the client writes.""" + + def __init__(self, inbound=b""): + self.inbound = bytearray(inbound) + self.sent = bytearray() + self.closed = False + + def feed(self, data): + self.inbound.extend(data) + + def recv(self, count): + if not self.inbound: + return b"" + chunk = bytes(self.inbound[:count]) + del self.inbound[:count] + return chunk + + def sendall(self, data): + self.sent.extend(data) + + def settimeout(self, _value): + pass + + def setsockopt(self, *_args): + pass + + def close(self): + self.closed = True + + +def _pg(mtype, payload): + return mtype + struct.pack("!I", len(payload) + 4) + payload + + +def _scram_transcript(password, client_nonce_from, server_extra="SRV", forge_signature=False, error=None): + """Builds an AuthenticationSASLContinue + SASLFinal pair the way a real server would.""" + + salt = b"0123456789abcdef" + iterations = 4096 + snonce = client_nonce_from + server_extra + server_first = "r=%s,s=%s,i=%d" % (snonce, base64.b64encode(salt).decode("ascii"), iterations) + if error is not None: + final = "e=%s" % error + else: + salted = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + client_first_bare = "n=,r=%s" % client_nonce_from + auth_message = "%s,%s,c=biws,r=%s" % (client_first_bare, server_first, snonce) + server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest() + signature = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest() + if forge_signature: + signature = os.urandom(32) + final = "v=%s" % base64.b64encode(signature).decode("ascii") + return server_first, final + + +class PostgresScramTest(unittest.TestCase): + """RFC 5802 requires the CLIENT to authenticate the server too. dbwire has no TLS underneath, so this + verification is the only thing standing between a scan and a server that merely answers the port.""" + + def _run(self, password="secret", **kwargs): + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + + def _feed_rest(): + sent = bytes(sock.sent) + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] + server_first, final = _scram_transcript(password, nonce, **kwargs) + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 0))) + + original = sock.recv + + def recv(count): # top up lazily, once the client has sent its client-first + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + _feed_rest() + return original(count) + + sock.recv = recv + return _postgres._authenticate(sock, "user", password) + + def test_valid_server_is_accepted(self): + self._run() # returns on AuthenticationOk without raising + + def test_forged_server_signature_is_rejected(self): + """A server that does not hold the credentials cannot produce ServerSignature.""" + try: + self._run(forge_signature=True) + self.fail("a forged server signature was accepted") + except dbwire.OperationalError as ex: + self.assertIn("signature", str(ex)) + + def test_server_nonce_must_extend_the_client_nonce(self): + """A server answering with a nonce of its own has not seen the client's - RFC 5802 5.1.""" + + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + original = sock.recv + + def recv(count): + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + server_first, final = _scram_transcript("secret", "COMPLETELYUNRELATED") + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) + return original(count) + + sock.recv = recv + try: + _postgres._authenticate(sock, "user", "secret") + self.fail("an unrelated server nonce was accepted") + except dbwire.OperationalError as ex: + self.assertIn("nonce", str(ex)) + + def test_server_reported_error_is_surfaced(self): + try: + self._run(error="invalid-proof") + self.fail("a SCRAM error was ignored") + except dbwire.OperationalError as ex: + self.assertIn("invalid-proof", str(ex)) + + def test_low_iteration_count_is_rejected(self): + """A tiny iteration count makes an offline attack on the captured exchange cheap.""" + + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + original = sock.recv + + def recv(count): + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + sent = bytes(sock.sent) + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] + first = "r=%sSRV,s=%s,i=1" % (nonce, base64.b64encode(b"salt").decode("ascii")) + sock.feed(_pg(b"R", struct.pack("!I", 11) + first.encode("ascii"))) + return original(count) + + sock.recv = recv + self.assertRaises(dbwire.OperationalError, _postgres._authenticate, sock, "user", "secret") + + +class MysqlCapabilityTest(unittest.TestCase): + def _handshake(self, server_caps): + payload = b"\x0a" + b"8.0.0-fake\x00" + struct.pack("<I", 1) + b"12345678" + b"\x00" + payload += struct.pack("<H", server_caps & 0xffff) + payload += b"\x21" + struct.pack("<H", 2) + payload += struct.pack("<H", (server_caps >> 16) & 0xffff) + payload += struct.pack("<B", 21) + (b"\x00" * 10) + b"123456789012\x00" + payload += b"mysql_native_password\x00" + return payload + + def _connect_with(self, server_caps): + """Drive the real handshake path with a fake server advertising `server_caps`.""" + + payload = self._handshake(server_caps) + sock = FakeSocket(struct.pack("<I", len(payload))[:3] + b"\x00" + payload) + self._last_sock = sock + saved = socket.create_connection + socket.create_connection = lambda *a, **k: sock + try: + _mysql.connect(host="h", port=3306, user="u", password="p", database=None, connect_timeout=1) + finally: + socket.create_connection = saved + return sock + + def test_server_without_protocol_41_is_refused_cleanly(self): + """Claiming a capability the server never advertised desynchronizes the handshake instead of + failing; refuse up front.""" + + try: + self._connect_with(_mysql._CLIENT_SECURE_CONNECTION) + self.fail("a pre-4.1 server was accepted") + except dbwire.OperationalError as ex: + self.assertIn("4.1 protocol", str(ex)) + + def test_client_flags_never_exceed_the_server_capabilities(self): + caps = (_mysql._CLIENT_PROTOCOL_41 | _mysql._CLIENT_SECURE_CONNECTION | _mysql._CLIENT_LONG_PASSWORD) + try: + sock = self._connect_with(caps) # fake server sends nothing back -> auth read fails + except dbwire.Error: + sock = self._last_sock + sent = bytes(sock.sent) + self.assertTrue(sent, "client sent no handshake response") + flags = struct.unpack("<I", sent[4:8])[0] + self.assertEqual(flags & ~caps, 0, "client claimed capabilities the server did not advertise") + self.assertTrue(flags & _mysql._CLIENT_PROTOCOL_41) + self.assertFalse(flags & _mysql._CLIENT_PLUGIN_AUTH, "PLUGIN_AUTH was not advertised by the server") + + +class TdsFramingTest(unittest.TestCase): + def _packet(self, body, eom=True): + return struct.pack(">BBHHBB", 4, 1 if eom else 0, len(body) + 8, 0, 0, 0) + body + + def test_message_is_reassembled_across_packets(self): + sock = FakeSocket(self._packet(b"AAA", eom=False) + self._packet(b"BBB", eom=True)) + self.assertEqual(_tds._read_message(sock), b"AAABBB") + + def test_unterminated_message_is_bounded(self): + """The packet length is 16-bit, so a per-packet cap can never fire: a peer that never sets EOM + would stream forever. The CUMULATIVE message is what must be bounded.""" + + chunk = self._packet(b"A" * 4000, eom=False) + sock = FakeSocket(chunk * 64) + + original = sock.recv + + def recv(count): # endless stream of non-final packets + if not sock.inbound: + sock.feed(chunk * 64) + return original(count) + + sock.recv = recv + saved = _tds._MAX_MESSAGE_LENGTH + try: + _tds._MAX_MESSAGE_LENGTH = 100000 + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) + finally: + _tds._MAX_MESSAGE_LENGTH = saved + + def test_zero_length_packet_is_rejected(self): + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, 0, 0, 0, 0)) + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) + + def test_done_token_carries_the_affected_row_count(self): + """DONE reports DoneRowCount when the DONE_COUNT status bit is set - the only place a DML + statement's affected-row count exists, since it returns no rows.""" + + done = struct.pack("<B", 0xfd) + struct.pack("<HHq", _tds._DONE_COUNT, 0, 5000) + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) + description, rows, affected = _tds._parse_tokens(sock) + self.assertIsNone(description) + self.assertEqual(rows, []) + self.assertEqual(affected, 5000) + + def test_done_without_the_count_flag_is_not_a_row_count(self): + done = struct.pack("<B", 0xfd) + struct.pack("<HHq", 0, 0, 1234) + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) + self.assertIsNone(_tds._parse_tokens(sock)[2]) + + +class TrinoSessionStateTest(unittest.TestCase): + """Trino is stateless on the wire: the server reports each session change as a response header and the + client must echo it back, or USE / SET SESSION silently do nothing on the next statement.""" + + def _connection(self): + return _presto.Connection("h", 8080, "u", None, "tpch", "tiny", 10) + + def test_set_catalog_and_schema_are_carried(self): + c = self._connection() + c._apply_state({"x-trino-set-catalog": "hive", "x-trino-set-schema": "sf1"}) + self.assertEqual(c._headers["X-Trino-Catalog"], "hive") + self.assertEqual(c._headers["X-Trino-Schema"], "sf1") + + def test_session_properties_accumulate_and_clear(self): + c = self._connection() + c._apply_state({"x-trino-set-session": "query_max_run_time=7m"}) + self.assertEqual(c._headers["X-Trino-Session"], "query_max_run_time=7m") + c._apply_state({"x-trino-set-session": "join_distribution_type=BROADCAST"}) + self.assertIn("join_distribution_type=BROADCAST", c._headers["X-Trino-Session"]) + self.assertIn("query_max_run_time=7m", c._headers["X-Trino-Session"]) + c._apply_state({"x-trino-clear-session": "query_max_run_time"}) + self.assertNotIn("query_max_run_time", c._headers["X-Trino-Session"]) + + def test_transaction_id_is_carried_then_cleared(self): + c = self._connection() + c._apply_state({"x-trino-started-transaction-id": "abc123"}) + self.assertEqual(c._headers["X-Trino-Transaction-Id"], "abc123") + c._apply_state({"x-trino-clear-transaction-id": "true"}) + self.assertNotIn("X-Trino-Transaction-Id", c._headers) + + def test_schema_is_never_sent_without_a_catalog(self): + """Trino rejects every request with 'Schema is set but catalog is not'.""" + + c = _presto.Connection("h", 8080, "u", None, None, "tiny", 10) + self.assertNotIn("X-Trino-Schema", c._headers) + self.assertNotIn("X-Presto-Schema", c._headers) + + +class HelperTest(unittest.TestCase): + def test_socket_failure_maps_into_the_dbapi_hierarchy(self): + """Callers of a PEP 249 driver only catch Error and its subclasses.""" + + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.OperationalError) + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.Error) + + def test_http_origin_brackets_a_literal_ipv6_host(self): + self.assertEqual(http_origin("10.0.0.5", 8123), "http://10.0.0.5:8123") + self.assertEqual(http_origin("::1", 8123), "http://[::1]:8123") + self.assertEqual(http_origin("[fe80::1]", 8123), "http://[fe80::1]:8123") + self.assertEqual(http_origin(None, 8123), "http://localhost:8123") + + def test_every_module_exposes_the_dbapi_surface(self): + for name in ("postgres", "mysql", "tds", "firebird", "cubrid", "monetdb", "clickhouse", "presto"): + module = __import__("extra.dbwire.%s" % name, fromlist=["connect"]) + self.assertTrue(callable(getattr(module, "connect", None)), name) + + +if __name__ == "__main__": + unittest.main()