Concluded·15 Jun 2026 · 2 min read

MQTT: anonymous connect and a spoofed publish

MQTT · Mosquitto · Node-RED · Docker · tcpdump · tshark

Same method, the MQTT layer. The broker runs a Node-RED generator publishing a tank and two pumps under northwind/rotterdam/T101/..., including a valve/command topic. The goal is to establish the read and write exposure of a default-configured broker.

Lab setup

  • mosquitto — broker on TCP 1883, config allow_anonymous true, no TLS. This is the default posture of a broker stood up to get an integration working.
  • generator — a Node-RED flow publishing telemetry and control topics every ~1 s.
  • Capture runs on the broker's docker bridge; the spoofed publish comes from a throwaway peer container (eclipse-mosquitto image) on the same network.

Reproduce

# capture plaintext MQTT
tcpdump -i br-<id> -w mqtt.pcap 'tcp port 1883'
 
# publish a control message from an anonymous peer — no -u / -P
docker run --rm --network <net> eclipse-mosquitto \
  mosquitto_pub -h mosquitto -t northwind/rotterdam/T101/valve/command -m 0

Every CONNECT is anonymous

tshark -r mqtt.pcap -Y 'mqtt.msgtype==1' \
  -e mqtt.conflag.uname -e mqtt.conflag.passwd
tshark decode: MQTT CONNECT frames, username and password flags both False
Every CONNECT carries no username and no password flag, and the broker accepts each one. No TLS, so topics and values are plaintext.

The spoofed publish

Filtering publishes to the valve command topic by source and payload shows the generator and the anonymous peer side by side:

tshark: publishes to valve/command grouped by source IP and payload
172.22.0.3 is the generator (payload 65). 172.22.0.5 is the anonymous peer, publishing payload 0x30 = '0' — a valve-close the broker accepted and fanned out to every subscriber.

The generator re-asserts its own value a second later, which is its own lesson about last-writer races on a shared broker. The point is that the broker accepted an unauthenticated write to a control topic and delivered it; a subscribed controller acts on each command it receives.

One publish, delivered to every subscriber

172.22.0.5no credentialsPUBLISHvalve/command = 0brokeraccepts, fans outController / PLCSCADA / HMIHistorianacts on the command it receives
An unauthenticated client at 172.22.0.5 published to the valve command topic. The broker accepted it and delivered it to every subscriber, including the controller that acts on commands.

Mitigation

Unlike Modbus, MQTT can defend itself at the protocol layer:

Four moves, all at the protocol layerAuthenticateper-client username or certificate. Turn allow_anonymous off. The broker should know who every publisher is.Authorize (ACL)a client may publish only its own topics. A tank sensor cannot publish a valve command, whatever it claims to be.Encrypt (TLS)8883 with certificates, so the man on the wire reads nothing and cannot replay what he could not see.Sparkplug STATEthe primary-host birth/death contract lets the edge notice a hijack and the consumer reject an unknown source.
Unlike Modbus, MQTT can defend itself. Each of these is a config change, not a network redraw. The protocol layer is where the fix lives here.

Turn allow_anonymous off; require a username or certificate; give each client an ACL so a sensor can publish only its own topics; run TLS on 8883; and use the Sparkplug STATE contract so the primary host and edge detect an imposter. All config, not a network redraw — though segmentation from the earlier parts still applies.

Harness

The broker config, the publish command, and mqtt.pcap are in the modbus-copilot repo security/. Isolated lab, own equipment only.

Why this matters, in plain terms: your MQTT broker probably trusts everyone.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.