In this writeup I’m going to demonstrate that a seemingly benign bug in Eclipse Mosquitto, publicly disclosed back in August 2026 and still unpatched upstream, can in fact be exploited to gain unauthenticated (pre-auth) RCE. The GitHub issue1 incorrectly concluded that the bug has no impact on memory safety. I had found the flaw independently about a week earlier while researching the Home Assistant Green for Pwn2Own Ireland 20262, but the public disclosure made it ineligible for the competition. This writeup also includes advisory information for affected users. See the Affected section below.

This blog post is a two-part series. The first part will cover a deep dive into the Mosquitto bug and how it can be reliably exploited to gain RCE in Home Assistant’s add-on container. In a second blog post I will go through escalating that foothold to full device compromise via a container escape.

Affected

Eclipse Mosquitto (upstream): Versions 2.1.x (2.1.0 onwards) built with the built-in WebSocket listener (WITH_WEBSOCKETS=yes) and exposing it on the network. There is no fixed release yet, so until there is, restrict or firewall the WebSocket port, or disable the built-in WebSocket listener if you don’t need MQTT-over-WebSocket.

Home Assistant Mosquitto add-on: Versions ≤ 7.1.1 in the default configuration. Fixed in 7.1.2 (19 October 2026), so update to 7.1.2 or later.

Information about the disclosure and timeline can be found at the bottom of this page.

Target

As in previous years, the Home Assistant Green was part of Pwn2Own’s target lineup in 2026. Home Assistant Green is a small, ready-to-use smart home hub sold by Nabu Casa, the company that funds development of the open-source Home Assistant (HA) project. It ships with Home Assistant OS, a lightweight Linux system built with Buildroot3. The Green runs an aarch64 build. Most of the interesting parts run as Docker containers, including HA Core, which serves the web interface, and the Supervisor, which manages Core, add-ons, backups and updates.

Home Assistant Green

In the first part of this blog post series we are going to focus solely on Eclipse Mosquitto which ships as an official HA add-on and also runs inside a container in the HA deployment. Eclipse Mosquitto is a popular open-source MQTT broker, a central server that relays messages between clients and is heavily used across IoT and machine-to-machine deployments. It speaks MQTT over TCP, TLS and WebSockets. Within HA, Mosquitto exposes its ports to the network which makes it a direct attack surface.

Methodology

Initially I spent some time on HA’s core attack surface but later decided to have a look at the official add-ons (or apps) within the HA ecosystem focusing on memory corruption vulnerabilities. I noticed that a lot of the exposed attack surface is already in OSS-Fuzz4, a service provided by Google that runs continuous fuzzing on critical open-source software. So I tried to find coverage gaps in their fuzzing.

I discovered one such gap in the harness of Eclipse Mosquitto. Among other ports Mosquitto’s HA container exposes tcp/1884 to the network which is used for MQTT-over-WebSocket. I figured it could be a good entry point to the HA Green device. So with a little help of AI I built several fuzzing harnesses, one of them coming back with an OOB write on the heap, reachable pre-auth, perfect. To make sure the same bug couldn’t be found by AI statically I pointed both Opus 4.8 and GPT-5.6 Sol at the relevant file of the code base to hunt for vulnerabilities. Both models came back with numerous minor findings, including the bug I found with fuzzing, but concluded that no memory safety issues were present, just like the GitHub issue claims. This was certainly not ideal, as it turned out in the end, but I decided to still pursue it further.

Mosquitto Packet Reassembly

Let’s start with some background on Mosquitto’s packet handling. This should give us an idea of how things are supposed to work and where they go wrong later. On the transport layer, Mosquitto supports TCP and WebSockets (WS) both with and without TLS. In the code all packets first traverse through packet__read, where a local__read function pointer is selected according to the transport of the current connection.

The data structure struct mosquitto, often called mosq in the code, and later also referred to as context, holds context information about a client connection. It’s important to note that this struct is persistent per connection across multiple packets as long as the particular client is connected and alive.

// lib/packet_mosq.c:645
int packet__read(struct mosquitto *mosq)
{
	// (...)

#if defined(WITH_WEBSOCKETS) && WITH_WEBSOCKETS == WS_IS_BUILTIN
	if(mosq->transport == mosq_t_ws){
		local__read = net__read_ws;
	}else
#endif
	{
		local__read = net__read;
	}
	do{
		// (...)
		rc = packet__read_single(mosq, state, local__read);
		// (...)
	}while(mosq->in_packet.packet_buffer_to_process > 0);

	return MOSQ_ERR_SUCCESS;
}

packet__read_single is where packet reassembly takes place. Reassembly is needed because for both transports, TCP and WS, reads don’t necessarily align to MQTT packets. A socket read can slice one packet across reads or deliver several at once. So the parser needs to take care of this.

The function works as a resumable parser. All state lives in the client context’s in_packet. As long as a packet is incomplete, it is reassembled into in_packet. Once it’s complete, it is handed over to handle__packet which goes on with dispatching it.

// lib/mosquitto_internal.h:196
struct mosquitto__packet_in {
	uint8_t *payload; // the MQTT packet being assembled
	uint32_t remaining_mult; // varint multiplier
	uint32_t remaining_length; // decoded body length
	uint32_t packet_length; // complete byte count of whole packet
	uint32_t to_process; // body bytes still needed
	uint32_t pos; // position in payload
	uint16_t packet_buffer_to_process;
	uint16_t packet_buffer_pos;
	uint16_t packet_buffer_size;
	uint8_t command; // command byte
	uint8_t *packet_buffer; // read-ahead buffer
	int8_t remaining_count; // varint-decode state
};

The parser expects the data arriving to be structured like the following:

MQTT Packet Structure

In the case of local__read = net__read_ws the above structure is wrapped in, and may be split across, multiple WS frames. So net__read_ws takes care of removing this framing layer.

Parsing proceeds in four stages. The first three have a resume gate. Because the socket is non-blocking, a read can return EAGAIN, meaning no data available right now, at any point mid-packet. When that happens the function returns, and the next call, triggered when more data arrives, picks up where it left off. For example, stage A is passed once the command byte has been set, and likewise stage B once remaining_length is parsed.

MQTT Packet Parsing

Stages A and B consume bytes from packet_buffer which is refilled from the socket via read_header (which calls local__read) only when it’s drained. Stage C instead calls local__read directly, reading the payload body straight into the payload buffer. Note that packet_buffer is a read-ahead buffer with a fixed size, whereas the payload buffer is allocated based on the size specified by remaining_length by the client.

// lib/packet_mosq.c:471
static int packet__read_single(struct mosquitto *mosq, enum mosquitto_client_state state, ssize_t (*local__read)(struct mosquitto *, void *, size_t))
{
	ssize_t read_length;
	int rc = 0;

	// Stage A: Read command byte
	if(!mosq->in_packet.command){
		// read first byte as command byte
		// (...)
	}

	// Stage B: Read and decode remaining_length varint
	if(mosq->in_packet.remaining_count <= 0){
		// read and decode remaining_count
		// (...)

		if(mosq->in_packet.remaining_length > 0){
			mosq->in_packet.payload = mosquitto_malloc(mosq->in_packet.remaining_length*sizeof(uint8_t));
			// (...)
			if(mosq->in_packet.packet_buffer_to_process > 0){
				// copy remaining read bytes from packet_buffer to payload
				// set to_process = remaining_length - number of bytes already read
			}
		}
	}

	// Stage C: Read payload body
	while(mosq->in_packet.to_process>0){
		read_length = local__read(mosq, &(mosq->in_packet.payload[mosq->in_packet.pos]), mosq->in_packet.to_process);
		if(read_length > 0){
			mosq->in_packet.to_process -= (uint32_t)read_length;
			mosq->in_packet.pos += (uint32_t)read_length;
		}
		// (...)
	}
	// (...)

	// D dispatch + reset
	rc = handle__packet(mosq);
	// (...)
}

Although payload can be populated via the initial read into packet_buffer, which is then copied to payload, it is also possible that payload is only fed through reading from the socket in stage C. This happens when the client only sends the data required for stages A and B but withholds the actual payload data for a future send. So for instance: A client can send the command byte and a remaining_length of 0x10, triggering a payload buffer allocation of 0x10 bytes. After shortly waiting, the next send would then fill the payload buffer.

The Bug

Let’s talk about the bug. We will focus on stage C as described above. The code in this stage makes the assumption that local__read never returns a number that is higher than to_process. However, the WS-specific part for local__read, which is net__read_ws, breaks exactly this assumption. Let’s see why.

As established before, net__read_ws takes care of stripping off the WS-specific header fields and reading from the socket. The actual reading of payload bytes is done inside net__read, which is basically a wrapper for read(mosq->sock, buf, count). So it reads count bytes into buf from mosq->sock, the client’s socket.

In net__read_ws we can observe that len is used to track how many bytes have been read and is returned to the caller. WS frames may legitimately carry an empty payload, a frame with a header but zero data bytes. The function assumes the normal case where a frame carries data. It parses the header fields, then reads the payload those fields describe. However, an empty frame exercises a path the function does not handle properly.

