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.

GitHub repoRecorded demoMosquitto · Docker · security

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 1883
allow_anonymous false
password_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.