Skip to content

Commit 1f05d04

Browse files
rgfaberclaude
andcommitted
fix: clone/serialize double-free crash across every FFI handle class, plus a leak in the fix
Every handle-owning class (KeyPair, Session, StreamHandle, PendingCall, Event, CallResponse, StreamOpenInfo, StreamReply, UcanPayload, StreamItem) held a private raw cgo.Handle int with no __clone() guard. PHP's clone shallow-copies private properties before running __clone(), so `clone $keyPair` produces a second object holding the identical handle; whichever of the two destructs second calls the Go free function on an already-deleted runtime/cgo.Handle, which panics -- and an unrecovered panic inside any cgo-exported function is fatal to the ENTIRE host PHP process, not a catchable exception. Reproduced before fixing (KeyPair, no network needed): $copy = clone $original; unset($original); unset($copy); // panic: runtime/cgo: misuse of an invalid Handle -- SIGABRT, exit 134 A naive `__clone() { throw ...; }` with no other change still crashed: PHP has already completed the shallow property copy before __clone() runs, so the doomed clone (never assigned to a variable, since the throw aborts the assignment) still holds a live handle copy, and ITS OWN __destruct() fires normally when that temporary is discarded, stealing the original's handle. Fixed by nulling the clone's own handle copy before throwing, on all 10 classes. Fable's review of that fix found the identical bug has a second door: serialize()/unserialize() copies $handle by value too, and reaches every consumer's private cache/queue/$_SESSION with no clone() call anywhere in sight. Fixed with __serialize()/__unserialize() guards on the same 10 classes, throwing LogicException (aligned with __clone(), which was inconsistently RuntimeException -- nothing here is in production yet, so the one-word cleanup across all 10 files is free). As defense in depth (whatever the PHP-side guards don't catch), cabi/main.go gained a safeDeleteHandle helper wrapping every _free export's Delete() in recover() -- same principle as this morning's macula-go pool.go fix (a double-free must cost nothing, never the whole process). That recover() itself introduced a real leak Fable caught: macula_session_close's single top-level recover() meant a panic from validating identityHandle skipped the trailing Delete(sessionHandle) entirely, leaking the session (and its live QUIC connection) forever, silently, on exactly the path the recover() was added for. Fixed by deferring safeDeleteHandle FIRST (runs LAST per Go's LIFO defer order, so it fires regardless of where a panic happened above it), with early returns replacing the nested ifs. Verified with a standalone Go probe reproducing the exact control-flow shape of both the old (leaks) and new (doesn't leak) code across every handle-validity combination. Added regression coverage in tests/KeyPairTest.php (clone throws and leaves the original usable; serialize()/unserialize() of a live instance and of a hand-crafted blob both throw). Full suite (24 tests) green, composer validate clean, gofmt/vet/build clean, and every live primitive re-verified against station-de-frankfurt.macula.io (session close is exercised by all of them): handshake, call, pubsub, content, UCAN, RPC provider, stream provider. Fable-reviewed in two rounds (initial finding + a follow-up pass verifying both fixes' completeness); second round returned zero required items. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Khkcfi5pTAy2uA2ErjL57Q
1 parent e263d83 commit 1f05d04

18 files changed

Lines changed: 480 additions & 16 deletions

