-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add support for websockets #4579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,264 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| # SPDX-License-Identifier: GPL-2.0-or-later | ||||||||||||||||||||||||||||||||||||||||||||||
| # This file is part of Scapy | ||||||||||||||||||||||||||||||||||||||||||||||
| # See https://scapy.net/ for more information | ||||||||||||||||||||||||||||||||||||||||||||||
| # Copyright (C) 2024 Lucas Drufva <lucas.drufva@gmail.com> | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # scapy.contrib.description = WebSocket | ||||||||||||||||||||||||||||||||||||||||||||||
| # scapy.contrib.status = loads | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Based on rfc6455 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import struct | ||||||||||||||||||||||||||||||||||||||||||||||
| import base64 | ||||||||||||||||||||||||||||||||||||||||||||||
| import zlib | ||||||||||||||||||||||||||||||||||||||||||||||
| from hashlib import sha1 | ||||||||||||||||||||||||||||||||||||||||||||||
| from scapy.fields import (BitFieldLenField, Field, BitField, BitEnumField, ConditionalField, XNBytesField) | ||||||||||||||||||||||||||||||||||||||||||||||
| from scapy.layers.http import HTTPRequest, HTTPResponse, HTTP | ||||||||||||||||||||||||||||||||||||||||||||||
| from scapy.layers.inet import TCP | ||||||||||||||||||||||||||||||||||||||||||||||
| from scapy.packet import Packet | ||||||||||||||||||||||||||||||||||||||||||||||
| from scapy.error import Scapy_Exception | ||||||||||||||||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class PayloadLenField(BitFieldLenField): | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def __init__(self, name, default, length_of, size=0, tot_size=0, end_tot_size=0): | ||||||||||||||||||||||||||||||||||||||||||||||
| # Initialize with length_of (like in BitFieldLenField) and lengthFrom (like in BitLenField) | ||||||||||||||||||||||||||||||||||||||||||||||
| super().__init__(name, default, size, length_of=length_of, tot_size=tot_size, end_tot_size=end_tot_size) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def getfield(self, pkt, s): | ||||||||||||||||||||||||||||||||||||||||||||||
| s, _ = s | ||||||||||||||||||||||||||||||||||||||||||||||
| # Get the 7-bit field (first byte) | ||||||||||||||||||||||||||||||||||||||||||||||
| length_byte = s[0] & 0x7F | ||||||||||||||||||||||||||||||||||||||||||||||
| s = s[1:] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if length_byte <= 125: | ||||||||||||||||||||||||||||||||||||||||||||||
| # 7-bit length | ||||||||||||||||||||||||||||||||||||||||||||||
| return s, length_byte | ||||||||||||||||||||||||||||||||||||||||||||||
| elif length_byte == 126: | ||||||||||||||||||||||||||||||||||||||||||||||
| # 16-bit length | ||||||||||||||||||||||||||||||||||||||||||||||
| length = struct.unpack("!H", s[:2])[0] # Read 2 bytes | ||||||||||||||||||||||||||||||||||||||||||||||
| s = s[2:] | ||||||||||||||||||||||||||||||||||||||||||||||
| return s, length | ||||||||||||||||||||||||||||||||||||||||||||||
| elif length_byte == 127: | ||||||||||||||||||||||||||||||||||||||||||||||
| # 64-bit length | ||||||||||||||||||||||||||||||||||||||||||||||
| length = struct.unpack("!Q", s[:8])[0] # Read 8 bytes | ||||||||||||||||||||||||||||||||||||||||||||||
| s = s[8:] | ||||||||||||||||||||||||||||||||||||||||||||||
| return s, length | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def addfield(self, pkt, s, val): | ||||||||||||||||||||||||||||||||||||||||||||||
| p_field, p_val = pkt.getfield_and_val(self.length_of) | ||||||||||||||||||||||||||||||||||||||||||||||
| val = p_field.i2len(pkt, p_val) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if val <= 125: | ||||||||||||||||||||||||||||||||||||||||||||||
| self.size = 7 | ||||||||||||||||||||||||||||||||||||||||||||||
| return super().addfield(pkt, s, val) | ||||||||||||||||||||||||||||||||||||||||||||||
| elif val <= 0xFFFF: | ||||||||||||||||||||||||||||||||||||||||||||||
| self.size = 7+16 | ||||||||||||||||||||||||||||||||||||||||||||||
| s, _, masked = s | ||||||||||||||||||||||||||||||||||||||||||||||
| return s + struct.pack("!BH", 126 | masked, val) | ||||||||||||||||||||||||||||||||||||||||||||||
| elif val <= 0xFFFFFFFFFFFFFFFF: | ||||||||||||||||||||||||||||||||||||||||||||||
| self.size = 7+64 | ||||||||||||||||||||||||||||||||||||||||||||||
| s, _, masked = s | ||||||||||||||||||||||||||||||||||||||||||||||
| return s + struct.pack("!BQ", 127 | masked, val) | ||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise Scapy_Exception("%s: Payload length too large" % | ||||||||||||||||||||||||||||||||||||||||||||||
| self.__class__.__name__) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class PayloadField(Field): | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| Field for handling raw byte payloads with dynamic size. | ||||||||||||||||||||||||||||||||||||||||||||||
| The length of the payload is described by a preceding PayloadLenField. | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| __slots__ = ["lengthFrom"] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def __init__(self, name, lengthFrom): | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| :param name: Field name | ||||||||||||||||||||||||||||||||||||||||||||||
| :param lengthFrom: Field name that provides the length of the payload | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| super(PayloadField, self).__init__(name, None) | ||||||||||||||||||||||||||||||||||||||||||||||
| self.lengthFrom = lengthFrom | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def getfield(self, pkt, s): | ||||||||||||||||||||||||||||||||||||||||||||||
| # Fetch the length from the field that specifies the length | ||||||||||||||||||||||||||||||||||||||||||||||
| length = getattr(pkt, self.lengthFrom) | ||||||||||||||||||||||||||||||||||||||||||||||
| payloadData = s[:length] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if pkt.mask: | ||||||||||||||||||||||||||||||||||||||||||||||
| key = struct.pack("I", pkt.maskingKey)[::-1] | ||||||||||||||||||||||||||||||||||||||||||||||
| data_int = int.from_bytes(payloadData, 'big') | ||||||||||||||||||||||||||||||||||||||||||||||
| mask_repeated = key * (len(payloadData) // 4) + key[: len(payloadData) % 4] | ||||||||||||||||||||||||||||||||||||||||||||||
| mask_int = int.from_bytes(mask_repeated, 'big') | ||||||||||||||||||||||||||||||||||||||||||||||
| payloadData = (data_int ^ mask_int).to_bytes(len(payloadData), 'big') | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+90
to
+95
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if("permessage-deflate" in pkt.extensions): | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| payloadData = pkt.decoder[0](payloadData + b"\x00\x00\xff\xff") | ||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||
| logging.debug("Failed to decompress payload", payloadData) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+97
to
+101
|
||||||||||||||||||||||||||||||||||||||||||||||
| if("permessage-deflate" in pkt.extensions): | |
| try: | |
| payloadData = pkt.decoder[0](payloadData + b"\x00\x00\xff\xff") | |
| except Exception: | |
| logging.debug("Failed to decompress payload", payloadData) | |
| extensions = getattr(pkt, "extensions", None) | |
| decoder = getattr(pkt, "decoder", None) | |
| # RFC6455 permessage-deflate uses RSV1 to indicate compression. | |
| if (extensions | |
| and "permessage-deflate" in extensions | |
| and getattr(pkt, "rsv", 0) & 0x4 | |
| and decoder | |
| and callable(decoder[0])): | |
| try: | |
| payloadData = decoder[0](payloadData + b"\x00\x00\xff\xff") | |
| except zlib.error: | |
| logging.debug("Failed to decompress payload: %r", payloadData) |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception handler calls logging.debug("Failed to decompress payload", payloadData). Passing payloadData as a second argument without a % placeholder will cause a TypeError in Python's logging (it tries to apply % formatting). Use a format placeholder (e.g. %r) or log with exc_info=True so decompression failures don't crash dissection.
| logging.debug("Failed to decompress payload", payloadData) | |
| logging.debug("Failed to decompress payload: %r", payloadData) |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WebSocket.__init__ uses a mutable default argument (extensions=[]). This can leak state between instances if the list is mutated. Use None as the default and create a new list/dict per instance.
| def __init__(self, pkt=None, extensions=[], decoder=None, *args, **fields): | |
| def __init__(self, pkt=None, extensions=None, decoder=None, *args, **fields): | |
| if extensions is None: | |
| extensions = [] |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extract_padding() returns '' (a str) rather than b'' (bytes). Packet.extract_padding is expected to return bytes, and returning a string can break dissection and TCP reassembly logic. Return b"" for the payload portion.
| return '', s | |
| return b'', s |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the SERVER_OPEN handshake branch, http_data.Upgrade.lower() is called without checking that Upgrade is present. For malformed/partial responses this will raise AttributeError and break TCP stream parsing. Mirror the request-side guard (if not http_data.Upgrade or ...).
| if not http_data.Upgrade.lower() == b"websocket": | |
| if not http_data.Upgrade or http_data.Upgrade.lower() != b"websocket": |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if b"Sec-WebSocket-Extensions" in http_data.Unknown_Headers: assumes Unknown_Headers is a dict. When the response is malformed and Unknown_Headers is None (which is possible per HTTP dissector), this will raise TypeError. Guard with if http_data.Unknown_Headers and ... before doing membership checks.
| if b"Sec-WebSocket-Extensions" in http_data.Unknown_Headers: | |
| if http_data.Unknown_Headers and b"Sec-WebSocket-Extensions" in http_data.Unknown_Headers: |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After the handshake, the code returns early when "original" not in metadata. In TCPSession, subsequent parsing of leftover bytes (padding) calls tcp_reassemble with a cleared metadata dict, so this guard can prevent dissecting multiple WebSocket frames that arrive in the same TCP segment. Consider storing the needed direction info in session (or metadata) during the first call and allowing parsing even when metadata["original"] is missing.
| if "original" not in metadata: | |
| return | |
| if "permessage-deflate" in session["extensions"]: | |
| is_server = True if metadata["original"][TCP].sport == session["server-port"] else False | |
| ws = WebSocket(bytes(data), extensions=session["extensions"], decoder = session["server-decoder"] if is_server else session["client-decoder"]) | |
| if "permessage-deflate" in session["extensions"]: | |
| # Determine direction (server vs client). When metadata["original"] is | |
| # not available (e.g., leftover bytes in the same TCP segment), | |
| # fall back to the last known direction stored in the session. | |
| if "original" in metadata: | |
| is_server = metadata["original"][TCP].sport == session["server-port"] | |
| session["last-direction-is-server"] = is_server | |
| else: | |
| is_server = session.get("last-direction-is-server", False) | |
| ws = WebSocket( | |
| bytes(data), | |
| extensions=session["extensions"], | |
| decoder=session["server-decoder"] if is_server else session["client-decoder"], | |
| ) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -529,10 +529,10 @@ def do_dissect(self, s): | |||||
| """From the HTTP packet string, populate the scapy object""" | ||||||
| first_line, body = _dissect_headers(self, s) | ||||||
| try: | ||||||
| Method, Path, HTTPVersion = re.split(br"\s+", first_line, maxsplit=2) | ||||||
| self.setfieldval('Method', Method) | ||||||
| self.setfieldval('Path', Path) | ||||||
| self.setfieldval('Http_Version', HTTPVersion) | ||||||
| method_path_version = re.split(br"\s+", first_line, maxsplit=2) + [None] | ||||||
| self.setfieldval('Method', method_path_version[0]) | ||||||
| self.setfieldval('Path', method_path_version[1]) | ||||||
| self.setfieldval('Http_Version', method_path_version[2]) | ||||||
|
Comment on lines
+532
to
+535
|
||||||
| except ValueError: | ||||||
| pass | ||||||
| if body: | ||||||
|
|
@@ -573,10 +573,10 @@ def do_dissect(self, s): | |||||
| ''' From the HTTP packet string, populate the scapy object ''' | ||||||
| first_line, body = _dissect_headers(self, s) | ||||||
| try: | ||||||
| HTTPVersion, Status, Reason = re.split(br"\s+", first_line, maxsplit=2) | ||||||
| self.setfieldval('Http_Version', HTTPVersion) | ||||||
| self.setfieldval('Status_Code', Status) | ||||||
| self.setfieldval('Reason_Phrase', Reason) | ||||||
| version_status_reason = re.split(br"\s+", first_line, maxsplit=2) + [None] | ||||||
|
||||||
| version_status_reason = re.split(br"\s+", first_line, maxsplit=2) + [None] | |
| version_status_reason = re.split(br"\s+", first_line, maxsplit=2) + [b""] |
Copilot
AI
Mar 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change adds support for omitting the reason phrase, but there is no regression test covering parsing of a status line like HTTP/1.1 101\r\n (no reason phrase). Adding a small unit/regression test would prevent future breakage of this behavior.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||
| # WebSocket layer unit tests | ||||||
| # Copyright (C) 2024 Lucas Drufva <lucas.drufva@gmail.com> | ||||||
| # | ||||||
| # Type the following command to launch start the tests: | ||||||
|
||||||
| # Type the following command to launch start the tests: | |
| # Type the following command to start the tests: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PayloadLenField.addfield()manually builds the second byte for extended payload lengths by unpacking the in-progress bitfield tuple (s, _, masked = s) and OR-ingmaskedinto 126/127. This drops the partial bitfield state and also sets the mask bit in the wrong position (it should be0x80, not0x01), so masked frames with payload length > 125 will be serialized incorrectly. Consider relying on Scapy's bitfield packing for the mask/payloadLen byte and adding separate conditional fields for the 16/64-bit extended length.