Blaze Blog /

A header that never ends: our independent discovery of CVE-2026-87121 in lwIP

FeaturedSecurity
Oct 6, 2026
18min read
An endless input stream overflows a small receiver into neighboring controls, illustrating lwIP MQTT receive-buffer corruption.

In June 2026, during a source-code review of lwIP, we found an MQTT parser that would keep accepting length bytes long after the protocol said it should stop. Those bytes were stored in a small receive buffer. Once the buffer filled, the parser kept writing into the fields beside it.

We tracked the finding internally as ADV-01. Small C harnesses confirmed corruption of the client's transmit-ring indices and a second consequence: a malformed header could trap the parser in an infinite loop. We revisited the finding in July, separating what our tests demonstrated from the exploitation paths suggested by the source.

In October, we came across ByteRay's disclosure of the same root cause, now tracked as CVE-2026-87121. ByteRay independently discovered and reported the vulnerability and describes an end-to-end remote-code-execution exploit. Credit for that reported exploit and the coordinated public disclosure belongs to their researchers, Shahriyar Jalayeri and Mehrun P. Hunter, and the parties involved in the disclosure process.

This post tells the story of our independent investigation: how the candidate became a reproducible bug, why the neighboring fields mattered, and what we could substantiate about its impact.

Finding the missing limit

Our review covered network-facing code in the lwIP 2.2.2 development tree, initially at commit 3d896ba0. The aim was to identify defects that incoming traffic could reach and then test them against the actual implementation.

The workflow combined AI-assisted source review in Claude Code with C proof-of-concept harnesses and subsequent validation using Codex. The initial environment included Windows and MinGW; later runs used WSL and Ubuntu with GCC and AddressSanitizer. Python and Scapy were also part of the broader review's traffic-generation tooling.

For the MQTT finding, the decisive step was moving from source analysis to execution. The first parser harness included the real mqtt.c and linked lwIP's actual packet-buffer and memory-management code. Supporting transport and system symbols were stubbed where they were outside the exercised parsing path.

That distinction matters. A program that prints an expected result establishes nothing about a vulnerable implementation. We needed attacker-supplied bytes to pass through the real parser and produce a measurable change.

The candidate was in mqtt_parse_incoming(), the function that processes MQTT data arriving from the broker. It contained a store controlled by an input-driven loop, with neither a receive-buffer bound nor the protocol's header-length limit enforced before the store. Understanding the bug requires following that loop, the object it modifies, and the functions that later consume the modified fields.

Four bytes means four bytes

An MQTT control packet begins with a fixed header: a byte identifying the packet type and flags, followed by a field called Remaining Length. This field describes the size of the rest of the packet.

Remaining Length uses a variable-length encoding. Each byte contributes seven bits of the value; its highest bit indicates whether another length byte follows. The MQTT 3.1.1 specification, section 2.2.3 permits at most four bytes for that field. Including the initial packet-type byte, the fixed header can therefore occupy at most five bytes.

For example, a Remaining Length of 128 is encoded as 0x80 0x01. The first byte contributes zero and requests another byte; the second contributes one at the next seven-bit position and terminates the encoding. A fifth length byte is invalid regardless of the numeric value the parser might compute from it.

The vulnerable parser followed the continuation flag without enforcing that limit. A broker could keep supplying bytes with their highest bit set, and the parser would keep storing them.

This is a useful distinction when reviewing length parsers: the size of the value being decoded and the number of bytes allowed to encode that value are separate limits. Rejecting an oversized message later does not protect a buffer that has already overflowed while reading its header.

Walking through the vulnerable C

The relevant part of the original parser is shown below. This is an abbreviated source excerpt: declarations and statements unrelated to the branching and stores have been omitted, and the comments explain their role. It is not a replacement implementation.

/* mqtt_parse_incoming(), src/apps/mqtt/mqtt.c */
/* fixed_hdr_len is u8_t; client->msg_idx is u32_t. */