cabi/content.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,5 @@ func macula_bytes_handle_read(bytesHandle C.uintptr_t, out *C.uchar) {
8888

8989
//export macula_bytes_handle_free
9090
func macula_bytes_handle_free(bytesHandle C.uintptr_t) {
91-
cgo.Handle(bytesHandle).Delete()
91+
safeDeleteHandle(cgo.Handle(bytesHandle))
9292
}

cabi/main.go

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,28 @@ func macula_free_string(s *C.char) {
110110
C.free(unsafe.Pointer(s))
111111
}
112112

113+
// safeDeleteHandle deletes h, recovering a panic instead of letting it
114+
// propagate. cgo.Handle.Delete (like .Value) panics on an
115+
// already-deleted or otherwise invalid handle, and an unrecovered
116+
// panic inside any cgo-exported function is fatal to the WHOLE host
117+
// process -- Go's panic unwinding doesn't stop at the C caller, it
118+
// terminates the process outright, taking down every other in-flight
119+
// request this same PHP process happens to be serving, not just the
120+
// one holding the bad handle.
121+
//
122+
// The PHP side is the primary guard (every wrapper class nulls its own
123+
// handle after freeing and rejects further use via handleOrFail(),
124+
// and __clone() on every one of them explicitly nulls the clone's copy
125+
// before throwing -- see e.g. KeyPair::__clone()). This is defense in
126+
// depth for whatever that tracking doesn't catch: a double-free must
127+
// cost nothing, never the whole process, same principle as
128+
// macula-go's own pool.deliverOne recovering a subscriber handler's
129+
// panic so one bad callback doesn't kill every other subscriber.
130+
func safeDeleteHandle(h cgo.Handle) {
131+
defer func() { recover() }()
132+
h.Delete()
133+
}
134+
113135
//export macula_identity_generate
114136
func macula_identity_generate(errOut **C.char) C.uintptr_t {
115137
id, err := identity.Generate()
@@ -156,7 +178,7 @@ func macula_identity_private_bytes(identityHandle C.uintptr_t, out32 *C.uchar) C
156178

157179
//export macula_identity_free
158180
func macula_identity_free(identityHandle C.uintptr_t) {
159-
cgo.Handle(identityHandle).Delete()
181+
safeDeleteHandle(cgo.Handle(identityHandle))
160182
}
161183

162184
//export macula_connect
@@ -272,14 +294,32 @@ func macula_session_station_node_id(sessionHandle C.uintptr_t, out32 *C.uchar) C
272294

273295
//export macula_session_close
274296
func macula_session_close(sessionHandle C.uintptr_t, identityHandle C.uintptr_t) {
297+
// safeDeleteHandle is deferred FIRST (so it runs LAST -- defers are
298+
// LIFO) and unconditionally, before sessionHandle has even been
299+
// validated: if the recover() below fires partway through (e.g. an
300+
// invalid identityHandle), execution would otherwise return without
301+
// ever reaching a trailing Delete() call, leaking the session
302+
// handle (and its still-open QUIC connection) forever. Ordering
303+
// this defer outermost means the handle gets deleted regardless of
304+
// where -- or whether -- a panic happened above it; safeDeleteHandle
305+
// has its own recover() too, so this is safe even if sessionHandle
306+
// itself turns out to be invalid.
307+
defer safeDeleteHandle(cgo.Handle(sessionHandle))
308+
// Recovers a panic from EITHER .Value() call below (an invalid
309+
// sessionHandle or identityHandle) -- see safeDeleteHandle's doc for
310+
// why an unrecovered panic here is fatal to the whole process, not
311+
// just this call.
312+
defer func() { recover() }()
313+
275314
session, ok := cgo.Handle(sessionHandle).Value().(*connection.Session)
276-
if ok {
277-
id, idOk := cgo.Handle(identityHandle).Value().(identity.KeyPair)
278-
if idOk {
279-
_ = session.Close("normal", nil, id)
280-
}
315+
if !ok {
316+
return
317+
}
318+
id, idOk := cgo.Handle(identityHandle).Value().(identity.KeyPair)
319+
if !idOk {
320+
return
281321
}
282-
cgo.Handle(sessionHandle).Delete()
322+
_ = session.Close("normal", nil, id)
283323
}
284324

285325
func main() {} // required by -buildmode=c-shared, never actually run

cabi/pubsub.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,5 +183,5 @@ func macula_event_payload_bytes(eventHandle C.uintptr_t, out *C.uchar) {
183183

184184
//export macula_event_free
185185
func macula_event_free(eventHandle C.uintptr_t) {
186-
cgo.Handle(eventHandle).Delete()
186+
safeDeleteHandle(cgo.Handle(eventHandle))
187187
}

cabi/rpc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,5 @@ func macula_response_error_detail(responseHandle C.uintptr_t) *C.char {
130130

131131
//export macula_response_free
132132
func macula_response_free(responseHandle C.uintptr_t) {
133-
cgo.Handle(responseHandle).Delete()
133+
safeDeleteHandle(cgo.Handle(responseHandle))
134134
}

cabi/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,5 +181,5 @@ func macula_pending_call_reply_error(pendingHandle C.uintptr_t, detail *C.char,
181181

182182
//export macula_pending_call_free
183183
func macula_pending_call_free(pendingHandle C.uintptr_t) {
184-
cgo.Handle(pendingHandle).Delete()
184+
safeDeleteHandle(cgo.Handle(pendingHandle))
185185
}

cabi/stream.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func macula_stream_open_info_caller(infoHandle C.uintptr_t, out32 *C.uchar) {
140140

141141
//export macula_stream_open_info_free
142142
func macula_stream_open_info_free(infoHandle C.uintptr_t) {
143-
cgo.Handle(infoHandle).Delete()
143+
safeDeleteHandle(cgo.Handle(infoHandle))
144144
}
145145

146146
//export macula_stream_send_data
@@ -280,7 +280,7 @@ func macula_stream_item_body_bytes(itemHandle C.uintptr_t, out *C.uchar) {
280280

281281
//export macula_stream_item_free
282282
func macula_stream_item_free(itemHandle C.uintptr_t) {
283-
cgo.Handle(itemHandle).Delete()
283+
safeDeleteHandle(cgo.Handle(itemHandle))
284284
}
285285

286286
//export macula_stream_await_reply
@@ -341,7 +341,7 @@ func macula_stream_reply_responded_by(replyHandle C.uintptr_t, out32 *C.uchar) {
341341

342342
//export macula_stream_reply_free
343343
func macula_stream_reply_free(replyHandle C.uintptr_t) {
344-
cgo.Handle(replyHandle).Delete()
344+
safeDeleteHandle(cgo.Handle(replyHandle))
345345
}
346346

347347
//export macula_stream_abort
@@ -359,5 +359,5 @@ func macula_stream_abort(streamHandle C.uintptr_t, code *C.char, message *C.char
359359

360360
//export macula_stream_free
361361
func macula_stream_free(streamHandle C.uintptr_t) {
362-
cgo.Handle(streamHandle).Delete()
362+
safeDeleteHandle(cgo.Handle(streamHandle))
363363
}

cabi/ucan.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func macula_ucan_payload_proofs_json(payloadHandle C.uintptr_t) *C.char {
169169

170170
//export macula_ucan_payload_free
171171
func macula_ucan_payload_free(payloadHandle C.uintptr_t) {
172-
cgo.Handle(payloadHandle).Delete()
172+
safeDeleteHandle(cgo.Handle(payloadHandle))
173173
}
174174

175175
// The minted token is plain []byte -- read it back with the existing

src/CallResponse.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,42 @@ public function __construct(int $handle)
1919
$this->handle = $handle;
2020
}
2121

22+
/**
23+
* PHP shallow-copies $handle into the clone before this method
24+
* runs, so nulling it here (not just throwing) is required: without
25+
* it, the clone's own __destruct() would free the ORIGINAL's handle
26+
* the moment this throw unwinds and the never-assigned clone is
27+
* discarded, and the eventual double free() on an already-deleted
28+
* cgo.Handle panics on the Go side, which is fatal to the whole
29+
* process if unrecovered. This PHP-side guard remains the primary
30+
* defense -- it fails fast with a catchable exception instead of
31+
* relying on cabi/main.go's safeDeleteHandle recover() at all.
32+
*/
33+
public function __clone(): never
34+
{
35+
$this->handle = null;
36+
throw new \LogicException('CallResponse cannot be cloned -- each instance owns a unique FFI handle');
37+
}
38+
39+
/**
40+
* serialize()/unserialize() is a second door to the same bug
41+
* clone() guards against -- it copies $handle by value, and
42+
* unserialize() would hand back a second live object holding that
43+
* same raw handle, reachable via ordinary PHP ($_SESSION, an
44+
* object cache, a queue payload), no reflection needed. The handle
45+
* isn't meaningful across requests/processes anyway.
46+
*/
47+
public function __serialize(): never
48+
{
49+
throw new \LogicException('CallResponse cannot be serialized -- each instance owns a unique FFI handle');
50+
}
51+
52+
/** @param array<mixed> $data */
53+
public function __unserialize(array $data): never
54+
{
55+
throw new \LogicException('CallResponse cannot be unserialized -- each instance owns a unique FFI handle');
56+
}
57+
2258
public function isError(): bool
2359
{
2460
return Binding::get()->macula_response_is_error($this->handleOrFail()) !== 0;

src/Event.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,42 @@ public function __construct(int $handle)
1818
$this->handle = $handle;
1919
}
2020

21+
/**
22+
* PHP shallow-copies $handle into the clone before this method
23+
* runs, so nulling it here (not just throwing) is required: without
24+
* it, the clone's own __destruct() would free the ORIGINAL's handle
25+
* the moment this throw unwinds and the never-assigned clone is
26+
* discarded, and the eventual double free() on an already-deleted
27+
* cgo.Handle panics on the Go side, which is fatal to the whole
28+
* process if unrecovered. This PHP-side guard remains the primary
29+
* defense -- it fails fast with a catchable exception instead of
30+
* relying on cabi/main.go's safeDeleteHandle recover() at all.
31+
*/
32+
public function __clone(): never
33+
{
34+
$this->handle = null;
35+
throw new \LogicException('Event cannot be cloned -- each instance owns a unique FFI handle');
36+
}
37+
38+
/**
39+
* serialize()/unserialize() is a second door to the same bug
40+
* clone() guards against -- it copies $handle by value, and
41+
* unserialize() would hand back a second live object holding that
42+
* same raw handle, reachable via ordinary PHP ($_SESSION, an
43+
* object cache, a queue payload), no reflection needed. The handle
44+
* isn't meaningful across requests/processes anyway.
45+
*/
46+
public function __serialize(): never
47+
{
48+
throw new \LogicException('Event cannot be serialized -- each instance owns a unique FFI handle');
49+
}
50+
51+
/** @param array<mixed> $data */
52+
public function __unserialize(array $data): never
53+
{
54+
throw new \LogicException('Event cannot be unserialized -- each instance owns a unique FFI handle');
55+
}
56+
2157
public function topic(): string
2258
{
2359
$ffi = Binding::get();

src/KeyPair.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,49 @@ private function __construct(int $handle)
1919
$this->handle = $handle;
2020
}
2121

22+
/**
23+
* PHP shallow-copies $handle into the clone before running this
24+
* method, so the clone already holds a live copy of the same raw
25+
* handle at this point -- if left alone, its own __destruct() would
26+
* free the ORIGINAL's handle out from under it the moment this
27+
* throw unwinds and the clone (never assigned to a variable) is
28+
* discarded. Null it first so that free() is a no-op on the clone,
29+
* THEN throw -- the exception must not depend on which line runs
30+
* first, since either order without both steps still crashes on a
31+
* build without the cabi-side recover() (safeDeleteHandle in
32+
* cabi/main.go): a double free() on an already-deleted cgo.Handle
33+
* panics on the Go side, which is fatal to the whole process if
34+
* unrecovered. This PHP-side guard remains the primary defense --
35+
* it fails fast with a catchable exception instead of relying on
36+
* that recover() at all.
37+
*/
38+
public function __clone(): never
39+
{
40+
$this->handle = null;
41+
throw new \LogicException('KeyPair cannot be cloned -- each instance owns a unique FFI handle');
42+
}
43+
44+
/**
45+
* serialize()/unserialize() is a second door to the exact same bug
46+
* clone() has: it copies $handle by value into the serialized
47+
* representation, and unserialize() would hand back a second live
48+
* object holding that same raw handle -- reachable via ordinary PHP
49+
* ($_SESSION, an object cache, a queue payload), no reflection
50+
* needed. Block both directions; the handle isn't meaningful across
51+
* requests/processes anyway. Persist the identity by value instead:
52+
* fromSeedBytes(privateBytes()) reconstructs an equivalent KeyPair.
53+
*/
54+
public function __serialize(): never
55+
{
56+
throw new \LogicException('KeyPair cannot be serialized -- persist privateBytes() and rebuild with fromSeedBytes()');
57+
}
58+
59+
/** @param array<mixed> $data */
60+
public function __unserialize(array $data): never
61+
{
62+
throw new \LogicException('KeyPair cannot be unserialized -- persist privateBytes() and rebuild with fromSeedBytes()');
63+
}
64+
2265
public static function generate(): self
2366
{
2467
$ffi = Binding::get();

0 commit comments

Comments
 (0)