// lib/net_ws.c:229
ssize_t net__read_ws(struct mosquitto *mosq, void *buf, size_t count)
{
	ssize_t len = 0;

	if(mosq->wsd.payloadlen == 0){
		// (...)
		// A
		if(wsd.mask == UINT8_MAX){
			len = read_ws_payloadlen_short(mosq);
		}
		// B
		if(mosq->wsd.mask == 1 && mosq->wsd.mask_bytes > 0){
			len = read_ws_mask(mosq);
			// (...)
		}
	}
	// (...)
	// C
	if(mosq->wsd.payloadlen > 0){
		// (...)
		len = net__read(mosq, buf, count);
		// (...)
	}

	// D
	if(mosq->wsd.pos == (ssize_t)mosq->wsd.payloadlen){
		if(/* CLOSE / PONG / out_packet */){
			len = -1;
			errno = EAGAIN;
		}
		// (...)
		mosq->wsd.payloadlen = 0;
		mosq->wsd.opcode = UINT8_MAX;
		mosq->wsd.mask = UINT8_MAX;
	}
	// (...)

	return len;
}

When an empty frame arrives, the header parsing steps run and each overwrites len with the number of bytes it consumed. Note that neither of these reads is bounded by count. Step A (read_ws_payloadlen_short) reads the byte carrying the mask flag and the short payload length. It records a payload length of zero, and returns 1. B (read_ws_mask) runs only when that byte marked the frame as masked. It then reads the 4-byte masking key and returns 4. So two cases are possible depending on the frame: an unmasked empty frame skips B, leaving len == 1 from A, while a masked empty frame ends on B with len == 4. Strictly speaking A can’t lead to a problem, because count must be > 0 when the function is called so it can’t be smaller than the number of bytes read. I still mention this case here because it will be important later.

On a normal frame with payload data, setting len after reading WS headers wouldn’t be an issue, because the payload read further down (C) would overwrite len with a value clamped to count. But the payload is empty, so that read is skipped. Branch D is entered but also does not change len in our case. So whichever header parsing step ran last has its byte count returned to the caller as-is. Also note that since both are WS header fields, the read data is not written to buf.

So this is where the assumption discussed before breaks and where we arrive at a situation where the returned value can be greater than count or from the caller’s perspective greater than to_process. To understand the consequence let’s go back to the caller.