while (p->tot_len > in_offset) {
  if ((fixed_hdr_len < 2) || ((b & 0x80) != 0)) {
    if (fixed_hdr_len < client->msg_idx) {
      b = client->rx_buffer[fixed_hdr_len];
    } else {
      b = pbuf_get_at(p, in_offset++);
      client->rx_buffer[client->msg_idx++] = b;
    }
    fixed_hdr_len++;
    /* Remaining Length decoding and other statements omitted. */
  } else {
    /* Variable-header processing omitted. */
  }
}

Three different positions are involved:

VariableWhat it tracksWhy it matters
in_offsetPosition in the current incoming packet bufferAdvancing this cursor makes progress through new input
client->msg_idxNumber of bytes stored in the receive bufferIt is used as the destination index without a bound in this branch
fixed_hdr_lenPosition while parsing or reparsing the fixed headerIt is only eight bits wide and can wrap independently of msg_idx

The fixed_hdr_len < 2 condition allows the initial packet-type byte and first length byte to be processed. After that, the previous byte's continuation bit decides whether the parser needs another length byte.

The inner condition serves a legitimate streaming-parser purpose. If bytes are already buffered, the parser rereads them from rx_buffer. Otherwise, it obtains a new byte from the incoming lwIP pbuf, advances in_offset, and appends the byte to the receive buffer.

The append operation is the failure point. Before executing it, the code does not establish either that msg_idx is below MQTT_VAR_HEADER_BUFFER_LEN or that the header remains within MQTT's legal size. An overlong continuation sequence keeps reaching that store.

In the reviewed development source, the store was at mqtt.c:862. Line numbers can vary between revisions; the missing check and the state transitions are what identify the defect.

From an overflow to corrupted transmit state

An unchecked append crosses into transmit staterx_buffer · 128 bytesputgetoutput.buf · 256 bytesOffsets: 0–127128–129130–131132–387
Illustration of the tested object layout; not to scale. Offsets are relative to rx_buffer.

The relevant object layout was:

/* Excerpts from src/include/lwip/apps/mqtt_priv.h. */
struct mqtt_ringbuf_t {
  u16_t put;
  u16_t get;
  u8_t buf[MQTT_OUTPUT_RINGBUF_SIZE];
};

struct mqtt_client_s {
  /* Earlier fields omitted. */
  u32_t msg_idx;
  u8_t rx_buffer[MQTT_VAR_HEADER_BUFFER_LEN];
  struct mqtt_ringbuf_t output;
};

With the tested sizes, MQTT_VAR_HEADER_BUFFER_LEN was 128 and MQTT_OUTPUT_RINGBUF_SIZE was 256. The overflow first crossed a member boundary inside the allocated client object. It did not need to reach the next heap allocation to change security-relevant state.

Offset from the start of rx_buffer in the tested layoutField reached
0–127The legitimate receive buffer
128–129output.put
130–131output.get
132–387output.buf

This explains why changing buffer sizes or object layouts changes the trigger's consequences. The table describes our tested configuration, not a universal layout for every downstream build.

The first runtime test made the crossing visible. An input consisting of 0x30 followed by 200 0xff bytes changed both ring indices from zero to 65535 and changed output.buf[0] to 255. The receive index reached 201.

The recorded output was:

[M-1 / Phase A] BEFORE: output.put=0 output.get=0 output.buf[0]=0
[M-1 / Phase A] AFTER : output.put=65535 output.get=65535 output.buf[0]=255  (msg_idx=201)

Those values were produced by parsing the input. They were not planted in the ring-buffer fields beforehand.

Write mechanism one: the linear overwrite

The parser's store writes successive input bytes beyond rx_buffer. This is a contiguous overwrite of the following fields. Its content is controlled by the incoming stream, subject to the continuation encoding needed to keep the branch active.

For the sustained continuation sequences used in our tests, those bytes were restricted to 0x80–0xff. That rules out treating the demonstrated overwrite as unrestricted pointer replacement: many pointer bytes, including zero bytes, are outside that alphabet.

