diff --git a/lib/core/option.py b/lib/core/option.py index bb915197d..ad80c4a52 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -2698,15 +2698,6 @@ def _setHttpOptions(): _http_client.HTTPConnection._http_vsn = 10 _http_client.HTTPConnection._http_vsn_str = 'HTTP/1.0' - if conf.url and (conf.url.startswith("ws:/") or conf.url.startswith("wss:/")): - try: - from websocket import ABNF - ABNF # require websocket-client, not any 'websocket' module - except ImportError: - errMsg = "sqlmap requires third-party module 'websocket-client' " - errMsg += "in order to use WebSocket functionality" - raise SqlmapMissingDependence(errMsg) - def _checkTor(): if not conf.checkTor: return diff --git a/lib/core/settings.py b/lib/core/settings.py index 04460590c..1b227713b 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.56" +VERSION = "1.10.7.57" 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/request/connect.py b/lib/request/connect.py index 70d5091d0..77f9f25a7 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -19,12 +19,8 @@ import sys import time import traceback -try: - import websocket - from websocket import WebSocketException -except ImportError: - class WebSocketException(Exception): - pass +from lib.request import websocket +from lib.request.websocket import WebSocketException from lib.core.agent import agent from lib.core.common import asciifyUrl diff --git a/lib/request/websocket.py b/lib/request/websocket.py new file mode 100644 index 000000000..1d8f56aee --- /dev/null +++ b/lib/request/websocket.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Native, dependency-free WebSocket client (RFC 6455), replacing the 'websocket-client' third-party +# library. Covers exactly what sqlmap needs: the client handshake, masked text framing, wss (TLS) and +# the recv/timeout surface. It also removes the long-standing ambiguity with the unrelated 'websocket' +# PyPI package (both expose a top-level 'websocket' module). Pure standard library, Python 2.7 / 3.x. + +import base64 +import hashlib +import os +import socket +import ssl +import struct + +from lib.core.convert import getBytes +from lib.core.convert import getText +from thirdparty.six.moves import urllib as _urllib + +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 magic value for the accept key + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +class WebSocketException(Exception): + pass + +class WebSocketTimeoutException(WebSocketException): + pass + +class WebSocketConnectionClosedException(WebSocketException): + pass + +class WebSocket(object): + """ + Minimal RFC 6455 client exposing the websocket-client subset used by sqlmap: settimeout(), connect(), + send(), recv(), close(), the handshake .status and getheaders() + """ + + def __init__(self): + self.sock = None + self.status = None + self._headers = {} + self._timeout = None + self._buffer = b"" + self._closed = False + + def settimeout(self, timeout): + self._timeout = timeout + if self.sock is not None: + self.sock.settimeout(timeout) + + def connect(self, url, header=None, cookie=None): + parts = _urllib.parse.urlsplit(url) + secure = parts.scheme == "wss" + host = parts.hostname + port = parts.port or (443 if secure else 80) + resource = parts.path or "/" + if parts.query: + resource += "?%s" % parts.query + + self.sock = socket.create_connection((host, port), timeout=self._timeout) + if secure: + self.sock = ssl._create_unverified_context().wrap_socket(self.sock, server_hostname=host) + self.sock.settimeout(self._timeout) + + hostport = "[%s]" % host if ":" in host else host # bracket IPv6 literals + if port not in (80, 443): + hostport = "%s:%d" % (hostport, port) + + key = getText(base64.b64encode(os.urandom(16))) + lines = ["GET %s HTTP/1.1" % resource, + "Host: %s" % hostport, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: %s" % key, + "Sec-WebSocket-Version: 13"] + for _ in (header or ()): + lines.append(_) + if cookie: + lines.append("Cookie: %s" % cookie) + lines.extend(("", "")) + self.sock.sendall(getBytes("\r\n".join(lines))) + + self._readHandshake(key) + + def _readHandshake(self, key): + while b"\r\n\r\n" not in self._buffer: + chunk = self._recvSome() + if not chunk: + raise WebSocketException("incomplete WebSocket handshake response") + self._buffer += chunk + + head, _, self._buffer = self._buffer.partition(b"\r\n\r\n") + lines = getText(head).split("\r\n") + + try: + self.status = int(lines[0].split(" ", 2)[1]) + except (IndexError, ValueError): + raise WebSocketException("malformed WebSocket handshake response") + + for line in lines[1:]: + name, _, value = line.partition(":") + self._headers[name.strip().lower()] = value.strip() + + if self.status != 101: + raise WebSocketException("Handshake status %d" % self.status) # 'Handshake status' is matched in connect.py + + accept = getText(self._headers.get("sec-websocket-accept", "")) + expected = getText(base64.b64encode(hashlib.sha1(getBytes(key + _GUID)).digest())) + if accept != expected: + raise WebSocketException("invalid 'Sec-WebSocket-Accept' in handshake response") + + def getheaders(self): + return dict(self._headers) + + def send(self, payload, opcode=OPCODE_TEXT): + self._sendFrame(getBytes(payload), opcode) + + def _sendFrame(self, data, opcode): + length = len(data) + frame = bytearray() + frame.append(0x80 | opcode) # FIN set, single (unfragmented) frame + + if length < 126: + frame.append(0x80 | length) # client frames must set the mask bit + elif length < 65536: + frame.append(0x80 | 126) + frame += struct.pack("!H", length) + else: + frame.append(0x80 | 127) + frame += struct.pack("!Q", length) + + mask = bytearray(os.urandom(4)) + frame += mask + payload = bytearray(data) + for i in range(length): + payload[i] ^= mask[i % 4] + frame += payload + + try: + self.sock.sendall(bytes(frame)) + except socket.timeout: + raise WebSocketTimeoutException("timed out while sending data") + except ssl.SSLError as ex: + if "timed out" in str(ex).lower(): + raise WebSocketTimeoutException("timed out while sending data") + raise + + def recv(self): + data = bytearray() + while True: + fin, opcode, payload = self._recvFrame() + if opcode == OPCODE_CLOSE: + self._closed = True + raise WebSocketConnectionClosedException("WebSocket connection closed") + elif opcode == OPCODE_PING: + self._sendFrame(bytes(payload), OPCODE_PONG) + continue + elif opcode == OPCODE_PONG: + continue + + data += payload + if fin: + break + + return getText(bytes(data)) + + def _recvFrame(self): + header = bytearray(self._readStrict(2)) + fin = (header[0] >> 7) & 1 + opcode = header[0] & 0x0F + masked = (header[1] >> 7) & 1 + length = header[1] & 0x7F + + if length == 126: + length = struct.unpack("!H", self._readStrict(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._readStrict(8))[0] + + mask = bytearray(self._readStrict(4)) if masked else None + payload = bytearray(self._readStrict(length)) + if mask is not None: # servers must not mask, but unmask defensively + for i in range(length): + payload[i] ^= mask[i % 4] + + return fin, opcode, payload + + def _readStrict(self, count): + while len(self._buffer) < count: + chunk = self._recvSome() + if not chunk: + raise WebSocketConnectionClosedException("WebSocket connection closed") + self._buffer += chunk + + result, self._buffer = self._buffer[:count], self._buffer[count:] + return result + + def _recvSome(self): + try: + return self.sock.recv(8192) + except socket.timeout: + raise WebSocketTimeoutException("timed out while receiving data") + except ssl.SSLError as ex: + # Python 2 raises ssl.SSLError('The read operation timed out') instead of socket.timeout on a + # TLS read timeout - which is exactly sqlmap's normal "read frames until timeout" path over wss + if "timed out" in str(ex).lower(): + raise WebSocketTimeoutException("timed out while receiving data") + raise + + def close(self): + if self.sock is not None: + try: + if not self._closed: + self._sendFrame(struct.pack("!H", 1000), OPCODE_CLOSE) # normal closure + except (socket.error, WebSocketException): + pass + try: + self.sock.close() + except socket.error: + pass + self.sock = None diff --git a/lib/utils/deps.py b/lib/utils/deps.py index b681ef4ef..99fcc31e8 100644 --- a/lib/utils/deps.py +++ b/lib/utils/deps.py @@ -83,17 +83,6 @@ def checkDependencies(): logger.warning(warnMsg) missing_libraries.add('python-impacket') - try: - __import__("websocket._abnf") - debugMsg = "'websocket-client' library is found" - logger.debug(debugMsg) - except ImportError: - warnMsg = "sqlmap requires 'websocket-client' third-party library " - warnMsg += "if you plan to attack a web application using WebSocket. " - warnMsg += "Download from 'https://pypi.python.org/pypi/websocket-client/'" - logger.warning(warnMsg) - missing_libraries.add('websocket-client') - try: __import__("tkinter") debugMsg = "'tkinter' library is found" diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 000000000..6091229a6 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the PURE (network-free) parts of the native WebSocket client in +lib/request/websocket.py: the RFC 6455 accept-key computation, client frame masking, +the length-encoding boundaries (7/16/64-bit), fragment reassembly and control-frame +handling. No socket is opened - frames are fed through a primed buffer and a fake sink. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import base64 +import hashlib +import os +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.websocket import ( + WebSocket, + WebSocketConnectionClosedException, + WebSocketTimeoutException, + _GUID, + OPCODE_TEXT, + OPCODE_CONTINUATION, + OPCODE_PING, + OPCODE_CLOSE, +) + + +class _FakeSock(object): + """Captures everything the client sends, so masked client frames / PONGs can be inspected.""" + def __init__(self): + self.sent = b"" + + def sendall(self, data): + self.sent += data + + def close(self): + pass + + +def _serverFrame(data, opcode=OPCODE_TEXT, fin=1): + """Build an (unmasked, server->client) frame carrying data.""" + if not isinstance(data, bytes): + data = data.encode("utf-8") + frame = bytearray([(fin << 7) | opcode]) + length = len(data) + if length < 126: + frame.append(length) + elif length < 65536: + frame.append(126); frame += struct.pack("!H", length) + else: + frame.append(127); frame += struct.pack("!Q", length) + frame += data + return bytes(frame) + + +def _client(buffer=b""): + ws = WebSocket.__new__(WebSocket) # bypass connect(): no socket + ws.sock = _FakeSock() + ws.status = 101 + ws._headers = {} + ws._timeout = None + ws._buffer = buffer + ws._closed = False + return ws + + +class TestWebSocket(unittest.TestCase): + def test_accept_key_rfc6455_vector(self): + # RFC 6455 section 1.3 canonical example + key = "dGhlIHNhbXBsZSBub25jZQ==" + accept = base64.b64encode(hashlib.sha1((key + _GUID).encode("ascii")).digest()).decode("ascii") + self.assertEqual(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") + + def test_client_frame_is_masked_and_roundtrips(self): + ws = _client() + ws._sendFrame(b"hello", OPCODE_TEXT) + raw = ws.sock.sent + self.assertEqual(bytearray(raw)[0], 0x80 | OPCODE_TEXT) # FIN + text + self.assertTrue(bytearray(raw)[1] & 0x80, "client frame must set the mask bit") + + # feeding the client's own (masked) frame back through the parser must recover the payload + fin, opcode, payload = _client(raw)._recvFrame() + self.assertEqual((fin, opcode, bytes(payload)), (1, OPCODE_TEXT, b"hello")) + + def test_length_encoding_boundaries(self): + for size in (125, 126, 65535, 65536): + ws = _client() + ws._sendFrame(b"A" * size, OPCODE_TEXT) + fin, opcode, payload = _client(ws.sock.sent)._recvFrame() + self.assertEqual(len(payload), size, msg="round-trip failed at length %d" % size) + + def test_recv_reassembles_fragments(self): + buf = _serverFrame("ab", OPCODE_TEXT, fin=0) + _serverFrame("cd", OPCODE_CONTINUATION, fin=1) + self.assertEqual(_client(buf).recv(), "abcd") + + def test_recv_answers_ping_then_returns_data(self): + ws = _client(_serverFrame("hi", OPCODE_PING) + _serverFrame("data", OPCODE_TEXT)) + self.assertEqual(ws.recv(), "data") + # a PONG (opcode 0xA) carrying the ping payload must have been sent back + pong = bytearray(ws.sock.sent) + self.assertEqual(pong[0], 0x80 | 0xA) + + def test_recv_close_raises(self): + ws = _client(_serverFrame(struct.pack("!H", 1000), OPCODE_CLOSE)) + self.assertRaises(WebSocketConnectionClosedException, ws.recv) + + def test_read_timeout_maps_to_ws_timeout(self): + import socket as _socket + import ssl as _ssl + + class _RaisingSock(object): + def __init__(self, exc): + self.exc = exc + def recv(self, n): + raise self.exc + + # both a plain socket timeout and Python 2's TLS 'read operation timed out' must surface as + # WebSocketTimeoutException (sqlmap's frame loop relies on it), while other SSL errors propagate + for exc in (_socket.timeout("timed out"), _ssl.SSLError("The read operation timed out")): + ws = _client(); ws.sock = _RaisingSock(exc) + self.assertRaises(WebSocketTimeoutException, ws.recv) + + ws = _client(); ws.sock = _RaisingSock(_ssl.SSLError("decryption failed")) + self.assertRaises(_ssl.SSLError, ws.recv) + + +if __name__ == "__main__": + unittest.main()