// lib/packet_mosq.c:587
while(mosq->in_packet.to_process>0){
	read_length = local__read(mosq, &(mosq->in_packet.payload[mosq->in_packet.pos]), mosq->in_packet.to_process);
	if(read_length > 0){
		mosq->in_packet.to_process -= (uint32_t)read_length;
		mosq->in_packet.pos += (uint32_t)read_length;
// (...)

Imagine to_process had a value of 1 and we hit the path discussed above where 4 is returned from local__read. In this case to_process underflows to (uint32_t)-3 (~4GB) which keeps the loop running. pos is advanced by 4 and now points past the end of the payload buffer. On the next iteration the data read from the socket is written at payload[pos] which is out of bounds.

On a side note: Although AI found that net__read_ws can report back the wrong len value, it didn’t put the issue properly into context with the caller and therefore missed the memory corruption issue.

The Primitive

The description of the bug already gave us an idea of what the basic primitive looks like. By provoking an underflow of to_process, through breaking the read_length <= to_process assumption, an overflow of the payload buffer on the heap can be achieved. Let’s exercise through a minimal trigger of this primitive.

Recall the reassembly stages we discussed before. To reach stage C we need to make sure that the remaining_length varint specifies a length that can’t be read in one go from the socket or we simply have to withhold the actual payload, so it can’t be read straight from the socket on the first run.

So we first send a frame carrying only the MQTT header, the command byte and the remaining_length varint. The parser decodes remaining_length, allocates payload to exactly that size, and drops into stage C with to_process equal to the remaining_length, waiting for the body. In case you’re wondering: mosquitto_malloc and mosquitto_free are wrappers around malloc and free to implement size tracking but don’t introduce any noise or changes on the allocations or the heap in general. So they can be considered identical to libc’s malloc and free.

Instead of sending any body payload we then send empty/header-only WS frames using the two paths in net__read_ws returning either 1 or 4. The 4-byte path drives the underflow while the 1-byte path lets us align to_process to a non-multiple of 4 beforehand, so subtracting 4 always steps past zero, which lets us freely choose remaining_length. So once we hit to_process = 3 we use the 4-byte path (a masked empty frame) to underflow to_process.

The table below shows this process where remaining_length = 8 was chosen.

WS HeaderMQTT HeaderMQTT Bodynet__read_ws returnsposto_processStage(s)
82 0210 08--08A + B
80 00--117C
80 00--126C
80 00--135C
80 00--144C
80 00--153C
80 80 00 00 00 00 (masked empty)--49(uint32_t)-1C
80 04-DE AD BE EF413(uint32_t)-5C

Note that the resulting overflow on the payload buffer always begins at offset N+1 or later (here 9), never at the immediately-adjacent offset N. Reaching offset N would require to_process to equal 0, which would complete and free the packet instead. In practice that isn’t really an issue, because we are free to choose our victim’s chunk size and therefore we have control over the alignment.

For example, against a 0x20 victim chunk, which has 24 usable bytes, we request remaining_length = 23, one byte short of filling the chunk. The single unreachable byte at offset N then falls on our own chunk’s last byte rather than the neighbor, and the first out-of-bounds write lands exactly at offset 24, the adjacent chunk’s first byte.

Each time the socket is drained the read returns -1 so packet__read_single returns to the event loop without completing the MQTT packet. Because in_packet including pos, to_process and payload persist on the connection, the next frames resume the same packet and keep writing out of bounds. Obviously after writing payload OOB we can still at any time leave gaps in the write using empty frames which makes it a relative write primitive.

Exploit

On to the fun part. With the discussed primitive in hand let’s dive into building an exploit. The goal with the exploit was to gain a clean and stable RCE with no crash of the broker process.

Leaking the Heap

To turn the described primitive into something more meaningful we start with leaking heap memory. This will allow us to locate our own sprayed objects on the heap later.

When Mosquitto sends packets back to the client packet__write comes into play. In the function we can see that response packets are taken from the client’s context and sent to the client via net__write. Just like net__read, the function net__write is basically just a wrapper around send, writing n bytes from the given buffer to the client’s socket.

// packet_mosq.c:272
int packet__write(struct mosquitto *mosq)
{
	// (...)
	packet = mosq->out_packet;
	// (...)
	while(packet){
		while(packet->to_process > 0){
			write_length = net__write(mosq, &(packet->payload[packet->pos]), packet->to_process);
			if(write_length > 0){
				// (...)
				packet->to_process -= (uint32_t)write_length;
				packet->pos += (uint32_t)write_length;
			}
			// (...)
		}
		// (...)
		next_packet = packet__get_next_out(mosq);
		mosquitto_FREE(packet);
		packet = next_packet;
	}
	// (...)
}

The send is structured like the parser discussed before. As long as there is data to be sent from the out_packet, meaning to_process > 0, the inner loop runs and keeps writing to the client’s socket. Let’s have a look at out_packet’s struct:

// lib/mosquitto_internal.h:184
struct mosquitto__packet {
	struct mosquitto__packet *next;
	uint32_t remaining_length;
	uint32_t packet_length;
	uint32_t to_process;
	uint32_t pos;
	uint16_t mid;
	uint8_t command;
	int8_t remaining_count;
	uint8_t payload[];
};

We learned that with the primitive established we can corrupt adjacent heap memory by overflowing the payload buffer. So the idea is this: If we can align the overflowing chunk with an adjacent mosquitto__packet (out_packet) scheduled for transmission to the client, the overflow primitive can be used to overwrite the to_process value of that packet. Obviously to_process is normally bound to the out_packet’s payload buffer. If we overwrite it with a large value using our primitive, the while(packet->to_process > 0) overruns the actual payload buffer.

As a result net__write will write the number of bytes specified in to_process to the client’s socket, which is the heap memory after the corrupted chunk. This works, because just like the receive loop, packet->pos is never validated to be within the bounds of packet->payload.

The diagram below shows how this plays out. We have the context mosquitto struct which embeds mosquitto__packet_in. The in_packet holds a pointer to payload which is the buffer allocated based on remaining_length and which we can overflow. The exploit needs to make sure a queued mosquitto__packet/out_packet is adjacent to payload so the overflow can overwrite its to_process field.

The queued mosquitto__packet we want next to our payload is a pong. Whenever a client sends a ping, the broker answers with a pong, which it builds as an out_packet on the heap before sending it off. (From here on I use mosquitto__packet, out_packet and pong interchangeably. They all mean the same thing in this exploit). So to get one of these next to our overflowing chunk, we spray them: we flood the broker with pings.

Normally the broker sends each pong straight back to us and frees it. So before spraying we shrink our own socket’s receive buffer (SO_RCVBUF). Now the broker can’t push the responses out and the pongs just pile up on the heap. We give each ping a 125-byte payload so every pong ends up as a 0xc0 chunk, and after a few thousand pings the heap is packed with these identical pong chunks.

Now we need our overflowing chunk to sit among them. For that we open the overflow connection’s own MQTT packet with a remaining_length of 0xb0, which also lands in a 0xc0 chunk, so its payload is allocated right in the pong spray. Then we send a few more pings so at least one pong sits directly behind it. That pong is our victim. From our payload, its to_process field is 0xd0 bytes away, one chunk (0xc0) plus the field’s offset in mosquitto__packet (0x10). Overwriting it with a large value turns the pong into the big out-of-bounds read.

Note: For brevity I will omit heap metadata in the following diagrams/examples.

Forward Read on the Heap

Recovering Absolute Heap Addresses

The leak gives us a chunk of heap memory that is adjacent to the corrupted pong. The length of the leak is determined by the value we set to_process to. To determine where we are on the heap the exploit uses what we already sprayed. Each pong packet has a pointer to its successor (*next), which points to the next outgoing packet in the queue.

// lib/mosquitto_internal.h:184
struct mosquitto__packet {
	struct mosquitto__packet *next;
	// (...)

Ideally the leak looks something like this:

Heap Leak

The leak window begins inside the corrupted pong N, at its payload buffer. So pong N’s own next pointer sits in the header just before the window and isn’t captured. In this idealized dump the pongs are laid out adjacently, each 0xc0 bytes (the pong chunk size) after the previous, so pong N+1’s next points straight at pong N+2 sitting right behind it. With the chunks adjacent we know where each successor lands and can compute the absolute address of every byte in the leak.

However, you might infer that this order of chunks on the heap isn’t deterministic. So to make this more robust the exploit actually relies on knowing what the next pong actually should look like.

With every ping a payload can be specified. The pong echoes back that exact payload. So we can use a unique pattern for every ping/pong so that when we encounter a next pointer of a given pong chunk with a payload pattern N, we know that the next pointer points to a chunk with the payload N + 1. This way the next pong doesn’t need to be adjacent to the current and we can still reliably recover the absolute addresses of the leaked heap data.

Arbitrary Write

Escalating our primitive further to an arbitrary write follows a very similar path to the initial leak. Just like before we aim at corrupting an adjacent data structure. If we can align our overflowing chunk with a second client’s context struct, whose embedded in_packet holds the payload pointer, the pointer can be overwritten to point at an arbitrary location. When we feed payload data to that second context it is written to the location the payload pointer was set to.

// lib/mosquitto_internal.h:300
struct mosquitto {
	// (...)
	struct mosquitto__packet_in in_packet;
	struct mosquitto__packet *out_packet;
	// (...)
};
// lib/mosquitto_internal.h:196
struct mosquitto__packet_in {
	uint8_t *payload;
	uint32_t remaining_mult;
	uint32_t remaining_length;
	uint32_t packet_length;
	uint32_t to_process;
	uint32_t pos;
	// (...)
};

Remember that the allocation of payload is based on remaining_length of the MQTT header and held until the MQTT packet is complete or the connection is dropped. This means that we can simply open a second connection triggering the allocation, then let the first client corrupt the payload pointer of the second and finally write to it using the second connection by delivering MQTT payload bytes. The corruption looks like this:

Arbitrary Write Corruption

In principle this allows us to write to arbitrary locations. However, it depends on grooming the heap to achieve this layout. It is also not repeatable, because once we have written, there is no way to restore the payload pointer to a different target or the original one. This is not only cumbersome but we also risk crashing the process if free is called with payload pointing into libc for example on packet cleanup.

How to deal with this? The idea is to use multiple contexts to form a ring for a repeatable and self-healing write. I’ll call them context A, B and C. One write cycle looks like this:

  1. Use context A to corrupt context B and C’s payload pointers via the forward relative write. B’s payload then points to A, C’s to the target address of the arbitrary write.
  2. Write to the target address using context C.
  3. Use context B to restore context A, pos=0.

This process can be repeated for an arbitrary number of writes. At the end we only have to make sure to restore B and C’s payload pointers to their original values using step 1, so we don’t trigger a crash on teardown.

Arbitrary Write Context Ring

To set this up the exploit sprays context objects during the initial groom where we also spray the pings. The goal is that enough sprayed contexts land in the leaked memory area. If that’s the case we can reliably select A, B and C where A’s payload buffer must be beneath contexts B and C on the heap to make the above work.

While the ping spray uses a single connection, for the context spray we open N new connections to trigger the allocations. These connections are then parked in the parser waiting for body payload to be sent by the client (stage C). It’s important to understand that these context objects can be kept alive via pings as long as we desire and we can write to their payload buffer at any time.

The contexts are tagged similarly to the pings so we can identify each context in the leak. But instead of using payload as an identifier, which is not resident to the client context, we use remaining_length of the in_packet with sizes starting from 0x4000 to 0x4100. By identifying the context structs, we also know where the corresponding payload buffers for each context are. Note that by knowing where those contexts are on the heap and the capability to write to them at will, we can also use them to store arbitrary payloads at known addresses.

A few notes on the heap groom: The exploit makes sure that a minimum number of N contexts, where N corresponds to the number of contexts used for later primitives including the arbitrary write, is present in the initial leak. If so the exploit continues. Otherwise the groom is repeated.

Arbitrary Read

The write won’t be the last primitive we need. A later step in the chain calls for an arbitrary read as well, so let’s build one now. Achieving it isn’t as straightforward as the write. As you might have noticed we can’t use the same trick of overwriting payload of the pong packet as we did for the in_packet because pong’s payload is an inlined array rather than a pointer:

// lib/mosquitto_internal.h:184
struct mosquitto__packet {
	// (...)
	uint8_t payload[];
};

So we need to dig deeper: The broker’s normal job is to deliver messages. When it sends a client a PUBLISH, it copies a stored message’s payload out of the heap and onto the wire. If we can forge a stored message with its payload pointer, the broker hands us that memory in a PUBLISH. That is our arbitrary read.

The relevant code for this mechanism is in db__message_write_inflight_out_single, which builds an outgoing PUBLISH for a queued message:

// src/database.c:1356
static int db__message_write_inflight_out_single(struct mosquitto *context, struct mosquitto__client_msg *client_msg)
{
	struct mosquitto__base_msg *base_msg;
	// (...)
	uint32_t payloadlen;
	const void *payload;
	// (...)

	base_msg = client_msg->base_msg;
	// (...)
	payloadlen = base_msg->data.payloadlen;
	payload = base_msg->data.payload;
	// (...)

	switch(client_msg->data.state){
		// (...)
		case mosq_ms_publish_qos1:
			rc = send__publish(context, mid, topic, payloadlen, payload, qos, retain, retries, subscription_id, base_msg_props, expiry_interval);
			// (...)
	}
	// (...)
}

client_msg is a per-client queue node holding a pointer to a stored message base_msg. The function reads the address and length of the content out of that struct and passes them to send__publish, which copies payloadlen bytes from payload into the outgoing packet. So if we forge a base_msg with payload set to an arbitrary address and payloadlen to the number of bytes we want, that PUBLISH comes back to us carrying exactly that region of the broker’s memory. The trigger for this mechanism is the client sending a SUBSCRIBE.

However, the catch is that this delivery path only runs for a fully connected, authenticated session, and so far we are still a pre-auth attacker. So before we can read, we have to promote one of our sprayed contexts into an active session.

Auth Bypass

Promoting a context means getting it past the checks that stand between a parked CONNECT and a session the broker will deliver to. Let’s have a look at those checks:

// src/handle_subscribe.c:51
if(context->state != mosq_cs_active){
	log__printf(NULL, MOSQ_LOG_INFO, "Protocol error from %s: SUBSCRIBE before session is active.", context->id);
	return MOSQ_ERR_PROTOCOL;
}
// src/database.c:1481
if(context->state != mosq_cs_active
		|| !net__is_connected(context)
		|| context->msgs_out.inflight == NULL){
	return MOSQ_ERR_SUCCESS;   // nothing is sent
}

So first of all we need its state field to read mosq_cs_active. The second check also wants a live socket (net__is_connected) and a non-empty outgoing queue, which we’ll supply ourselves.

The subscribe also must pass authorization. Reaching the handler isn’t enough. Subscribing runs an ACL check, and mosquitto_acl_check has two early exits that matter to us: it refuses if the context has no client id, and it returns success immediately if the context is a bridge, skipping all credential and ACL logic:

// src/handle_subscribe.c:193
rc2 = mosquitto_acl_check(context, sub.topic_filter, 0, NULL, qos, false, properties, MOSQ_ACL_SUBSCRIBE);
// src/plugin_acl_check.c:141
int mosquitto_acl_check(struct mosquitto *context, const char *topic, ...)
{
	if(!context->id){
		return MOSQ_ERR_INVAL;
	}
	if(context->bridge){
		return MOSQ_ERR_SUCCESS;   // skip ACL
	}
	// (...) normal ACL/plugin checks
}

With the arbitrary write we already have everything to bypass these checks with a handful of field writes into one of the sprayed contexts we located on the heap. We set its state to mosq_cs_active to clear both gates, plant a client id so mosquitto_acl_check doesn’t reject us on the !context->id check, and point its bridge field at a zeroed struct, which we carved out of the context’s own payload buffer as a scratch, so the ACL check short-circuits to success.

Finally we reset the context’s in_packet so the parser expects a fresh command rather than the body of the half-read CONNECT it was parked on, leaving it ready to accept a SUBSCRIBE as if the CONNECT had completed. The one remaining requirement, a non-empty outgoing queue, we fill in the next step with the forged message itself.

Forging the Message

With the context promoted, let’s turn to the actual read. We have seen db__message_write_inflight_out_single take the payload pointer out of base_msg but where does it actually come from and how is it triggered?

At the very end of the SUBSCRIBE handler the client’s outgoing queue is flushed:

// src/handle_subscribe.c:283
if(context->out_packet == NULL){
	rc = db__message_write_queued_out(context);
	if(rc){
		return rc;
	}
	rc = db__message_write_inflight_out_latest(context);
	if(rc){
		return rc;
	}
}
// src/database.c:1476
int db__message_write_inflight_out_latest(struct mosquitto *context)
{
	// (...)
	if(context->state != mosq_cs_active
			|| !net__is_connected(context)
			|| context->msgs_out.inflight == NULL){
		return MOSQ_ERR_SUCCESS;
	}

	if(context->msgs_out.inflight->prev == context->msgs_out.inflight){
		/* Only one message */
		return db__message_write_inflight_out_single(context, context->msgs_out.inflight);
	}
	// (...)
}

So client_msg is nothing more than context->msgs_out.inflight, the first node of that context’s outgoing queue.

Normally that node isn’t something we’d ever control. When the broker routes a real PUBLISH to a subscriber, db__message_insert allocates the client_msg, links it into the recipient’s queue, and points its base_msg at the shared stored message. We can skip that pipeline entirely.

Using the arbitrary write we forge both objects, a client_msg and the base_msg it points at, and set context->msgs_out.inflight to point at our forged client_msg. That satisfies the last outstanding requirement from the auth bypass: the outgoing queue is no longer empty. As a scratch pad for the forged structs we can use the same parked context’s payload buffer as in the auth bypass.

Once all writes are done we can trigger the read with a single SUBSCRIBE message on the promoted and corrupted client context. The broker then copies the data specified by the target address into an outgoing PUBLISH and sends it back over our own socket. We can then strip off the MQTT header and what remains is a verbatim copy of the broker’s memory at the address we chose. Arbitrary read achieved! The read is also repeatable by re-forging the same objects, pointing the payload pointer to a new arbitrary address and firing SUBSCRIBE again.

You might wonder what happens after the PUBLISH built from our forged objects is sent, aren’t we about to hit an invalid free? The answer is no, and the reason is that the exploit deliberately uses QoS1 for the message. After a QoS1 publish, the broker doesn’t consider the message done. It waits for the client to acknowledge it with a PUBACK. By simply never sending that acknowledgement, we keep the node parked in its waiting state, so the broker never reaches the path that would free it.

The only remaining risk is shutdown: when the context disconnects, teardown walks the outgoing queue and frees whatever it finds, including our fake node. So before letting the connection close, we detach the queue (set inflight back to NULL), leaving nothing for the broker to free.

Leaking LIBC

With a heap leak and arbitrary r/w in hand there are numerous ways of taking this further. Since the binary has a writable global offset table (GOT) I aimed at the classic technique of GOT overwrite to achieve RCE in the end which requires both libc and the PIE base. I decided to start with leaking libc before leaking PIE. It could also be done the other way round but I considered this to be the safer and more reliable choice. Also, I wanted to avoid scanning the heap even though the arbitrary read would allow that.

Remember that at this point of the exploit we still have our sprayed and located context structs on the heap including their payload buffers. Conveniently, the payload buffers span from 0x4000 to 0x4100 in size, which, when freed, are well past the small-bin range. So when one of them is freed libc puts it on the unsorted bin first, and later into a large bin. Both bins are doubly-linked lists whose heads live inside libc’s main_arena, and main_arena is a static structure in libc’s data segment. So leaking the list head gives us a libc leak.

The leak works because those list links are stored inside the freed chunk itself. When libc puts the chunk on a bin, it writes the fd and bk pointers into the first sixteen bytes of the chunk’s user area.

So we pick one of our located contexts, close its connection to trigger the free, and then use the arbitrary read to read the memory where its payload buffer used to be. The bytes there are no longer our data but the fd/bk links libc just wrote.

A note on reliability: A leaked eight-byte value isn’t automatically a libc pointer: the freed chunk could have been reused, coalesced, or simply not yet written the way we expect. If the chunk is not alone in the free list, the fd/bk pointers might point to the next chunk on the list. So the exploit does a few validation checks before trusting it. For example: We can eliminate values that are not in a plausible range or subtract libc’s main_arena offset and check whether the result is page aligned. The exploit also probes the typically adjacent landing packet_buffer chunk because the context chunk is often consolidated with it.

Leaking PIE

With the libc base leaked, obtaining the PIE base is only a matter of following known pointers. There’s no heap manipulation this time, just three arbitrary reads that walk a fixed chain from libc into the dynamic loader and out to the main executable.

The first read aims at libc’s GOT, where we read the resolved address of ld.so’s _rtld_global, a loader state variable referenced by libc. _rtld_global begins with _dl_ns, the loader’s array of link namespaces, so reading eight bytes at that address (offset 0) gives us the first entry’s _ns_loaded field which is a pointer to a link_map. The namespace at index 0 is the default one, where the main executable and its dependencies live, so this _ns_loaded points at the main executable’s link_map. That is where our second read is aimed. Finally, l_addr sits at the start of the link_map (offset 0), and it holds the base address of the loaded PIE executable, our Mosquitto binary. We can verify that with a third read of its ELF header. That’s it, PIE base recovered.

Leaking PIE image base

RCE

At this point there are certainly numerous ways of finally popping a shell. A first version of my exploit used the House of Apple 25 technique, which I had also used in a previous writeup. In a nutshell, the technique abuses libc’s FILE internals to turn an arbitrary write primitive (and some leaks) into a call to system: we forge a fake FILE, repoint libc’s stdout at it, and the next stdio operation on stdout dispatches through our object into system with our command as its argument.

Its big advantage is that it doesn’t depend on the GOT being writable. But in our case the GOT is writable, and that makes classic GOT overwrite more feasible. The GOT holds the resolved addresses of the libc functions the binary calls, so with it writable we can overwrite one entry with the address of system, and the next call through that slot runs system instead of the original function. We lean on this not necessarily because it’s simpler to land, but because it’s trivial to undo and it causes less collateral damage: we can pick a target function we can reach on demand through our own trigger, so we control exactly when the redirected slot fires, instead of leaving it to be hit by unrelated broker activity.

So the goal is to point one, ideally not too crowded, GOT entry at system, call the function through it with our command as the argument, and put the entry back.

I ended up picking strncmp(s1, s2, n). There is a call site where that first argument is a fully attacker-controlled string. Also, that call site is on a path we can trigger on demand, handling a PUBLISH, so the redirected slot fires when we send the corresponding packet.

The call site is in handle__publish, right after the message is accepted:

// src/handle_publish.c:388
if(!strncmp(base_msg->data.topic, "$CONTROL/", 9)){
	// (...) $CONTROL/ handling
}

base_msg->data.topic is the topic of the PUBLISH we just sent. With strncmp’s GOT slot replaced by system, the call becomes system(base_msg->data.topic). Since we control the topic, it is system("<some command>").

Reaching the publish handler is only possible because of the auth bypass. So the PUBLISH is sent from our promoted/auth bypassed context. So what we do is:

  1. Overwrite strncmp@GOT -> &system
  2. Trigger
  3. Overwrite strncmp@GOT -> &strncmp

On a busy broker we can obviously still hit collateral. During the overwrite window, any other strncmp call in the broker, e.g. another client’s PUBLISH or SUBSCRIBE hitting one of these prefix checks, also becomes a system call on its own first argument. The chances are high that such strings aren’t valid shell commands, so they return nonzero and the checks fall through to normal routing as expected, and even when the branch is entered it’s crash-safe, and the unknown topic is simply rejected later.

Here’s the final result:

Wrapping up

In this blog post we walked through the process of turning a seemingly benign bug into pre-auth RCE using lots of heap magic, all without crashing the broker. With that we have an initial foothold inside an add-on container on the Home Assistant Green device. Stay tuned for part 2, where we escape the container and gain code execution on the host system!

Disclosure

I reported the vulnerability shown in this writeup to the Eclipse Foundation through its official security tracker. The public GitHub issue1 had concluded the bug has no memory safety impact, which left the real risk unrecognized, so my aim was to get it taken seriously and fixed upstream. I offered to coordinate on disclosure and to give them time to ship a fix before publishing. The report was ultimately closed as a duplicate on 28 October, without a remediation or disclosure timeline. By then the root cause had been public since August and Home Assistant had already shipped a fix, which left no protective reason to keep waiting. For this reason I moved publication up from 18 November (90 days) to 4 November. As of publication there is no fixed upstream release and no CVE has been assigned.

Credit goes to UWCSZhou for the original public report (#3704), and to whoever filed the earlier report in Eclipse’s security tracker that mine was closed as a duplicate of.

Thanks to the Home Assistant project and especially Stefan Agner for swiftly shipping a fix for Home Assistant users.

Timeline

DateEvent
2026-08-04My initial discovery of the bug.
2026-08-10The same root cause is independently published as GitHub issue #3704.
2026-08-20I report the memory safety impact (pre-auth RCE) to Eclipse via its GitLab security tracker.
2026-09-16Follow-up asking how Eclipse wants to handle remediation and disclosure: treat it as already public (I would then publish once ready), or embargo it and assign a CVE under their three-month policy.
2026-10-01Follow-up saying I would prefer to coordinate and offer 7 days’ notice before publishing my technical writeup once it’s ready.
2026-10-16Follow-up stating that I would publish 90 days after my initial report. I inform Home Assistant the same day.
2026-10-19Home Assistant ships a fix in add-on 7.1.2.
2026-10-21First response from Eclipse: after a Home Assistant maintainer raises the report through another channel, a member of the Eclipse Foundation Security Team acknowledges my report and tags the Mosquitto maintainers to provide a remediation/disclosure timeline.
2026-10-28Eclipse closes the report as a duplicate, without a fix or disclosure timeline.
2026-11-04Publication.

References