Contents
Introduction
This post documents CVE-2026-5857, an out-of-bounds write (a bug where a program writes past the end of a memory buffer and changes whatever data is stored after it) in the MQTT client of Contiki-NG, an operating system for small networked devices. I reported it, and VulnCheck assigned it as the CVE Numbering Authority.
The interesting part is not that a bounds check was missing. A bounds check is present, it is written correctly, and it still does not stop the overflow. The check compares the incoming topic length against the buffer size, exactly as you would want. But the line immediately above it records that the length has been read. When the topic is too long, the check rejects it and the function returns without undoing that record. The next time the function runs, the record still says the length was handled. The function skips the entire block that contains the check and copies the attacker’s bytes with no validation at all.
Getting the function to run a second time is the whole of the bug. MQTT runs over TCP, which delivers a stream of bytes rather than whole messages, so an attacker who controls the sending side chooses where the message is split. Sending the length in one TCP segment and the topic in the next is enough.
VulnCheck assigned CVSS v3.1 8.1 and CVSS v4.0 9.2, both with Attack Complexity: High. That qualifier is the honest part of the score. The attacker has to be the broker the device is connected to, or has to be able to modify the traffic between them, which is possible because Contiki-NG’s MQTT client does not support TLS. No host on the internet can reach this on its own.
Part 4 follows the overflow all the way to a shell, in a recorded demonstration, and is equally precise about what that demonstration does not show: it runs on x86-64 Linux, not on a microcontroller.
Part 1: Background
What Is Contiki-NG?
Contiki-NG describes itself as “an open-source, cross-platform operating system for Next-Generation IoT devices.” IoT stands for Internet of Things — network-connected devices such as sensors, meters, and switches, as opposed to general-purpose computers.
The devices it targets are much smaller than a phone or a laptop. The documentation names “energy-efficient architectures such as the ARM Cortex-M3/M4 and the Texas Instruments MSP430,” and the maintainers’ published paper describes the target as “severely constrained wireless embedded devices.” In practice that means a microcontroller with tens of kilobytes of RAM and a few hundred kilobytes of flash memory, running a single program with no separate operating-system kernel underneath it. The repository ships 14 platform targets: twelve real hardware families, plus a native host build and the Cooja simulator. It is distributed under the 3-clause BSD license.
One honest note on scale, from someone who has spent enough time in this codebase to have reported three findings in it. Contiki-NG’s own README, website, and published paper name no commercial deployments, and its stated aims are research prototyping, reducing time to market, and teaching. Its documented impact is academic. A widely repeated list of deployed products — city sound monitoring, street lights, power meters — belongs to the original Contiki project, which is a separate and earlier system. Attributing that list to Contiki-NG would be wrong. How many shipping devices run Contiki-NG is not publicly documented, and I am not going to guess.
What Is MQTT?
MQTT is a messaging protocol built for exactly this class of device: small messages, low bandwidth, unreliable links. It uses a publish and subscribe model, which works as follows.
Devices do not send messages to each other directly. Instead a central server receives every message and passes it on. The MQTT 3.1.1 specification calls that server the Server and never uses the word broker, but broker is what almost everyone calls it, and this post does too. The specification defines it as “a program or device that acts as an intermediary between Clients which publish Application Messages and Clients which have made Subscriptions.” A device that wants to report a temperature reading publishes it to a named topic, for example sensors/room1/temperature. Any device that has subscribed to that topic receives a copy. The publisher is never told which devices are subscribed.
The consequence that matters for this vulnerability is that the broker sends messages to the client, unprompted. After a Contiki-NG device connects and subscribes, the broker can send it a PUBLISH packet at any time, and the device parses whatever arrives. So the code on the small device is also attack surface — the set of places where data from outside reaches code that has to interpret it. Attack surface on a client is easy to overlook. The device started the connection, so the data that comes back over it is easy to treat as trusted.
How a PUBLISH Packet Is Laid Out
Every MQTT packet begins with a fixed header, followed by a variable header, followed by a payload. For a PUBLISH packet the layout is:
| Part | Size | Field |
|---|---|---|
| Fixed header | 1 byte | Packet type (3 for PUBLISH) plus four flag bits |
| Fixed header | 1 to 4 bytes | Remaining Length — how many bytes follow |
| Variable header | 2 bytes | Topic Length |
| Variable header | variable | Topic Name |
| Payload | remainder | The message content |
That is the layout of the packet the rest of this post uses, which carries QoS 0. At QoS 1 or 2 a 2-byte Packet Identifier sits between the Topic Name and the payload; nothing below depends on it, and Contiki-NG’s topic parser never reads it.
Two details from this table matter for everything below.
The topic length is a 2-byte field. Two bytes hold values from 0 to 65535, and the MQTT 3.1.1 specification confirms that maximum directly: “you cannot use a string that would encode to more than 65535 bytes.” So a broker may legitimately declare a topic of any length up to 65535. The protocol imposes no smaller limit.
The topic comes first in the variable header, before the payload. The specification requires it: the Topic Name “MUST be present as the first field in the PUBLISH Packet Variable header.” A parser therefore has to read the length, then read that many bytes of topic, before it can find anything else. That ordering is what makes the topic-length field the first attacker-controlled number the parser acts on.
Contiki-NG’s buffer for that topic is 65 bytes:
#define MQTT_MAX_TOPIC_LENGTH 64
One name to keep straight before going further: the same header also defines MQTT_TOPIC_MAX_LENGTH, set to 128. The two names are one transposition apart and are unrelated. The buffer below is sized from MQTT_MAX_TOPIC_LENGTH, the one that is 64.
struct mqtt_message {
uint32_t mid;
char topic[MQTT_MAX_TOPIC_LENGTH + 1];
uint8_t *payload_chunk;
uint16_t payload_chunk_length;
uint8_t first_chunk;
uint16_t payload_length;
uint16_t payload_left;
};
64 bytes of topic plus one byte for the terminating zero that C uses to mark the end of a string. So the protocol permits 65535 bytes and the buffer holds 65. Something in the parser has to reject every length in between.
Why TCP Segmentation Matters Here
MQTT runs over TCP, a protocol that delivers a stream of bytes rather than discrete messages. When a program sends 200 bytes over TCP, the receiver is not guaranteed to get them in one delivery. It may get 5 bytes, then 195. The network decides how to split the stream, and so does the sender.
This means an MQTT parser cannot assume a whole packet has arrived. It has to handle a packet that arrives in pieces: read what is available, record how far it got, and continue when more arrives. Contiki-NG’s parser does exactly that, and it remembers its progress in a set of state flags that persist across deliveries.
That resumption logic is correct in principle and necessary in practice. It is also where the bug is, because the state that is kept between deliveries includes a flag that the bounds check does not clear.
Part 2: The Vulnerability
The vulnerable function is parse_publish_vhdr() in os/net/app-layer/mqtt/mqtt.c. All line numbers below refer to commit 871f5a46, the last commit before the fix. The function is 55 lines and has two blocks: one that reads the topic length, and one that copies the topic.
The First Block: Reading the Length
Here is the first block, with the debug macros removed for readability. Four names recur in it. input_data_ptr is the buffer holding the bytes this TCP delivery brought, and input_data_len is how many bytes that is, so both describe one delivery and nothing beyond it. pos is the caller’s read cursor into that buffer, passed by pointer so the function can advance it. And conn->in_packet is the per-connection state that persists between deliveries. I explain what each line does below.
if(conn->in_packet.topic_len_received == 0) {
conn->in_packet.topic_pos = 0;
conn->in_packet.topic_len = (input_data_ptr[(*pos)++] << 8);
conn->in_packet.byte_counter++;
if(*pos >= input_data_len) {
return;
}
conn->in_packet.topic_len |= input_data_ptr[(*pos)++];
conn->in_packet.byte_counter++;
conn->in_packet.topic_len_received = 1;
if(conn->in_packet.topic_len > MQTT_MAX_TOPIC_LENGTH) {
return;
}
}
Line 1: if(conn->in_packet.topic_len_received == 0) — This is the resumption gate. topic_len_received is a flag stored in the connection’s state, not a local variable, so it is kept from one delivery of network data to the next. The whole block runs only while the flag is zero, meaning the length has not been read yet.
Line 3: topic_len = (input_data_ptr[(*pos)++] << 8) — Reads the first of the two length bytes and shifts it left by 8 bit positions, which moves it into the upper half of a 16-bit value. MQTT stores multi-byte numbers with the most significant byte first, so this is the high byte. (*pos)++ reads the byte at the current position and then advances that position by one.
Lines 5 to 7: if(*pos >= input_data_len) { return; } — A bounds check on the input buffer. If the first length byte was the last byte in this delivery, there is no second byte to read yet, so the function returns and waits for more data. This is correct behaviour, and note that it returns while topic_len_received is still zero, so the block will run again from its first line next time. That is the resumption logic working as designed.
Line 8: topic_len |= input_data_ptr[(*pos)++] — Reads the low byte and combines it with the high byte using a bitwise OR. After this line topic_len holds the full 16-bit value the broker declared. It can be anything from 0 to 65535.
Line 10: conn->in_packet.topic_len_received = 1 — Records that the length has now been read. From this point on the test on line 1 is false, so any future entry into this function skips the entire block.
Lines 11 to 13: if(conn->in_packet.topic_len > MQTT_MAX_TOPIC_LENGTH) { return; } — The bounds check. It compares the declared length against 64 and returns if it is larger. The comparison is correct. The constant is correct. The problem is that it comes after line 10, and its return does nothing to undo line 10.
So when a broker declares a 132-byte topic, this is the state the function leaves behind:
topic_lenis 132, an attacker-chosen valuetopic_len_receivedis 1, so the block containing the check will never run againtopic_receivedis still 0, because no topic bytes have been copied yet- the TCP connection is still open, and nothing was reported to the application
That last point deserves emphasis. In the original source the rejection path calls a DBG() macro before returning, which appears to log a warning. It does not. DEBUG_MQTT is defined as 0 in mqtt.h, so DBG(...) expands to nothing in a default build. The oversized topic is rejected silently, the connection continues, and the parser waits for the rest of the message.
The Second Block: Copying the Topic
if(conn->in_packet.topic_len_received == 1 &&
conn->in_packet.topic_received == 0) {
copy_bytes = MIN(conn->in_packet.topic_len -
conn->in_packet.topic_pos,
input_data_len - *pos);
memcpy(&conn->in_publish_msg.topic[conn->in_packet.topic_pos],
&input_data_ptr[*pos],
copy_bytes);
Lines 1 and 2: The entry condition is that the length has been read and the topic has not. After the rejected first delivery, both halves are satisfied exactly.
Lines 3 to 5: copy_bytes = MIN(topic_len - topic_pos, input_data_len - *pos) — How many bytes to copy this time. MIN takes the smaller of two values: how much of the topic is still outstanding, and how much data is left in this delivery. Note what is not in this expression: the size of the destination buffer. The copy is bounded by the topic length and by the input, never by the 65 bytes that are actually available to write into.
Lines 6 to 8: memcpy(&conn->in_publish_msg.topic[topic_pos], ...) — The copy itself, writing into the 65-byte topic array. With topic_len at 132 and a delivery containing 132 bytes, this writes 132 bytes into 65 bytes of space.
The Two Deliveries
Putting both blocks together gives the attack. It is a single MQTT PUBLISH packet, split across two TCP segments by the attacker.
Figure 1 — The bug is a property of ordering, not a missing test. Both rows are the same function on the same connection. Only the first run is still allowed to reach the check, and by then it has already recorded the length it is about to reject.
The first segment is five bytes: the PUBLISH header byte 0x30, the Remaining Length, and the two topic-length bytes 0x00 0x84, which is 132. That is deliberately the whole segment. Because the parser has consumed everything available and the packet is not complete, it returns and keeps the connection’s state intact.
The second segment carries the 132 topic bytes. The parser is called again with the same connection state. The test on line 1 is now false, so memcpy runs with copy_bytes set to 132.
Why the State Survives Between Segments
The connection state is only wiped when a packet finishes. The relevant code resets it at the top of the input handler, but only under a condition:
if(conn->in_packet.packet_received) {
reset_packet(&conn->in_packet);
}
reset_packet() is a plain memset that zeroes the whole structure, topic_len_received included. But packet_received is only set when a complete packet has been parsed, and after the first segment the packet is deliberately incomplete. The reset does not run, and every flag carries over.
This is also why a single self-contained PUBLISH does not trigger the bug. If the entire packet arrives in one delivery, the first block sets the flag, the check rejects the length, and the function returns with no further data to process. The copy is reached only on a second entry into the parser, which needs either a following TCP segment or a single segment larger than 512 bytes, since newdata() hands anything above the socket’s input buffer to the parser as successive calls. Describing this as “one malformed packet overflows the buffer” would be wrong. The split is not incidental, it is the mechanism.
How Far the Write Can Go
The copy is not unbounded in a single step. copy_bytes is capped by how much data the delivery contains, and Contiki-NG’s TCP input buffer is 512 bytes, so one call into the parser can move at most 512 bytes into the destination. A TCP segment larger than that is handed to the parser as successive 512-byte calls. What is not capped is the total. topic_pos accumulates across those calls, so each one writes progressively further past the end, up to the declared topic_len of 65535. For the proof of concept 132 bytes is enough, and keeping the whole write inside a single segment makes the demonstration easier to follow.
Where the Check Came From
The check did not arrive by accident, and it was not routine hardening either. It was added to fix an earlier report of an overflow in this same function, into this same buffer, and the git history shows exactly how.
Issue #600, opened on 2018-07-11, reported that parse_publish_vhdr() copies into a 65-byte buffer with no length check. That report became CVE-2018-19417. Pull request #702, titled “MQTT buffer overflow fix” and merged on 2018-10-17, closed it.
Commit bcebd3af, dated 2018-10-15, is titled “MQTT parse_publish_vhdr: added missing check of topic length.” Its diff adds a comment and the four-line check in place of a blank line, and the line above them is context rather than an addition:
conn->in_packet.topic_len_received = 1;
-
+ /* Abort if topic is longer than our topic buffer */
+ if(conn->in_packet.topic_len > MQTT_MAX_TOPIC_LENGTH) {
+ DBG("MQTT - topic too long %u/%u\n", ...);
+ return;
+ }
So the flag assignment was already there, and the new check went immediately after it. That placement is the natural one: it puts the check directly beside the value it validates, which is where anyone reading the function would look for it. The property it happens not to have is that it runs before the state is recorded, and that single ordering stayed unchanged for the next seven years and nine months. So the 2026 overflow is the 2018 overflow, in the same function and the same 65-byte buffer, reached by a route the 2018 fix left open.
This is the part of the finding I would most want another reader to take away. A check that is correct, positioned where a reviewer would expect it, and one line later than the assignment it needs to precede is genuinely hard to see. Reading that function top to bottom, nothing looks wrong, because on a single self-contained packet nothing is wrong.
Part 3: What the Overflow Reaches
An out-of-bounds write is only as serious as the data stored next to the buffer. So the question is what follows topic in memory.
C guarantees that the members of a structure are laid out in the order they are declared, at increasing addresses. Reading the two relevant declarations in order gives the answer. Inside struct mqtt_message, topic is followed by payload_chunk, a pointer. That structure sits inside the larger struct mqtt_connection as the member in_publish_msg, and after it come server_host (another pointer), the broker’s address and port, and then socket:
struct mqtt_message in_publish_msg;
char *server_host;
uip_ipaddr_t server_ip;
uint16_t server_port;
struct tcp_socket socket;
And struct tcp_socket begins like this:
struct tcp_socket {
struct tcp_socket *next;
tcp_socket_data_callback_t input_callback;
tcp_socket_event_callback_t event_callback;
void *ptr;
Its second and third members are function pointers — variables that hold the address of a function to call. input_callback is the one the TCP layer invokes every time data arrives on this socket.
So the write that starts inside a 65-byte topic buffer runs forward through several data pointers and into a pair of function pointers. On the 64-bit build I used for testing, input_callback sits 124 bytes past the start of topic.
Figure 2 — The write is twice the size of the buffer, and it ends on something that gets called. Nothing between topic and input_callback is a guard. That distance is only the sum of the fields that happen to be declared in between.
That 124 is specific to the machine I measured it on. C guarantees the order of the fields, not the distances between them, because compilers insert padding to satisfy each type’s alignment requirements. A 32-bit ARM target has 4-byte pointers instead of 8-byte ones, so its distances are smaller. The field order holds everywhere. The exact number has to be measured per target.
From Corrupted Memory to Running Code
A function pointer is just a variable holding an address. When the program calls through it, execution goes wherever that variable points. Overwriting it therefore redirects the next call.
The call site is in Contiki-NG’s TCP layer, in a function named newdata() that runs whenever a segment arrives:
copylen = MIN(len, s->input_data_maxlen);
memcpy(s->input_data_ptr, dataptr, copylen);
if(s->input_callback) {
bytesleft = s->input_callback(s, s->ptr,
s->input_data_ptr, copylen);
}
Nothing here is wrong. The code checks that the pointer is non-null and calls it. The TCP layer has no way to check whether that value is still the address the program set.
Figure 3 — Only the first step is a bug. Everything after it is the program doing exactly what it was written to do, after one value in its memory was changed.
The overflow also overwrites payload_chunk, the pointer immediately after topic. I targeted socket.input_callback rather than that one for a simple reason: payload_chunk is reassigned by the normal code path before the message is handed to the application, so a value written there is replaced before anything reads it. socket.input_callback sits outside struct mqtt_message, no code path rewrites it, and the network stack reads it on purpose the moment more data arrives.
Part 4: The Proof of Concept
A crash proves the write lands somewhere it should not. It does not prove the write is controllable. So the proof of concept goes all the way from a network message to running code.
What the Test Target Is
The target is a small program I wrote that embeds the vulnerable parser. Three things about it need stating clearly.
The vulnerable function is the real function. parse_publish_vhdr() is taken from Contiki-NG at commit 871f5a46, and it is identical once the comments and the DBG() logging are removed, apart from one explicit cast that C’s arithmetic conversions were already performing. struct mqtt_message, struct mqtt_in_packet and struct tcp_socket match the originals field for field. My struct mqtt_connection keeps the tail from in_buffer onward unchanged and drops the fields in front of it, so it is 1280 bytes against Contiki-NG’s 2096. That is the part the overflow never reaches. The part it does reach is the same, which I checked by compiling a program against Contiki-NG’s own headers and comparing the offsets against my copy. The distance from topic to socket.input_callback is 124 bytes in both.
The loop around it is a reduced version. The real tcp_input() is about 227 lines and handles cases this demonstration does not need — draining oversized non-PUBLISH packets, chunking a full input buffer to the application, dispatching every other message type. My version keeps the same control flow for the path that matters and drops the rest, so it is a reimplementation rather than a copy.
Everything below the parser is not the real code. Contiki-NG’s TCP stack, its scheduler, and its hardware drivers are replaced by ordinary POSIX sockets, because the goal was to run the parser on a machine I could inspect. This is a harness, not a Contiki-NG firmware image, and I would not want anyone to read the demonstration as a firmware exploit.
One detail I did keep faithful. Contiki-NG’s own MQTT example declares its connection as static struct mqtt_connection conn;, which places it in the .bss section — the area holding zero-initialised global data — rather than on the stack. My harness declares it the same way. So this is not a stack overflow, and no saved return address is involved. Stack canaries are guard values that a compiler places next to a saved return address to detect an overwrite, so they have nothing to do with this bug in either build.
I also built the harness with two protections switched off, and both matter. Position-independent execution normally loads a program at a different address on every run; disabling it fixes every address, so I could read them straight out of the file. And the stack is normally marked non-executable, so that data landing there cannot be run as instructions; I marked it executable instead.
The Malicious Broker
The attacking side is a small server that implements enough of MQTT to act as a broker. It accepts the connection, answers the client’s CONNECT with a CONNACK, answers the SUBSCRIBE with a SUBACK, and then sends the crafted PUBLISH.
It sends three pieces of data, each in its own TCP segment:
- Five bytes — the PUBLISH header, the Remaining Length, and the topic length
0x00 0x84, which is 132. This is the delivery that sets the flag and then fails the check. - 133 bytes — 132 bytes of topic followed by one byte of payload. The 132 topic bytes are the overflow. Bytes 124 to 131 of that run overwrite
socket.input_callback. - A final segment — this one carries the bytes that end up executing. Its arrival makes the TCP layer call
input_callback, which now holds the address written by the previous segment, and from there execution continues into the data this segment delivered.
Two details are worth naming. The parser also writes a terminating zero at the byte just past the topic, which in this layout lands on the first byte of the neighbouring event_callback pointer; it does no harm here, but it is a second corrupted pointer sitting there. And the split into separate segments is something the attacker has to arrange rather than something that happens by itself. TCP may combine several writes into one segment, so the broker pauses briefly between sends to keep them separate. An attacker controlling the sending socket can do that.
I am not publishing the exploit source. The mechanism above is complete enough to understand and to reason about; a working payload adds nothing to the explanation.
The Demonstration
The recording shows three panes. On the right, the target running the vulnerable parser: it connects, subscribes, and reports each packet it receives. On the lower left, the broker delivering the three segments. On the upper left, a listener waiting on port 4444.
whoami and id run in the target's process.The target’s own output is the useful part. It reports the PUBLISH with topic len=132 and then reports receiving the next segment, exactly as it would for any normal message. Nothing in the parser records a problem. The connection was never dropped and no error was raised, which is the same silence described in Part 2.
What This Demonstrates, and What It Does Not
These two need separating explicitly, because a video of a shell is more convincing than the evidence behind it warrants.
What it demonstrates. The overflow is reachable from the network with no local access and no user interaction. The attacker controls both the length of the write and its contents, and the write reaches a function pointer that the network stack calls on the next segment. That is enough to redirect execution rather than merely crash the program, which is the difference between a denial-of-service bug and a code-execution bug.
What it does not demonstrate. No microcontroller was involved at any point. The only machine on which code ran was an x86-64 Linux host executing an extracted copy of the parser. Reaching the same outcome on a real device needs three things that my test target provided without any effort on my part:
- A known address to write. My binary is not position-independent, so every address is fixed and I could read them straight out of the file. A real attacker needs the target’s memory layout, which in practice means obtaining and analysing the same firmware image.
- Somewhere to put code, and permission to execute it there. In my harness the received bytes sit in a local variable, so they are on the stack, and I had built the binary with an executable stack. Removing that one flag and rebuilding makes the program crash instead. On a real target the received bytes are not on the stack at all: Contiki-NG gives the socket a buffer inside the same connection structure, so they are stored in the same
.bssregion as everything else. - The right instructions. The payload does not transfer between architectures. x86-64 machine code is not ARM Thumb code, and the instruction I redirected execution to happened to exist inside the C library that was statically linked into my test binary. Firmware for a microcontroller contains no such library, so an attacker would have to find their own equivalent in the target’s own code.
There is one more difference, and it is the largest. My payload opens a shell, because that is the clearest way to show that arbitrary code ran. A Contiki-NG device has no shell to open. There is no /bin/sh, no execve, and no process model, so an attacker there is manipulating a single running program rather than starting a new one. The reverse shell is a demonstration convenience. It is not a description of what a compromise means on one of these small devices.
The other direction needs care too, because the obvious next sentence is that microcontrollers are hardened against this, and they are largely not. Cortex-M parts have an optional Memory Protection Unit, which restricts which regions of memory may be written or executed. It does nothing until software programs it, and searching the Contiki-NG source code for the registers that would program it returns no matches. Arm’s default memory map also marks the SRAM region as executable. So the mitigations my test target lacked are, in general, not present on the target class either.
The honest summary is that once the offset to the callback is known, the bug reliably gives an attacker control over where the program jumps next. Turning that into useful code execution on a specific device is a further piece of work, and that work is why both CVSS vectors carry Attack Complexity: High. As of this writing CISA’s assessment records exploitation as none, automatable as yes, and technical impact as partial, and the EPSS score is near 0.5 percent. Those figures change over time.
The Attacker Model
The precise precondition is that the attacker controls what the device receives on its MQTT connection. That means one of two positions: running or having compromised the broker the device connects to, or being positioned on the network path between them. The second position is available because Contiki-NG’s documentation states plainly that “the implementation does not support MQTT over TLS,” so the traffic is neither encrypted nor authenticated and can be modified in transit.
Two clarifications, one in each direction.
It is narrower than an internet-facing bug. The victim here is a client, not a server. An arbitrary host on the internet cannot reach it, and neither can a broker the device has never connected to. This is not comparable to an unauthenticated bug in a listening service.
It does not require the normal connection sequence. My proof of concept completes a normal CONNECT and SUBSCRIBE first, because that is what a device does and it makes the recording easier to follow. That sequence is not a precondition. The input handler does not check the connection state before it parses a PUBLISH, so a malicious server can send the malformed packet as soon as the TCP connection is open.
Adding TLS would not fix this on its own. Encrypting the link removes the on-path injection route, but it does nothing about a malicious or compromised broker, and that is the primary attacker model rather than a secondary one. Separately, the project is not short of transport security in general. Its other protocols do ship with encrypted transports available. The gap is specific to this one client.
Part 5: The Fix
This is fixed. Pull request #3163 was merged into the development branch on 2026-07-10, and the change for this issue is commit 3cde7946, shown here with its log line removed as above:
if(conn->in_packet.topic_len > MQTT_MAX_TOPIC_LENGTH) {
call_event(conn, MQTT_EVENT_ERROR, NULL);
abort_connection(conn);
return -1;
}
The oversized topic now raises an error event to the application and closes the TCP connection, instead of returning and waiting for more bytes. That removes the state the attack depends on: with the connection closed, the flag that was never cleared no longer matters, because nothing further arrives for the copy to use. The same pull request carries several other hardening changes to the MQTT input path.
The CVE gives the affected range as everything before commit a34a2dbd rather than as a version number, so the release to move to is worth naming directly. Release v5.2, published on 14 August 2026, carries the fix. v5.1 and every earlier release do not. When this post first went up, v5.1 was the newest tag and the fix existed only on the development branch. Raising the buffer size was never an alternative either: MQTT_MAX_TOPIC_LENGTH has no configuration override, so a project configuration file cannot enlarge it.
Part 6: Takeaways
A correct check only protects the code that runs after it. The comparison against MQTT_MAX_TOPIC_LENGTH is correct, the resumption flags are the standard way to parse a stream that arrives in pieces, and returning early on bad input is normal. The defect is only visible in the ordering, and reviewing either line on its own finds nothing wrong with it.
Discarding a message is not the same as ending the exchange. The vulnerable code did reject the oversized topic. It returned without copying anything. What it did not do was disconnect. The attacker kept a live connection, a stored length of their choosing, and a parser waiting for more bytes. The fix works by closing the connection, which ends the exchange instead of discarding one message.
Parser state that is kept after one packet ends should be reviewed on its own. This bug needs two TCP segments because the parser carries state between them, and the attacker decides where the boundary is. Every parser that resumes across deliveries works this way. The question to ask of each retained value is what an attacker gains by making the function return at exactly that point.
None of this needed deep MQTT knowledge or a debugger. It needed reading two adjacent lines and asking which one runs first — which is easy to say now, and was not obvious to anyone reading the file before.
References
- CVE-2026-5857 — the CVE record, assigned by VulnCheck
- Pull request #3163 — “mqtt: harden TCP input parsing against malformed and coalesced packets”
- Fix commit a34a2db — the merge that carries the fix
- Release v5.2 — the first tagged release containing the fix
- Contiki-NG — the project repository
- CVE-2018-19417 — the 2018 report of the same overflow, which the misplaced check was added to fix
- Contiki-NG MQTT documentation — including the statement that the client does not support TLS
- MQTT 3.1.1 specification — OASIS standard, section 3.3 covers PUBLISH
Kazuma Matsumoto. “CVE-2026-5857: Remote Code Execution in Contiki-NG’s MQTT Client”. 637th Research Lab, 2026-08-13. https://y637f9qq2x.com/posts/cve-2026-5857/