There is a subtlety in the C: the branch tests the previous byte before fetching and storing the next one. A terminating byte with its high bit clear can therefore also be stored before the parser leaves the continuation path. It does not let an attacker freely insert low-valued bytes throughout a long continuation sequence. The constraints below describe the all-continuation overwrite we demonstrated, rather than claiming to enumerate every possible terminating-byte variant.

Write mechanism two: an index used before its wrap check

The output ring's normal byte-store sequence was:

/* Body excerpt from mqtt_ringbuf_put(). */
rb->buf[rb->put] = item;
rb->put++;
if (rb->put >= MQTT_OUTPUT_RINGBUF_SIZE) {
  rb->put = 0;
}

The wrap logic maintains a valid index during ordinary operation. It does not validate the index before using it. If put has already been corrupted, the first store has already occurred out of bounds by the time the check runs.

This creates a second write with different controls. The corrupted index selects the offset relative to output.buf, but item is the client's next outgoing byte. The demonstrated primitive is therefore a controlled-offset store with a value determined by the outgoing operation. Turning that into a useful exploit requires establishing which outgoing byte can be made to appear, when the store occurs, and what lives at the selected offset.

Our full-object guard-page test used put = 0xc0de and observed the store faulting at output.buf + 0xc0de. It established the relationship between the corrupted field and the accessed address. A deliberately placed inaccessible page is a way to observe that relationship; it is not evidence that a production object with an exploitable callback occupies that address.

The read path consumes the other corrupted index

The pointer-producing helper and its consumer were:

/* Body excerpt from mqtt_ringbuf_get_ptr(). */
return &rb->buf[rb->get];
/* Call excerpt from mqtt_output_send(); trailing arguments omitted. */
err = altcp_write(tpcb, mqtt_ringbuf_get_ptr(rb), send_len, /* ... */);

With an invalid get, the send path begins reading outside the ring buffer. Whether readable bytes are copied into the TCP output, a fault occurs, or a sanitizer aborts first depends on the address and runtime.

The read offset is controlled; the amount read is separately bounded by the send path's computed length and available TCP send capacity. An attacker does not obtain an arbitrary read of any address merely by corrupting get.

The older maintainer notes include an ASan trace showing a 1,024-byte read through mqtt_output_send(), altcp_write(), and tcp_write(). That trace identifies the invalid read path. On its own, an ASan abort does not prove that all—or any—of those bytes were transmitted to the broker. Evidence of disclosure requires a corresponding capture or observation at the receiving end.

Which path executes first matters

The overwrite affects get as well as put. The older network-test notes describe the normal output flush reaching the invalid read before a later outgoing message exercised the derived write. In an ASan build, that first invalid read can abort the process and prevent the later store from occurring.

A standalone test can invoke the write helper directly after corruption and demonstrate the store. A network exploit has to arrange the real callback and send sequence. Evidence from the former cannot silently stand in for the latter, and the ordering observed in one test is not a universal rule for every product.

The exact attack constraints

For the demonstrated overwrite, both bytes of each corrupted 16-bit index came from 0x80–0xff. The smallest resulting value is 0x8080 (32,896) and the largest is 0xffff (65,535), but the reachable values do not form a continuous interval.

For example, 0x80ff and 0x8180 fit the constraint, whereas 0x8101 does not. There are 128 choices for each byte, giving 16,384 possible index values. Describing this as control of every offset in a 64 KiB window would overstate the demonstrated primitive.

PropertyWhat the demonstrated path providesWhat still needs to be established for exploitation
Attacker positionControl of the connected broker or injection into the established streamA route to that position in the particular deployment
Initial overwriteContiguous bytes crossing from the receive buffer into transmit stateA useful target layout and corruption pattern
Overwrite alphabetHigh-bit continuation bytes in the sustained test sequenceAny useful terminating-byte variants require separate analysis
Derived write offsetSparse 16-bit offsets relative to output.bufA writable target at one of those offsets
Derived write valueThe outgoing item supplied by the clientControl or useful predictability of that byte
Derived readAn invalid base selected through getReadable memory and proof that contents actually reach the peer
Execution orderA read may run before the desired writeA working sequence through the actual network callbacks
Code executionNot demonstrated by Blaze's final validationA viable control-flow target and a complete exploit on the chosen build

