Skip to content

ESP32: use-after-free panic in ECMA-419 TCP tcpReceive when a socket closes with data in flight (lwIP tcpip thread vs XS thread race) #1655

Description

@xymeow

Environment

  • Moddable SDK 8.2.3 (the relevant code in modules/io/socket/lwip/tcp.c is unchanged on the current public branch)
  • ESP-IDF 6.0, ESP32-S3 (M5Stack CoreS3), CONFIG_LWIP_TCPIP_CORE_LOCKING not set (IDF default)
  • Debug build (mcconfig -d); the device runs an HTTP server (embedded:io listener) serving roughly 0.2–1 request/s from a LAN client, plus occasional outbound HTTP (WAV streaming)

Symptom

Sporadic Guru Meditation Error: Core 1 panic'ed (LoadProhibited) followed by rst:0xc (RTC_SW_CPU_RST). 24 such resets were captured on the serial console over one evening of ordinary traffic. Three panics include full backtraces, all entering through tcpReceive on the lwIP tcpip_thread:

tcpReceive               modules/io/socket/lwip/tcp.c:575
tcp_process_refused_data lwip/src/core/tcp.c:1578
tcp_input                lwip/src/core/tcp_in.c:434
ip4_input / ethernet_input / tcpip_thread
tcpReceive               modules/io/socket/lwip/tcp.c:575
tcp_input                lwip/src/core/tcp_in.c:512
ip4_input ...
fxInNetworkDebugLoop     xs/platforms/esp/xsPlatform.c:122
tcpReceive               modules/io/socket/lwip/tcp.c:559
...

Analysis

tcpReceive runs on the lwIP tcpip_thread. doClose / xs_tcp_destructor run on the XS task and clear the lwIP callbacks with raw tcp_recv/tcp_sent/tcp_err calls and then free the TCP record. (tcp_close itself is marshaled on ESP32 via tcp_close_safe/tcpip_api_call — the callback removal and the free are the unsynchronized steps.) With core locking disabled there is no synchronization between the two threads, so:

  1. A segment — or a refused-data redelivery, see the tcp_process_refused_data frame — can enter tcpReceive with arg pointing at a TCP record that the XS thread frees concurrently. The crash sites are the tcp->buffers walk (line 575) and fxInNetworkDebugLoop(tcp->the) (line 559).
  2. Refused data widens the window considerably: tcpReceive returns ERR_MEM when c_malloc fails or while fxInNetworkDebugLoop() is true (debug builds), so lwIP parks the pbuf and redelivers it later — for short-lived HTTP connections, frequently after the JS side has already closed the socket.

The existing useCount atomics protect fields of the record but not the lifetime of the arg pointer handed to lwIP callbacks.

Supporting evidence from mitigation

Switching to a release build and lowering the LAN client's polling frequency reduced the crash rate substantially (from ~24/evening) but did not eliminate it — a further rst:0xc occurred after ~17 minutes of release-build uptime, consistent with the direct cross-thread race remaining.

Proposed fix (tested)

