MQTT tenant isolation
A clone-and-run lab that makes cross-tenant message disclosure on a multi-tenant MQTT broker executable — then contains it with one line of Mosquitto ACL. The same attack runs before and after; only the broker config changes.
Threat model
A single MQTT broker serves multiple tenants on shared infrastructure. Each tenant works under its own topic namespace — tenant_a/#, tenant_b/# — and the trust assumption holding the whole thing together is simple: one tenant cannot read another's traffic.
The vulnerability
Authentication is not authorization. The broker has auth switched on — allow_anonymous false, real credentials — so this is not an open broker. But with no acl_file configured, Mosquitto grants every authenticated user full topic access by default. Any tenant can subscribe to the wildcard # and receive every other tenant's messages.
The impact is cross-tenant message disclosure: one authenticated tenant reading another's private telemetry, on a broker that looks secured because it asks for a password.
Reproduction
One command — ./run_demo.sh — stands up the vulnerable broker and runs the attack. tenant_b publishes a secret to tenant_b/telemetry; tenant_a, subscribed to #, receives it:
>> tenant_b publishes its secret [tenant_b] published to tenant_b/telemetry: SECRET::tenant_b turbine rpm=8421 key=hunter2 >> tenant_a subscribes to '#' and listens [tenant_a] LEAKED <- tenant_b/telemetry: SECRET::tenant_b turbine rpm=8421 key=hunter2 [tenant_a] captured 1 message(s) -- ISOLATION BROKEN
Output from the recorded run. ISOLATION BROKEN — a tenant just read another tenant's data.
The fix
The hardened broker is byte-for-byte the same setup plus a single directive — an acl_file:
listener 1883allow_anonymous falsepassword_file /mosquitto/config/passwd+ acl_file /mosquitto/config/aclfile
That file is the entire defense. One load-bearing line:
pattern readwrite %u/#
%u expands to the authenticated username at connect time, scoping each user to their own subtree — tenant_a can only touch tenant_a/#. ACLs are default-deny, so anything a rule doesn't explicitly permit is refused.
Verification
The identical attack — same scripts, same credentials, unchanged — runs against the hardened broker:
>> tenant_a subscribes to '#' and listens [tenant_a] connected (rc=Success), subscribing to '#' [tenant_a] subscription result reason_codes=[Granted QoS 1] [tenant_a] captured nothing -- isolation holding
Worth being precise about what happens here. The broker doesn't reject the # subscription — it's still Granted QoS 1. The default-deny ACL filters delivery: tenant_a's subscription succeeds, but the broker never hands it any topic outside tenant_a/#, so tenant_b's secret is simply never delivered. The eavesdropper captures nothing.
The attack's invariance across both runs is the whole point. Nothing about the attacker changed — only the broker config — so the config is unambiguously the control.
Takeaway
Auth and ACL are one control, not two. Authentication without authorization is what leaks here; authorization without authentication is meaningless, because %u scoping needs an authenticated identity to bind to — which is why allow_anonymous false is load-bearing, not incidental.
And wildcard containment lives broker-side. A client can always request #; the broker just has to refuse to deliver beyond that user's rules. Trusting the client to ask nicely was never the control.