fix(Outbox): re-anchor it._prev after _remove() to prevent dangling pointer - #188
Conversation
…ointer Problem ------- `remove(Iterator& it)` calls `++it` to advance the iterator before passing the predecessor pointer `prev` to `_remove()`. The `++it` operator sets `it._prev = node` (the node about to be removed). After `_remove(prev, node)` deletes `node`, `it._prev` holds a pointer to freed memory. A second call to `remove(it)` extracts `prev = it._prev` (dangling) and passes it to `_remove()`. Depending on which branch fires: _last = prev; // writes through freed pointer _last->next = nullptr; // dereferences freed pointer prev->next = node->next;// dereferences freed pointer This corrupts whatever heap block was recycled into that address and can produce use-after-free crashes in the caller. The failure is triggered by any code path that calls `_clearQueue(0)` on TCP disconnect and then has retained outbox items — exactly what happens on an unexpected RST. Fix --- Re-anchor `it._prev = prev` after `_remove()` completes. `prev` is the live predecessor that `_remove` just used to relink the queue; it remains valid and is the correct `_prev` for the iterator's new position. Test ---- `test_outbox_remove_consecutive` calls `remove(it)` three times in succession on a three-element outbox, verifying each removal leaves the correct element in the iterator and the outbox is empty at the end. Without the fix, the second call writes through a freed pointer.
|
I added your test without the fix and it passed. I want to reproduce using a test before merging. Are you using the async version and would it be possible to share a (decoded) stack trace?? Don't get me wrong, the current code partly invalidates the iterator and that is a bug. I'm just trying to figure out a scenario to test. |
|
I did some testing.
So I created a branch with the testing issues fixed. I also improved your solution. Feel free to cherry-pick and adjust your PR. // remove node at iterator, iterator points to next
void remove(Iterator& it) { // NOLINT(runtime/references)
if (!it) return;
// capture iterator state
Node* node = it._node;
Node* prev = it._prev;
Node* next = node->next;
// remove element
_remove(prev, node);
// rebuild iterator state
it._prev = prev;
it._node = next;
} |
|
Yes, AsyncTCP-backed — I do have decoded stack traces from the five crashes but they're not going to be very useful here — they're crash-site backtraces (downstream heap corruption victims: What might be more useful: Trigger: Validation: After patching, we absorbed 4 TCP RST events on one device over a 14-hour continuous run with zero coredumps. Unpatched, any of those RST events would have crashed within the next Also — I'll incorporate your improved |
|
Done — updated the PR to use your implementation from The current state:
Let me know if anything needs adjusting before merge. |
|
Everything is fine! Thank you for the bugfix! According to my local AI there is no clear path that reveals the issue because it is UB. It probably manifests under high load or async operations only. |
|
My pleasure. I'm glad i could help. :) |
Problem
remove(Iterator& it)calls++itto advance the iterator before passing the predecessor pointerprevto_remove(). The++itoperator setsit._prev = node(the node that_removeis about to free). After_remove(prev, node)deallocatesnode,it._prevholds a dangling pointer.A second call to
remove(it)extractsprev = it._prev(now dangling) and passes it to_remove(). Depending on which internal branch fires:This corrupts whatever heap block was recycled into that slot and produces use-after-free crashes in the caller. The failure surface is any code path that calls
_clearQueue(0)on TCP disconnect while outbox items remain — exactly what happens on an unexpected TCP RST.We observed five distinct crash signatures on an ESP32 fleet (three separate devices, confirmed single root cause):
LoadProhibitedfaulting onPacket::packetType/_data[0], aheap_capsassertion in~Packetfreeing_data, two lwIPtcp_inputassertions, and alwip_netconn_do_close_internalLoadProhibited— all traceable to a single corrupted outbox node after a TCP RST disconnection.Fix
Capture all iterator state before the removal, then rebuild both fields explicitly — avoiding
++itentirely (suggested by @bertmelis):previs the live predecessor that_removejust used to relink the queue — it remains valid.nextis captured before the removal so it is never read through the freednode.Test
Added
test_outbox_remove_consecutive(improved by @bertmelis) which callsremove(it)twice in succession from the middle of a five-element outbox. Without the fix, the second call passes a dangling pointer to_remove(); with the fix the size is correct and no memory is corrupted.The test suite is also run without the memory pool (
EMC_USE_MEMPOOL=0) so Valgrind can detect use-after-free — with the pool enabled, recycled memory masks the UB.Validation
Validated on an 8-device ESP32 fleet: 9-hour soak after applying this patch, including 4 absorbed TCP RST events on one device during an extended 14-hour run, 0 post-patch coredumps across the full fleet.