Описание
AMQP message conversion can repeatedly disconnect AMQP 0-9-1 consumers
1. Summary
A producer that speaks AMQP 1.0 can publish a well formed message whose application properties contain a nested map with a key longer than 255 bytes. RabbitMQ accepts and stores this message. The failure happens later, on the consumer side: to deliver the message to an AMQP 0.9.1 consumer, the broker converts the message into AMQP 0.9.1 properties, which produces a field table whose key cannot be represented in AMQP 0.9.1 (field table keys are shortstr, maximum 255 bytes). Encoding the delivery then raises content_properties_shortstr_overflow inside the connection's writer process, which tears the connection down. The consumer sees INTERNAL_ERROR (541) and never receives the message.
The message is not removed by the failed delivery. Unless an operator purges the queue or consumes the message with a different protocol, every subsequent AMQP 0.9.1 consumer that reaches it is disconnected in the same way, with no limit on the number of attempts. This turns a single small publish into a persistent denial of service for the AMQP 0.9.1 consumer population of that queue.
Two properties of the issue raise it above a queue level nuisance:
- The producer does not need any permission on the destination queue. Publish authorization is evaluated against the exchange, as documented, so a user whose only permission is
writeon a shared exchange can plant the message in any queue bound to that exchange, including queues owned by other users or tenants that the producer cannot read, write or configure. I verified this with a user restricted towrite=^poc\.shared$. - Each failed delivery writes failure output to the broker log. I measured 1293 bytes of broker log per victim reconnect cycle (541 bytes for the connection itself, 752 bytes for the failure). A consumer that reconnects automatically, or an attacker who opens connections in a loop, therefore converts one publish into unbounded broker log growth, which is a node level resource problem rather than a per queue one.
The documented conversion rules state the opposite behaviour: entries that fail the shortstr condition are not converted, and the message remains deliverable with that entry omitted. The top level conversion applies exactly that rule. Only the nested map case is missing the check, which is why the message is stored in a state the broker cannot deliver.
2. Affected versions
Verified by building and running the source tree in this repository:
- branch main, commit 169a13dc8a, version reported as 4.2.0+beta.4.1143.g169a13dc8a, Erlang/OTP 29.
The same unguarded nested map conversion exists unchanged in the maintained release branches of this repository. I checked each branch for the expression that performs the nested conversion:
Native AMQP 1.0 support, and with it this conversion module, was introduced in RabbitMQ 4.0, so 4.0 and later are affected.
3. Root cause
3.1 The conversion
An AMQP 1.0 message published by a client is stored in its original format. When an AMQP 0.9.1 consumer receives it, mc_amqpl converts the message sections into AMQP 0.9.1 properties. The entry point for the delivery path is:
deps/rabbit/src/rabbit_channel.erl:2769outgoing_content/2deps/rabbit/src/rabbit_channel.erl:2771Mc2 = mc:convert(mc_amqpl, Mc1)
For application properties, mc_amqpl maps each entry to an AMQP 0.9.1 header, but only if the key satisfies the shortstr condition:
deps/rabbit/src/mc_amqpl.erl:153-155
The same guard exists for message annotations:
deps/rabbit/src/mc_amqpl.erl:168-175
The values themselves are converted by to_091/2. For maps, the conversion recurses without any guard on the nested keys:
deps/rabbit/src/mc_amqpl.erl:736-745
unwrap/1 returns the key as a plain binary. There is no length check and no validity check, so a nested key of 300 bytes becomes a 300 byte entry in an AMQP 0.9.1 field table.
3.2 The encoder
AMQP 0.9.1 field table keys are shortstr. The encoder enforces this and fails hard:
deps/rabbit_common/src/rabbit_binary_generator.erl:148-152
Field table entries are encoded through table_field_to_binary/1, which calls it for the field name:
deps/rabbit_common/src/rabbit_binary_generator.erl:109-110
3.3 What the consumer sees
The exit happens while the delivery frame is being assembled in the connection's writer process. The writer terminates and the reader maps that to a connection close:
deps/rabbit_common/src/rabbit_writer.erl:185-191handle_info(Message, State)catches the exit and stops with{shutdown, Reason}
The broker log shows exactly this. Excerpt from the node log during testing:
The client sees a connection close with reply code 541:
Because the failure happens before the delivery is completed, the message is not acknowledged. It is requeued and remains in the queue with its unusable header.
3.4 Documented behaviour versus observed behaviour
The RabbitMQ documentation for inter protocol conversions defines the shortstr condition and states what must happen when it is not met:
Source: https://www.rabbitmq.com/docs/conversions, section "Conventions"
shortstr: (condition) a string that is smaller than 256 bytes and contains only valid UTF-8 encoded data with noNUL(binary zero octet) characters.
Conditions can be read to be evaluated in the order they are written. For example an AMQP
message_idof typeutf8with more than 255 bytes will fail the firstshortstrcondition and then fall through to the next line. If there is no matching line then the data will not be converted (and therefore not be available to the consuming application).
The same page applies that condition to the exact sections used by this proof of concept, in the table for "AMQP 1.0 -> AMQP 0.9.1":
application properties, conditionKey: shortstr, targetproperties.headersmessage annotations, conditionKey: shortstrandx-*, targetproperties.headers
In other words, the documented outcome for an over long key is that the entry is dropped and the rest of the message is delivered. The implementation does that for top level keys and fails to do it for nested keys, and instead of dropping the entry it aborts the delivery and disconnects the consumer.
4. Impact
4.1 Persistent denial for AMQP 0.9.1 consumers of the queue
The poisoned message stays in the queue. Every AMQP 0.9.1 consumer that reaches it is disconnected with INTERNAL_ERROR before receiving any message. In testing, 35 consecutive consumer connections were killed by a single poisoned message and it was still queued afterwards. There is no self healing: the message is not dead lettered, not expired and not removed by a failed delivery.
The same message can still be consumed with AMQP 1.0 or STOMP. That means the queue is not entirely dead, but the consumer population that uses AMQP 0.9.1 (the most widely deployed protocol for this broker) is denied service indefinitely, and only an operator action (purge, delete the queue, or consume with another protocol) restores it.
4.2 The producer does not need permission on the destination queue
RabbitMQ authorizes a publish against the exchange, not against the queues it routes to. The AMQP 1.0 documentation states this for the queue target form:
Source: https://www.rabbitmq.com/docs/amqp, section "Address v2"
The 3rd format
/queues/:queuecauses all messages on the given link to be sent to queue:queue. The queue must exist. Internally, this queue target still uses the default exchange. Hence, the user needs write permissions to exchangeamq.default.
Publishing to /exchanges/:exchange/:routing-key requires write on that exchange. No permission on the destination queue is consulted. I verified this end to end:
pocwriterpermissions:configure=^$,write=^poc\.shared$,read=^$- an attempt by
pocwriterto read the victim queue is refused by the broker:
- the same user publishes to
/exchanges/poc.shared/victim.k1, and the message lands inpoison_q, a queue bound to that exchange:
The practical consequence is that a producer with write access to one shared exchange can disable AMQP 0.9.1 consumption for any queue bound to it. In shared exchange topologies (topic exchanges, fanout exchanges, shared audit or dead letter queues) that includes queues belonging to other applications or tenants, and queues the producer cannot even see.
4.3 Silent message loss with automatic acknowledgement
With a consumer that uses automatic acknowledgement, the message is removed from the queue when the delivery fails, and the consumer never receives it. Observed during testing on a separate queue: the delivery attempt killed the connection, the queue then reported zero messages and the client reported zero messages received. The publisher, if it used a confirm mode publish, has already received an acknowledgement.
4.4 Broker log amplification, measured
Each failed delivery writes failure output to the broker log. I measured this from the broker's own log stream, taking a byte cursor before and after a fixed number of operations, on the same node and with the same clients:
| Measurement | Bytes |
|---|---|
| 10 victim reconnect cycles (each cycle: connect, basic.get, connection killed) | 12930 |
| per reconnect cycle | 1293 |
| 10 bare connect and close cycles (no queue involved) | 5411 |
| per bare connection | 541 |
| failure logging attributable to one failed delivery | 752 |
The poison is permanent, so the loop is unlimited. Arithmetic from the measured per cycle cost:
| Victim reconnects per second | Broker log growth |
|---|---|
| 20 | about 2.1 GiB per day |
| 200 | about 20.8 GiB per day |
| 2000 | about 208 GiB per day |
The first two rows are reachable with consumer auto reconnect behaviour, the third with a loop of cheap TCP connections, which the attacker can drive directly if the account also holds read permission on the poisoned queue. The per cycle figures are measurements; the daily figures are arithmetic from those measurements and are presented as such. RabbitMQ's disk alarms monitor queue data directories, not the log volume, so sustained log growth is not covered by them.
5. Severity
CVSS 4.0 vector:
Base score: 7.1 (High).
Reasoning, stated plainly:
- Attack vector network, complexity low, no attack requirements beyond a bound queue. A single publish is enough and it is a normal, well formed AMQP 1.0 message, so the attacker does not need a malformed frame, a race or any timing condition.
- Privileges required low. Any account with write permission on an exchange is enough, and that is the ordinary permission of a publisher. For the cross tenant case, an account with write on a shared exchange, and no permission at all on the victim queue, is enough.
- Availability impact high. The denial is persistent, it survives consumer restarts and broker restarts (verified: the message and its unusable header are stored), and it also produces node level log growth.
- No confidentiality or integrity impact, which is why the vector is availability only.
I am aware that a triager may view "a publisher can disturb the queue it publishes to" as expected. The two properties that take this beyond that reading are 4.2 (the attacker does not need any permission on the queue that is actually damaged, so the permission model does not contain the attack) and 4.4 (the same publish drives node level log growth). If the project prefers to rate the queue level denial alone, the cross permission aspect should at least be considered for the authorization boundary it crosses.
6. Reproduction
6.1 Environment
- RabbitMQ built and run from this repository, branch main at 169a13dc8a, on Erlang/OTP 29, macOS arm64.
- Default listeners: AMQP 0.9.1 and AMQP 1.0 both on 5672, management HTTP on 15672.
- Python 3 with
pikainstalled (pip install pika) for the consumer. The publisher uses only the standard library.
6.2 Files
All files are in this report's poc directory. Only these two are needed to reproduce; run_e2e.sh is the wrapper that produced the transcript in section 6.4 and can be used as is.
poc_publish_poison.py(standard library only): performs the AMQP 1.0 handshake (SASL PLAIN), opens a session and a sender link, and transfers one message whose application properties contain a nested map whose key is 300 bytes long.poc_consume_victim.py(requirespika): opens an AMQP 0.9.1 connection, performsbasic.getwith manual acknowledgement, and reports what the broker does. Repeat it as many times as you like; the message is never consumed.run_e2e.sh(optional): creates the topology and two restricted users, publishes as the restricted producer, runs the victim loop, and prints queue state.
6.3 Steps
-
Start the broker from this repository (
./sbin/rabbitmq-serverafter a build, orgmake run-broker). -
Create a queue, a shared exchange, a binding, and two restricted users. This mirrors an ordinary shared exchange deployment:
- Publish the poisoned message as the restricted producer:
- Confirm the message landed in a queue the producer has no permission on:
- Run the victim loop:
- Confirm the message is still queued after the loop:
6.4 Transcript
This is the captured output of run_e2e.sh on the test node, trimmed to the steps that matter (the wrapper also waits for the broker to start and prints contrast notes at the end).
6.5 What the client and the broker report
Client side, the connection is closed by the broker with 541:
Broker side, each attempt produces the encoder exit and the connection close:
6.6 Scope notes
- Verified on classic queues. The conversion is shared by all queue types that store a message published with AMQP 1.0 and deliver it to an AMQP 0.9.1 consumer, so quorum queues and streams are expected to behave the same way, but I did not test them and do not claim it as verified.
- An AMQP 0.9.1 publisher cannot create such a message: AMQP 0.9.1 field table keys are shortstr by construction. A STOMP publisher that sends an over long header name has its own connection closed at publish time (observed during testing), so it does not create a stored poison either. The planting step requires an AMQP 1.0 publish.
- Messages that are consumed with AMQP 1.0 or STOMP are unaffected. This is specific to the AMQP 0.9.1 conversion and encoder.
7. Suggested remediation
-
Apply the same condition in the nested conversion that is already applied at the top level. In
mc_amqpl:to_091/2, for the{map, M}clause and for the list and array helpers, filter out keys for which?IS_SHORTSTR_LEN/1is false, exactly asmc_amqpl.erl:154and:169do for top level keys and message annotations. Dropping the entry matches the documented conversion rules. -
Harden the delivery path so that an unencodable header cannot take down a consumer connection. Encoding a single delivery should not be fatal to the connection: log the offending field once, skip the header, and deliver the message, or reject the delivery and route the message to the dead letter exchange. As it stands, any future conversion gap in the same code path has the same effect.
-
Consider validating at conversion time rather than at encode time. The conversion is where the AMQP 0.9.1 constraints are known; failing there produces a clear error for the publisher or a clean drop, instead of a stored message that can never be delivered.
8. Detection and workarounds for operators
- Detection: search broker logs for
content_properties_shortstr_overflow. Each occurrence is one killed consumer connection. A single queue with repeated occurrences and a stable message count is a poisoned queue. - Containment: purge the affected queue with
rabbitmqctl purge_queue, or delete it withrabbitmqctl delete_queue -p <vhost> <queue>, or consume the queue with an AMQP 1.0 client, which is not affected. - Permission hygiene: restrict
writeon shared exchanges. Producers that only need to publish to their own queues should publish through the default exchange, where the write check is applied to the queue name, which prevents planting messages in other people's queues. - Log volume: put the RabbitMQ log directory on a partition with its own capacity and monitor it, since disk alarms do not cover log growth.
9. Timeline
- 2026-09-20: issue found and reproduced on main at 169a13dc8a.
- 2026-09-20: report prepared, proof of concept minimized to two files (one of them standard library only).
- (report sent)
Appendix A: message that reproduces the issue
The publisher sends one AMQP 1.0 message with this shape (shown in AMQP 1.0 type notation):
The application property key must be of type string (utf8). Application properties whose keys are symbols are dropped by the conversion before this code path is reached, so they cannot be used to reproduce the issue.
Appendix B: relevant source locations
| Location | Role |
|---|---|
deps/rabbit/src/mc_amqpl.erl:153-155 | top level application property conversion, shortstr check present |
deps/rabbit/src/mc_amqpl.erl:168-175 | message annotation conversion, shortstr check present |
deps/rabbit/src/mc_amqpl.erl:736-745 | nested map conversion, no key check, source of the issue |
deps/rabbit/src/rabbit_channel.erl:2769-2771 | delivery path that calls the conversion |
deps/rabbit_common/src/rabbit_binary_generator.erl:109-110 | field table entry encoding |
deps/rabbit_common/src/rabbit_binary_generator.erl:148-152 | shortstr encoder, raises content_properties_shortstr_overflow |
deps/rabbit_common/src/rabbit_writer.erl:185-191 | writer stops on the exit, which closes the connection |
Пакеты
rabbitmq
>= 4.3.0, < 4.3.7
4.3.7
rabbitmq
>= 4.2.0, < 4.2.12
4.2.12
rabbitmq
>= 4.1.0, < 4.1.17
4.1.17
6.3 Medium
CVSS4
6.3 Medium
CVSS4