|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +""" |
| 4 | +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) |
| 5 | +See the file 'LICENSE' for copying permission |
| 6 | +
|
| 7 | +Protocol-transcript coverage for the dependency-free wire clients in extra/dbwire: PostgreSQL SCRAM |
| 8 | +server verification, MySQL capability negotiation, TDS framing and affected-row counts, Trino session |
| 9 | +state, and the shared DB-API error/URL helpers. |
| 10 | +
|
| 11 | +Network-free - a fake socket replays a recorded server transcript, so a hostile or malformed peer can be |
| 12 | +expressed exactly. These are the cases that are awkward to reach against a real server: a rogue server |
| 13 | +that does not know the password, a peer that never terminates a message, a server missing a mandatory |
| 14 | +capability. |
| 15 | +
|
| 16 | +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. |
| 17 | +""" |
| 18 | + |
| 19 | +import base64 |
| 20 | +import hashlib |
| 21 | +import hmac |
| 22 | +import os |
| 23 | +import socket |
| 24 | +import struct |
| 25 | +import sys |
| 26 | +import unittest |
| 27 | + |
| 28 | +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 29 | +from _testutils import bootstrap |
| 30 | +bootstrap() |
| 31 | + |
| 32 | +import extra.dbwire as dbwire |
| 33 | +from extra.dbwire import connection_lost |
| 34 | +from extra.dbwire import http_origin |
| 35 | +from extra.dbwire import mysql as _mysql |
| 36 | +from extra.dbwire import postgres as _postgres |
| 37 | +from extra.dbwire import presto as _presto |
| 38 | +from extra.dbwire import tds as _tds |
| 39 | + |
| 40 | + |
| 41 | +class FakeSocket(object): |
| 42 | + """Replays `inbound` to the client and records everything the client writes.""" |
| 43 | + |
| 44 | + def __init__(self, inbound=b""): |
| 45 | + self.inbound = bytearray(inbound) |
| 46 | + self.sent = bytearray() |
| 47 | + self.closed = False |
| 48 | + |
| 49 | + def feed(self, data): |
| 50 | + self.inbound.extend(data) |
| 51 | + |
| 52 | + def recv(self, count): |
| 53 | + if not self.inbound: |
| 54 | + return b"" |
| 55 | + chunk = bytes(self.inbound[:count]) |
| 56 | + del self.inbound[:count] |
| 57 | + return chunk |
| 58 | + |
| 59 | + def sendall(self, data): |
| 60 | + self.sent.extend(data) |
| 61 | + |
| 62 | + def settimeout(self, _value): |
| 63 | + pass |
| 64 | + |
| 65 | + def setsockopt(self, *_args): |
| 66 | + pass |
| 67 | + |
| 68 | + def close(self): |
| 69 | + self.closed = True |
| 70 | + |
| 71 | + |
| 72 | +def _pg(mtype, payload): |
| 73 | + return mtype + struct.pack("!I", len(payload) + 4) + payload |
| 74 | + |
| 75 | + |
| 76 | +def _scram_transcript(password, client_nonce_from, server_extra="SRV", forge_signature=False, error=None): |
| 77 | + """Builds an AuthenticationSASLContinue + SASLFinal pair the way a real server would.""" |
| 78 | + |
| 79 | + salt = b"0123456789abcdef" |
| 80 | + iterations = 4096 |
| 81 | + snonce = client_nonce_from + server_extra |
| 82 | + server_first = "r=%s,s=%s,i=%d" % (snonce, base64.b64encode(salt).decode("ascii"), iterations) |
| 83 | + if error is not None: |
| 84 | + final = "e=%s" % error |
| 85 | + else: |
| 86 | + salted = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) |
| 87 | + client_first_bare = "n=,r=%s" % client_nonce_from |
| 88 | + auth_message = "%s,%s,c=biws,r=%s" % (client_first_bare, server_first, snonce) |
| 89 | + server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest() |
| 90 | + signature = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest() |
| 91 | + if forge_signature: |
| 92 | + signature = os.urandom(32) |
| 93 | + final = "v=%s" % base64.b64encode(signature).decode("ascii") |
| 94 | + return server_first, final |
| 95 | + |
| 96 | + |
| 97 | +class PostgresScramTest(unittest.TestCase): |
| 98 | + """RFC 5802 requires the CLIENT to authenticate the server too. dbwire has no TLS underneath, so this |
| 99 | + verification is the only thing standing between a scan and a server that merely answers the port.""" |
| 100 | + |
| 101 | + def _run(self, password="secret", **kwargs): |
| 102 | + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) |
| 103 | + |
| 104 | + def _feed_rest(): |
| 105 | + sent = bytes(sock.sent) |
| 106 | + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") |
| 107 | + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] |
| 108 | + server_first, final = _scram_transcript(password, nonce, **kwargs) |
| 109 | + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) |
| 110 | + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) |
| 111 | + sock.feed(_pg(b"R", struct.pack("!I", 0))) |
| 112 | + |
| 113 | + original = sock.recv |
| 114 | + |
| 115 | + def recv(count): # top up lazily, once the client has sent its client-first |
| 116 | + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): |
| 117 | + _feed_rest() |
| 118 | + return original(count) |
| 119 | + |
| 120 | + sock.recv = recv |
| 121 | + return _postgres._authenticate(sock, "user", password) |
| 122 | + |
| 123 | + def test_valid_server_is_accepted(self): |
| 124 | + self._run() # returns on AuthenticationOk without raising |
| 125 | + |
| 126 | + def test_forged_server_signature_is_rejected(self): |
| 127 | + """A server that does not hold the credentials cannot produce ServerSignature.""" |
| 128 | + try: |
| 129 | + self._run(forge_signature=True) |
| 130 | + self.fail("a forged server signature was accepted") |
| 131 | + except dbwire.OperationalError as ex: |
| 132 | + self.assertIn("signature", str(ex)) |
| 133 | + |
| 134 | + def test_server_nonce_must_extend_the_client_nonce(self): |
| 135 | + """A server answering with a nonce of its own has not seen the client's - RFC 5802 5.1.""" |
| 136 | + |
| 137 | + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) |
| 138 | + original = sock.recv |
| 139 | + |
| 140 | + def recv(count): |
| 141 | + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): |
| 142 | + server_first, final = _scram_transcript("secret", "COMPLETELYUNRELATED") |
| 143 | + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) |
| 144 | + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) |
| 145 | + return original(count) |
| 146 | + |
| 147 | + sock.recv = recv |
| 148 | + try: |
| 149 | + _postgres._authenticate(sock, "user", "secret") |
| 150 | + self.fail("an unrelated server nonce was accepted") |
| 151 | + except dbwire.OperationalError as ex: |
| 152 | + self.assertIn("nonce", str(ex)) |
| 153 | + |
| 154 | + def test_server_reported_error_is_surfaced(self): |
| 155 | + try: |
| 156 | + self._run(error="invalid-proof") |
| 157 | + self.fail("a SCRAM error was ignored") |
| 158 | + except dbwire.OperationalError as ex: |
| 159 | + self.assertIn("invalid-proof", str(ex)) |
| 160 | + |
| 161 | + def test_low_iteration_count_is_rejected(self): |
| 162 | + """A tiny iteration count makes an offline attack on the captured exchange cheap.""" |
| 163 | + |
| 164 | + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) |
| 165 | + original = sock.recv |
| 166 | + |
| 167 | + def recv(count): |
| 168 | + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): |
| 169 | + sent = bytes(sock.sent) |
| 170 | + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") |
| 171 | + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] |
| 172 | + first = "r=%sSRV,s=%s,i=1" % (nonce, base64.b64encode(b"salt").decode("ascii")) |
| 173 | + sock.feed(_pg(b"R", struct.pack("!I", 11) + first.encode("ascii"))) |
| 174 | + return original(count) |
| 175 | + |
| 176 | + sock.recv = recv |
| 177 | + self.assertRaises(dbwire.OperationalError, _postgres._authenticate, sock, "user", "secret") |
| 178 | + |
| 179 | + |
| 180 | +class MysqlCapabilityTest(unittest.TestCase): |
| 181 | + def _handshake(self, server_caps): |
| 182 | + payload = b"\x0a" + b"8.0.0-fake\x00" + struct.pack("<I", 1) + b"12345678" + b"\x00" |
| 183 | + payload += struct.pack("<H", server_caps & 0xffff) |
| 184 | + payload += b"\x21" + struct.pack("<H", 2) |
| 185 | + payload += struct.pack("<H", (server_caps >> 16) & 0xffff) |
| 186 | + payload += struct.pack("<B", 21) + (b"\x00" * 10) + b"123456789012\x00" |
| 187 | + payload += b"mysql_native_password\x00" |
| 188 | + return payload |
| 189 | + |
| 190 | + def _connect_with(self, server_caps): |
| 191 | + """Drive the real handshake path with a fake server advertising `server_caps`.""" |
| 192 | + |
| 193 | + payload = self._handshake(server_caps) |
| 194 | + sock = FakeSocket(struct.pack("<I", len(payload))[:3] + b"\x00" + payload) |
| 195 | + self._last_sock = sock |
| 196 | + saved = socket.create_connection |
| 197 | + socket.create_connection = lambda *a, **k: sock |
| 198 | + try: |
| 199 | + _mysql.connect(host="h", port=3306, user="u", password="p", database=None, connect_timeout=1) |
| 200 | + finally: |
| 201 | + socket.create_connection = saved |
| 202 | + return sock |
| 203 | + |
| 204 | + def test_server_without_protocol_41_is_refused_cleanly(self): |
| 205 | + """Claiming a capability the server never advertised desynchronizes the handshake instead of |
| 206 | + failing; refuse up front.""" |
| 207 | + |
| 208 | + try: |
| 209 | + self._connect_with(_mysql._CLIENT_SECURE_CONNECTION) |
| 210 | + self.fail("a pre-4.1 server was accepted") |
| 211 | + except dbwire.OperationalError as ex: |
| 212 | + self.assertIn("4.1 protocol", str(ex)) |
| 213 | + |
| 214 | + def test_client_flags_never_exceed_the_server_capabilities(self): |
| 215 | + caps = (_mysql._CLIENT_PROTOCOL_41 | _mysql._CLIENT_SECURE_CONNECTION | _mysql._CLIENT_LONG_PASSWORD) |
| 216 | + try: |
| 217 | + sock = self._connect_with(caps) # fake server sends nothing back -> auth read fails |
| 218 | + except dbwire.Error: |
| 219 | + sock = self._last_sock |
| 220 | + sent = bytes(sock.sent) |
| 221 | + self.assertTrue(sent, "client sent no handshake response") |
| 222 | + flags = struct.unpack("<I", sent[4:8])[0] |
| 223 | + self.assertEqual(flags & ~caps, 0, "client claimed capabilities the server did not advertise") |
| 224 | + self.assertTrue(flags & _mysql._CLIENT_PROTOCOL_41) |
| 225 | + self.assertFalse(flags & _mysql._CLIENT_PLUGIN_AUTH, "PLUGIN_AUTH was not advertised by the server") |
| 226 | + |
| 227 | + |
| 228 | +class TdsFramingTest(unittest.TestCase): |
| 229 | + def _packet(self, body, eom=True): |
| 230 | + return struct.pack(">BBHHBB", 4, 1 if eom else 0, len(body) + 8, 0, 0, 0) + body |
| 231 | + |
| 232 | + def test_message_is_reassembled_across_packets(self): |
| 233 | + sock = FakeSocket(self._packet(b"AAA", eom=False) + self._packet(b"BBB", eom=True)) |
| 234 | + self.assertEqual(_tds._read_message(sock), b"AAABBB") |
| 235 | + |
| 236 | + def test_unterminated_message_is_bounded(self): |
| 237 | + """The packet length is 16-bit, so a per-packet cap can never fire: a peer that never sets EOM |
| 238 | + would stream forever. The CUMULATIVE message is what must be bounded.""" |
| 239 | + |
| 240 | + chunk = self._packet(b"A" * 4000, eom=False) |
| 241 | + sock = FakeSocket(chunk * 64) |
| 242 | + |
| 243 | + original = sock.recv |
| 244 | + |
| 245 | + def recv(count): # endless stream of non-final packets |
| 246 | + if not sock.inbound: |
| 247 | + sock.feed(chunk * 64) |
| 248 | + return original(count) |
| 249 | + |
| 250 | + sock.recv = recv |
| 251 | + saved = _tds._MAX_MESSAGE_LENGTH |
| 252 | + try: |
| 253 | + _tds._MAX_MESSAGE_LENGTH = 100000 |
| 254 | + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) |
| 255 | + finally: |
| 256 | + _tds._MAX_MESSAGE_LENGTH = saved |
| 257 | + |
| 258 | + def test_zero_length_packet_is_rejected(self): |
| 259 | + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, 0, 0, 0, 0)) |
| 260 | + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) |
| 261 | + |
| 262 | + def test_done_token_carries_the_affected_row_count(self): |
| 263 | + """DONE reports DoneRowCount when the DONE_COUNT status bit is set - the only place a DML |
| 264 | + statement's affected-row count exists, since it returns no rows.""" |
| 265 | + |
| 266 | + done = struct.pack("<B", 0xfd) + struct.pack("<HHq", _tds._DONE_COUNT, 0, 5000) |
| 267 | + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) |
| 268 | + description, rows, affected = _tds._parse_tokens(sock) |
| 269 | + self.assertIsNone(description) |
| 270 | + self.assertEqual(rows, []) |
| 271 | + self.assertEqual(affected, 5000) |
| 272 | + |
| 273 | + def test_done_without_the_count_flag_is_not_a_row_count(self): |
| 274 | + done = struct.pack("<B", 0xfd) + struct.pack("<HHq", 0, 0, 1234) |
| 275 | + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) |
| 276 | + self.assertIsNone(_tds._parse_tokens(sock)[2]) |
| 277 | + |
| 278 | + |
| 279 | +class TrinoSessionStateTest(unittest.TestCase): |
| 280 | + """Trino is stateless on the wire: the server reports each session change as a response header and the |
| 281 | + client must echo it back, or USE / SET SESSION silently do nothing on the next statement.""" |
| 282 | + |
| 283 | + def _connection(self): |
| 284 | + return _presto.Connection("h", 8080, "u", None, "tpch", "tiny", 10) |
| 285 | + |
| 286 | + def test_set_catalog_and_schema_are_carried(self): |
| 287 | + c = self._connection() |
| 288 | + c._apply_state({"x-trino-set-catalog": "hive", "x-trino-set-schema": "sf1"}) |
| 289 | + self.assertEqual(c._headers["X-Trino-Catalog"], "hive") |
| 290 | + self.assertEqual(c._headers["X-Trino-Schema"], "sf1") |
| 291 | + |
| 292 | + def test_session_properties_accumulate_and_clear(self): |
| 293 | + c = self._connection() |
| 294 | + c._apply_state({"x-trino-set-session": "query_max_run_time=7m"}) |
| 295 | + self.assertEqual(c._headers["X-Trino-Session"], "query_max_run_time=7m") |
| 296 | + c._apply_state({"x-trino-set-session": "join_distribution_type=BROADCAST"}) |
| 297 | + self.assertIn("join_distribution_type=BROADCAST", c._headers["X-Trino-Session"]) |
| 298 | + self.assertIn("query_max_run_time=7m", c._headers["X-Trino-Session"]) |
| 299 | + c._apply_state({"x-trino-clear-session": "query_max_run_time"}) |
| 300 | + self.assertNotIn("query_max_run_time", c._headers["X-Trino-Session"]) |
| 301 | + |
| 302 | + def test_transaction_id_is_carried_then_cleared(self): |
| 303 | + c = self._connection() |
| 304 | + c._apply_state({"x-trino-started-transaction-id": "abc123"}) |
| 305 | + self.assertEqual(c._headers["X-Trino-Transaction-Id"], "abc123") |
| 306 | + c._apply_state({"x-trino-clear-transaction-id": "true"}) |
| 307 | + self.assertNotIn("X-Trino-Transaction-Id", c._headers) |
| 308 | + |
| 309 | + def test_schema_is_never_sent_without_a_catalog(self): |
| 310 | + """Trino rejects every request with 'Schema is set but catalog is not'.""" |
| 311 | + |
| 312 | + c = _presto.Connection("h", 8080, "u", None, None, "tiny", 10) |
| 313 | + self.assertNotIn("X-Trino-Schema", c._headers) |
| 314 | + self.assertNotIn("X-Presto-Schema", c._headers) |
| 315 | + |
| 316 | + |
| 317 | +class HelperTest(unittest.TestCase): |
| 318 | + def test_socket_failure_maps_into_the_dbapi_hierarchy(self): |
| 319 | + """Callers of a PEP 249 driver only catch Error and its subclasses.""" |
| 320 | + |
| 321 | + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.OperationalError) |
| 322 | + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.Error) |
| 323 | + |
| 324 | + def test_http_origin_brackets_a_literal_ipv6_host(self): |
| 325 | + self.assertEqual(http_origin("10.0.0.5", 8123), "http://10.0.0.5:8123") |
| 326 | + self.assertEqual(http_origin("::1", 8123), "http://[::1]:8123") |
| 327 | + self.assertEqual(http_origin("[fe80::1]", 8123), "http://[fe80::1]:8123") |
| 328 | + self.assertEqual(http_origin(None, 8123), "http://localhost:8123") |
| 329 | + |
| 330 | + def test_every_module_exposes_the_dbapi_surface(self): |
| 331 | + for name in ("postgres", "mysql", "tds", "firebird", "cubrid", "monetdb", "clickhouse", "presto"): |
| 332 | + module = __import__("extra.dbwire.%s" % name, fromlist=["connect"]) |
| 333 | + self.assertTrue(callable(getattr(module, "connect", None)), name) |
| 334 | + |
| 335 | + |
| 336 | +if __name__ == "__main__": |
| 337 | + unittest.main() |
0 commit comments