Heap layout, endianness, allocator behavior, memory mapping, and protection mechanisms all matter. A microcontroller's flat address space can make some exploitation steps easier, but it does not remove the byte constraints or establish a usable target at a chosen offset.

Repeated corruption cycles also require proof. The fact that one index can be changed once does not establish that the client remains usable long enough to perform a series of controlled writes.

The header could also stop the parser from returning

The same missing limit affected forward progress. Recall that the loop had two ways to obtain a byte:

if (fixed_hdr_len < client->msg_idx) {
  b = client->rx_buffer[fixed_hdr_len];  /* No input-cursor advance. */
} else {
  b = pbuf_get_at(p, in_offset++);       /* Consumes incoming input. */
  client->rx_buffer[client->msg_idx++] = b;
}
fixed_hdr_len++;

fixed_hdr_len is only eight bits wide. Once it increments past 255, it wraps to zero. The wider msg_idx does not wrap at the same point. The inner comparison now treats the header position as referring to bytes already buffered, so the parser rereads the receive object without consuming new input.

Our sustained test sequence kept that reparse path active through subsequent counter cycles. The loop condition still saw unread bytes because in_offset no longer advanced. The callback remained stuck.

The state transitions explain the failure:

Stagefixed_hdr_lenmsg_idxEffect on incoming input
Before the wrapIncreases toward 255Has grown with appended bytesFresh-byte branch can advance in_offset
After the wrapReturns to zeroRemains largerBuffered-byte branch leaves in_offset unchanged
During repeated reparsingCycles through eight-bit positionsRemains larger than those positionsThe tested malformed sequence produces no forward progress

The non-consuming branch is necessary to support normal fragmented input. The missing header bound allows the state to enter a condition that normal MQTT traffic should never create.

Our watchdog test reproduced the hang against the real parser using a 1,024-byte malformed input. On a deployment where networking work shares a stack thread or event loop, a stuck callback can prevent that work from progressing. Whether the wider device stalls, restarts under a watchdog, or recovers through another mechanism depends on the product.

This is another consequence of the same parser defect, not a separate discovery requiring another CVE.

What the validation established

By June 22, our private repository contained the ADV-01 analysis and its proof-of-concept records. On June 26, a separate Codex execution under WSL and Ubuntu reproduced an AddressSanitizer write error in mqtt_parse_incoming() and the watchdog-confirmed hang.

We repeated the tests on July 16 against a newer lwIP development commit, fad200f0fdc5e859cfe445fb22f55ec0c6a6072c. That run again produced the parser write error and hang. A full-client-object test also changed output.put to 0xc0de and output.get to 0xbabe; the subsequent ring-buffer store faulted at the corresponding controlled offset.

The allocation details deserve an explanation. One sanitizer harness deliberately ended its allocation at the receive-buffer boundary to expose the first invalid store. That boundary was narrower than the complete production client object. The separate full-object test established the adjacent-field corruption and the derived out-of-bounds access. These tests answered complementary questions.

Our July review retained the finding but narrowed the impact language. The fresh evidence established parser corruption, a hang, and controlled-offset access in a harness. A fresh TAP-based attempt failed to establish the broker connection, so it did not provide a successful network-level exploit receipt. We did not demonstrate remote code execution or substantiate live data exfiltration in that revalidation.

Older notes explored disclosure through the corrupted read index and possible control-flow corruption. Those remain useful analysis paths, but they should not be presented as outcomes established by our final validation.

The collision with ByteRay

On October 6, we recognized ADV-01 in ByteRay's BYTERAY-2026-0211 advisory. Its root cause, affected parser, receive-buffer overflow, and output-ring corruption matched our finding.

ByteRay lists July 26 as its reporting date and September 22 as public disclosure, with CISA coordination. Its September 30 blog post describes taking the bug through to remote code execution. The advisory identifies lwIP 2.0.1 through 2.2.1 as affected.

Our records establish that we had independently documented the bug in June. They do not establish priority of vendor reporting, and we are not claiming credit for ByteRay's exploit or the CVE assignment.

The identifier for this shared root cause is CVE-2026-87121. ByteRay's research and disclosure deserve explicit acknowledgment alongside our account of independently finding and validating it.

Who is exposed?

This is a client-side vulnerability. A product must include and use the affected MQTT application, and the attacker must control the broker or be able to inject data into the established connection.

The parser handles incoming broker data before MQTT-level authentication provides a barrier. That does not mean any Internet host can send an unsolicited packet to every device using lwIP and trigger the bug. The configured connection and the attacker's position remain important.

TLS with proper server-certificate validation restricts stream injection and broker impersonation. It does not make malformed input from a compromised authenticated broker safe to parse.

Fixing the parser and checking downstream copies

The implementation needs both a memory bound and a protocol bound. They answer different questions: is the destination still inside the receive buffer, and is this still a legal MQTT header?

When checking the public remediation, we inspected the upstream changes directly. Commit 68b2c1191886578d40342846db6ab9a0099c2f40, titled “mqtt: fix rx_buffer overrun,” adds a bounds check at the parser store and a regression test. The subsequent commit f89407ea711879c04d91c92b35d67be78bbaf0f1, referenced by ByteRay, corrects the test's memset arguments.

The production change guards the destination before writing:

if (client->msg_idx < MQTT_VAR_HEADER_BUFFER_LEN) {
  client->rx_buffer[client->msg_idx++] = b;
} else {
  return MQTT_CONNECT_DISCONNECTED;
}

This prevents that append from crossing the receive-buffer boundary. It does not, by itself, express the much smaller maximum size of a valid fixed header.

Our maintainer notes proposed a protocol-length guard in the fixed-header branch before processing another header byte:

/* Proposed guard; surrounding parser statements omitted. */
if (fixed_hdr_len >= 5) {
  return MQTT_CONNECT_DISCONNECTED;
}

They also proposed rejecting an unfinished length encoding immediately after consuming its fourth length byte, rather than waiting for a subsequent byte to arrive:

/* Proposed guard immediately after fixed_hdr_len++. */
if ((fixed_hdr_len == 5) && ((b & 0x80) != 0)) {
  return MQTT_CONNECT_DISCONNECTED;
}

The count includes the packet-type byte. A five-byte fixed header is valid only if the fourth length byte terminates the encoding. These guards must be integrated with the parser's cached-byte and fragmented-input handling; they are explanatory excerpts from our proposed remediation, not a claim that this is the complete upstream patch.

The notes also suggested normalizing the output-ring indices before use. A bit mask works for the default 256-byte ring only because that size is a power of two. It is not valid for an arbitrary configured size. More generally, output helpers can check their index invariants and reject corrupted state before accessing memory. Making an index fall inside the array does not restore the integrity of an already corrupted MQTT client.

For product teams, the practical task is to verify the MQTT source compiled into their firmware, including any vendored SDK copy, and integrate the upstream fix and relevant follow-up changes. Useful regression cases include the maximum legal encoding, an unterminated fourth length byte, an attempted fifth length byte, and an overlong continuation sequence. Repeat the cases across receive boundaries and check that disconnect handling releases the connection cleanly. A streaming parser must preserve its limits when a header arrives in pieces.

What we took from the investigation

The protocol allowed four length bytes. The implementation accepted an attacker-controlled sequence, stored it without a bound, and eventually lost both memory safety and forward progress.

AI-assisted review helped us investigate the code and construct tests. The confidence came from executing the implementation, inspecting the changed fields, and revisiting the claims when the evidence was narrower than the original narrative.

ByteRay's independent work took the public account further with a reported RCE exploit and coordinated disclosure. Our contribution here is the reproducible path from a suspicious parser loop to object corruption and a hang—and a record of where our own proof stopped.

Do you have questions? Let's talk.

Get in touch with our cybersecurity experts

Read More