The SDK already marshals tcp_close/tcp_write/etc. onto the tcpip thread on ESP32 (modLwipSafe.c's tcpip_api_call idiom) — but callback removal still uses raw tcp_recv/tcp_sent/tcp_err calls from the XS task, immediately before the record is freed. The fix follows the existing idiom:

  1. Add tcp_clear_callbacks_safe(pcb) to modLwipSafe.c/h: synchronously (via tcpip_api_call) clear tcp_arg, tcp_recv, tcp_sent, and tcp_err on the tcpip thread. Because tcpip_api_call completes only after the tcpip thread finishes its current message, returning from it guarantees no callback for that pcb is running or can run again — making the subsequent free safe.
  2. Use it from removeTCPCallbacks under #if ESP32.
  3. Add NULL == arg guards to tcpReceive / tcpSent / tcpError (the cleared tcp_arg makes any residual delivery arrive as NULL; tcpReceive frees the pbuf in that case).

Patch is +44 lines across modules/io/socket/lwip/tcp.c and modules/network/socket/lwip/modLwipSafe.{c,h} (inline below). Running on the affected device now; will report back with soak results. The ECMA-419 UDP glue likely deserves the same treatment for its callback removal, though all captured crashes here were TCP.

Related reports

I could not find an existing issue or PR for this specific crash (searched
tcpReceive, LoadProhibited, use-after-free, and recent commits touching
modules/io/socket/lwip/tcp.c). The closest precedent is the same bug class
in BLE, fixed recently: #1650 "GATTServer crash on late ATT access after
disconnect". This report is the TCP/lwIP analog: a late lwIP callback touching
a record the XS thread has already torn down.

Suggested direction

Marshal callback removal + pcb close + record free onto the tcpip thread (tcpip_callback), or take LOCK_TCPIP_CORE around teardown when core locking is available, or add a lifetime handshake so lwIP callbacks can detect a record that is being torn down.

Happy to provide the full serial capture or test a patch; the crash reproduces within hours on a debug build serving periodic HTTP requests from a LAN poller.

Patch (+44 lines: tcp.c, modLwipSafe.c/h)
diff --git a/modules/io/socket/lwip/tcp.c b/modules/io/socket/lwip/tcp.c
index 0f90023..e1334df 100644
--- a/modules/io/socket/lwip/tcp.c
+++ b/modules/io/socket/lwip/tcp.c
@@ -262,9 +262,18 @@ void xs_tcp_destructor(void *data)
 void removeTCPCallbacks(TCP tcp)
 {
 	if (tcp->skt) {
+#if ESP32
+		// Clearing callbacks from the XS task races the lwIP tcpip task,
+		// which may be inside tcpReceive/tcpSent/tcpError with this record
+		// as arg; the caller frees the record right after. Marshal the clear
+		// onto the tcpip task synchronously: when it returns, no callback
+		// for this pcb is running or can run again.
+		tcp_clear_callbacks_safe(tcp->skt);
+#else
 		tcp_recv(tcp->skt, NULL);
 		tcp_sent(tcp->skt, NULL);
 		tcp_err(tcp->skt, NULL);
+#endif
 	}
 
 	tcp->triggerable &= ~kTCPWritable;
@@ -535,6 +544,9 @@ void tcpError(void *arg, err_t err)
 {
 	TCP tcp = arg;
 
+	if (NULL == tcp)		// callbacks cleared during teardown
+		return;
+
 	removeTCPCallbacks(tcp);
 	tcp->skt = NULL;		// "pcb is already freed when this callback is called"
 	if (kTCPError & tcp->triggerable)
@@ -546,6 +558,12 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
 	TCP tcp = arg;
 	TCPBuffer buffer;
 
+	if (NULL == tcp) {		// callbacks cleared during teardown
+		if (pb)
+			pbuf_free(pb);
+		return ERR_OK;
+	}
+
 	if ((NULL == pb) || (ERR_OK != err)) {		//@@ when is err set here?
 		removeTCPCallbacks(tcp);
 #if ESP32
@@ -590,6 +608,9 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
 
 err_t tcpSent(void *arg, struct tcp_pcb *pcb, u16_t len)
 {
+	if (NULL == arg)		// callbacks cleared during teardown
+		return ERR_OK;
+
 	tcpTrigger((TCP)arg, kTCPWritable);
 	return ERR_OK;
 }
diff --git a/modules/network/socket/lwip/modLwipSafe.c b/modules/network/socket/lwip/modLwipSafe.c
index a4cc7fb..dc699dd 100644
--- a/modules/network/socket/lwip/modLwipSafe.c
+++ b/modules/network/socket/lwip/modLwipSafe.c
@@ -169,6 +169,28 @@ void tcp_recved_safe(struct tcp_pcb *tcpPCB, u16_t len)
 	tcpip_callback_with_block(tcp_recved_INLWIP, msg, 1);
 }
 
+static err_t tcp_clear_callbacks_INLWIP(struct tcpip_api_call_data *tcpMsg)
+{
+	LwipMsg msg = (LwipMsg)tcpMsg;
+	if (msg->tcpPCB) {
+		tcp_arg(msg->tcpPCB, NULL);
+		tcp_recv(msg->tcpPCB, NULL);
+		tcp_sent(msg->tcpPCB, NULL);
+		tcp_err(msg->tcpPCB, NULL);
+	}
+	return ERR_OK;
+}
+
+// Synchronous: when this returns, no lwIP callback for this pcb is running
+// or can run again, so the caller may safely free its callback argument.
+void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB)
+{
+	LwipMsgRecord msg = {
+		.tcpPCB = tcpPCB,
+	};
+	tcpip_api_call(tcp_clear_callbacks_INLWIP, &msg.call);
+}
+
 static err_t tcp_listen_INLWIP(struct tcpip_api_call_data *tcpMsg)
 {
 	LwipMsg msg = (LwipMsg)tcpMsg;
diff --git a/modules/network/socket/lwip/modLwipSafe.h b/modules/network/socket/lwip/modLwipSafe.h
index 717808d..0f5c94f 100644
--- a/modules/network/socket/lwip/modLwipSafe.h
+++ b/modules/network/socket/lwip/modLwipSafe.h
@@ -52,6 +52,7 @@
 	#include "lwip/raw.h"
 
 	struct tcp_pcb *tcp_new_safe(void);
+	void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB);
 	err_t tcp_connect_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected);
 	u8_t pbuf_free_safe(struct pbuf *p);
 	err_t tcp_bind_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port);

Edited: corrected the tcp_close_safe description (it is marshaled on ESP32; the unsynchronized step is callback removal), updated the mitigation results (reduced, not eliminated), and added the tested patch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions