Skip to content

Commit ba4c345

Browse files
committed
Fix IOCP use-after-free crash
The 0.62.0 fix for this was incomplete — it matched specific error codes but missed others and couldn't distinguish teardown errors from legitimate remote peer disconnects. Replace the error-code approach with a shared liveness token. Each ASIO event allocates an iocp_token_t with an atomic dead flag and refcount. IOCP submissions increment the refcount; callbacks decrement it. Destroy sets the dead flag before freeing the event. Callbacks check the flag before touching the event — if dead, they clean up without accessing freed memory. The last callback frees the token.
1 parent 9d91ee3 commit ba4c345

4 files changed

Lines changed: 91 additions & 6 deletions

File tree

.release-notes/next-release.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
## Fix IOCP use-after-free crash
2+
3+
The fix for this issue in 0.62.0 was incomplete. That fix checked for specific Windows error codes (`ERROR_OPERATION_ABORTED` and `ERROR_NETNAME_DELETED`) in the IOCP completion callback to detect orphaned I/O operations. However, Windows can deliver completions with other error codes after the socket is closed, and `ERROR_NETNAME_DELETED` can also arrive from legitimate remote peer disconnects — making error-code matching the wrong approach entirely.
4+
5+
The new fix addresses the root cause: IOCP completion callbacks can fire on Windows thread pool threads after the owning actor has destroyed the ASIO event via `pony_asio_event_destroy`, leaving the callback with a dangling pointer to freed memory.
6+
7+
Each ASIO event now allocates a small shared liveness token (`iocp_token_t`) containing an atomic dead flag and a reference count. Every in-flight IOCP operation holds a pointer to the token and increments the reference count. When `pony_asio_event_destroy` runs, it sets the dead flag (release store) before freeing the event. Completion callbacks check the dead flag (acquire load) before touching the event — if dead, they clean up the IOCP operation struct without accessing the freed event. The last callback to decrement the reference count to zero frees the token.
8+
9+
This correctly handles all error codes and all IOCP operation types (connect, accept, send, recv) without swallowing events the actor needs to see.

src/libponyrt/asio/event.c

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#define PONY_WANT_ATOMIC_DEFS
2+
13
#include "event.h"
24
#include "asio.h"
35
#include "../actor/actor.h"
@@ -30,6 +32,12 @@ PONY_API asio_event_t* pony_asio_event_create(pony_actor_t* owner, int fd,
3032
ev->writeable = false;
3133
ev->readable = false;
3234

35+
#ifdef PLATFORM_IS_WINDOWS
36+
ev->iocp_token = POOL_ALLOC(iocp_token_t);
37+
atomic_store_explicit(&ev->iocp_token->dead, false, memory_order_relaxed);
38+
atomic_store_explicit(&ev->iocp_token->refcount, 0, memory_order_relaxed);
39+
#endif
40+
3341
owner->live_asio_events = owner->live_asio_events + 1;
3442

3543
// The event is effectively being sent to another thread, so mark it here.
@@ -63,6 +71,15 @@ PONY_API void pony_asio_event_destroy(asio_event_t* ev)
6371

6472
ev->flags = ASIO_DESTROYED;
6573

74+
#ifdef PLATFORM_IS_WINDOWS
75+
// Grab the token pointer before freeing the event.
76+
iocp_token_t* token = ev->iocp_token;
77+
78+
// Mark the token as dead. Any IOCP callback that hasn't yet checked the
79+
// token will see this (acquire/release) and skip the event.
80+
atomic_store_explicit(&token->dead, true, memory_order_release);
81+
#endif
82+
6683
// When we let go of an event, we treat it as if we had received it back from
6784
// the asio thread.
6885
pony_ctx_t* ctx = pony_ctx();
@@ -74,6 +91,13 @@ PONY_API void pony_asio_event_destroy(asio_event_t* ev)
7491
ev->owner->live_asio_events = ev->owner->live_asio_events - 1;
7592

7693
POOL_FREE(asio_event_t, ev);
94+
95+
#ifdef PLATFORM_IS_WINDOWS
96+
// Free the token if no IOCP callbacks are outstanding. If callbacks are
97+
// still in flight, the last one to complete will free the token.
98+
if(atomic_load_explicit(&token->refcount, memory_order_acquire) == 0)
99+
POOL_FREE(iocp_token_t, token);
100+
#endif
77101
}
78102

79103
PONY_API int pony_asio_event_fd(asio_event_t* ev)

src/libponyrt/asio/event.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88

99
PONY_EXTERN_C_BEGIN
1010

11+
#ifdef PLATFORM_IS_WINDOWS
12+
/** Shared liveness token for IOCP operations.
13+
*
14+
* IOCP completion callbacks fire on Windows thread pool threads after the
15+
* owning actor may have destroyed the event. Each in-flight IOCP operation
16+
* holds a pointer to this token. The callback checks the dead flag before
17+
* touching the event; the refcount tracks how many callbacks are outstanding
18+
* so the token itself can be freed when no longer needed.
19+
*/
20+
typedef struct iocp_token_t
21+
{
22+
PONY_ATOMIC(bool) dead;
23+
PONY_ATOMIC(uint32_t) refcount;
24+
} iocp_token_t;
25+
#endif
26+
1127
/** Definiton of an ASIO event.
1228
*
1329
* Used to carry user defined data for event notifications.
@@ -26,6 +42,7 @@ typedef struct asio_event_t
2642

2743
#ifdef PLATFORM_IS_WINDOWS
2844
HANDLE timer; /* timer handle */
45+
iocp_token_t* iocp_token; /* shared liveness token for IOCP callbacks */
2946
#endif
3047
} asio_event_t;
3148

src/libponyrt/lang/socket.c

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#define PONY_WANT_ATOMIC_DEFS
2+
13
#ifdef __linux__
24
#define _GNU_SOURCE
35
#endif
@@ -198,6 +200,7 @@ typedef struct iocp_t
198200
iocp_op_t op;
199201
int from_len;
200202
asio_event_t* ev;
203+
iocp_token_t* token;
201204
} iocp_t;
202205

203206
typedef struct iocp_accept_t
@@ -214,12 +217,35 @@ static iocp_t* iocp_create(iocp_op_t op, asio_event_t* ev)
214217
iocp->op = op;
215218
iocp->ev = ev;
216219

220+
if(ev != NULL)
221+
{
222+
iocp->token = ev->iocp_token;
223+
atomic_fetch_add_explicit(&iocp->token->refcount, 1, memory_order_relaxed);
224+
} else {
225+
iocp->token = NULL;
226+
}
227+
217228
return iocp;
218229
}
219230

231+
static void iocp_release_token(iocp_token_t* token)
232+
{
233+
if(atomic_fetch_sub_explicit(&token->refcount, 1, memory_order_acq_rel) == 1)
234+
{
235+
// We were the last outstanding operation. If the event has been destroyed,
236+
// nobody else will free the token — we do it.
237+
if(atomic_load_explicit(&token->dead, memory_order_acquire))
238+
POOL_FREE(iocp_token_t, token);
239+
}
240+
}
241+
220242
static void iocp_destroy(iocp_t* iocp)
221243
{
244+
iocp_token_t* token = iocp->token;
222245
POOL_FREE(iocp_t, iocp);
246+
247+
if(token != NULL)
248+
iocp_release_token(token);
223249
}
224250

225251
static iocp_accept_t* iocp_accept_create(SOCKET s, asio_event_t* ev)
@@ -228,26 +254,33 @@ static iocp_accept_t* iocp_accept_create(SOCKET s, asio_event_t* ev)
228254
memset(&iocp->iocp.ov, 0, sizeof(OVERLAPPED));
229255
iocp->iocp.op = IOCP_ACCEPT;
230256
iocp->iocp.ev = ev;
257+
iocp->iocp.token = ev->iocp_token;
258+
atomic_fetch_add_explicit(&iocp->iocp.token->refcount, 1,
259+
memory_order_relaxed);
231260
iocp->ns = s;
232261

233262
return iocp;
234263
}
235264

236265
static void iocp_accept_destroy(iocp_accept_t* iocp)
237266
{
267+
iocp_token_t* token = iocp->iocp.token;
238268
POOL_FREE(iocp_accept_t, iocp);
269+
270+
if(token != NULL)
271+
iocp_release_token(token);
239272
}
240273

241274
static void CALLBACK iocp_callback(DWORD err, DWORD bytes, OVERLAPPED* ov)
242275
{
243276
iocp_t* iocp = (iocp_t*)ov;
277+
iocp_token_t* token = iocp->token;
244278

245-
// When CancelIoEx cancels pending I/O, the completion fires with
246-
// ERROR_OPERATION_ABORTED. When closesocket closes a handle with pending
247-
// I/O, the completion fires with ERROR_NETNAME_DELETED. In both cases the
248-
// owning actor is tearing down — don't touch iocp->ev because the event
249-
// or its owner may already be freed. Just clean up and return.
250-
if(err == ERROR_OPERATION_ABORTED || err == ERROR_NETNAME_DELETED)
279+
// Check whether the event has been destroyed. If so, the event and its
280+
// owning actor may already be freed — don't touch iocp->ev.
281+
// token is NULL for IOCP_NOP (e.g. UDP sendto) which has no event.
282+
if((token != NULL) &&
283+
atomic_load_explicit(&token->dead, memory_order_acquire))
251284
{
252285
if(iocp->op == IOCP_ACCEPT)
253286
{
@@ -257,9 +290,11 @@ static void CALLBACK iocp_callback(DWORD err, DWORD bytes, OVERLAPPED* ov)
257290
} else {
258291
iocp_destroy(iocp);
259292
}
293+
260294
return;
261295
}
262296

297+
// Event is alive — proceed normally.
263298
switch(iocp->op)
264299
{
265300
case IOCP_CONNECT:

0 commit comments

Comments
 (0)