A client that keeps using an IPC object after calling its destroy method crashes the server. In Bitcoin Core, a mining client that calls BlockTemplate.destroy and then calls another BlockTemplate method makes bitcoin-node call a virtual method through a null pointer and die with SIGSEGV. A client that calls destroy from one thread while another of its threads is still inside a method on the same object makes the server free the implementation under that call, which is a use-after-free.
The libmultiprocess C++ client cannot do either, because it sends destroy only from ~ProxyClientBase, after which no C++ code can call that proxy. A Python (pycapnp) or Rust (capnp-rust) client holds a plain capability and can, for example when a stale task still holds a template.
Where
destroycallsProxyServerBase::invokeDestroy()(viaServerDestroy), which doesm_impl.reset()with no lock. TheProxyServeritself stays reachable for as long as the client holds the capability.- Every other method goes through
ProxyMethodTraits::invoke, which callsm_impl.get()->*implwith no null check.
How it plays out in Bitcoin Core
BlockTemplate and Echo declare destroy (mining.capnp line 34).
- Call after destroy: methods with a
Context(getBlock,submitSolution,waitNext, ...) crash on their worker thread.interruptWait, which has noContext, crashes on the event loop thread. A seconddestroyis harmless. - Destroy during a call: the
ProxyServeris the only owner ofBlockTemplateImpl, so the reset deletes it. The clearest case iswaitNext, which loops for up to the client's timeout reading the template'shashPrevBlockandvTxFees. A client that drops a template on a new tip and destroys it without callinginterruptWaitfirst turns every later iteration into a use-after-free. Rust Stratum v2 clients already run several calls on one template from separate threads (bitcoin/bitcoin#33923).
Under Bitcoin Core's trust model a client that can reach the -ipcbind socket already has unauthenticated RPC access, so this gives a hostile client nothing new. The concern is a buggy non-C++ client taking down the node.
Possible fix
Make destroy a checked operation, and document dropping the capability as the normal way to release an object.
- A method called after
destroythrows a clear error ("method called after destroy") instead of calling through a null pointer. destroythrows if calls on the same object are still in progress, instead of freeing the implementation under them. EachProxyServerwould keep a mutex, a count of active calls and a destroyed flag.serverInvokewould increment the count when a request arrives and decrement it when the request's promise settles, so the count covers building the results (which can point into the implementation) and covers methods without aContext, likeinterruptWait.destroywould not count itself. A refuseddestroydoes not leak: the implementation is freed when the client releases the capability, as it is today.- Document the two ways to end an object's life. Dropping the capability is always safe, even with calls in flight: Cap'n Proto keeps the
ProxyServeralive until its calls finish, and~ProxyServerBasethen frees the implementation on the async cleanup thread.destroyis for a client that wants the implementation freed synchronously at a known point and knows the object is idle. For example, a mining client discarding a template on a new tip should callinterruptWaitand drop the template, not calldestroywhilewaitNextmay still be returning.
The libmultiprocess C++ client is unaffected, since it only sends destroy from the proxy destructor, after its own calls on that proxy have returned.
Alternatives considered: making destroy wait for running calls (a read/write lock) would tie up a server worker thread for as long as a call like waitNext runs, and having each call hold its own reference to the implementation so destroy can return early adds nothing over dropping the capability, which already behaves that way. The privately reported fix also made destroy fail while calls are in progress.
Reproduction
Both cases reproduced on 2026-09-26 in mptest against Bitcoin Core master (bitcoin/bitcoin@bfdcd9797cd1a1345bdaf7ee7ef48f94028262da, which carries libmultiprocess v13 in its subtree), the code being the same as on master. The test adds a makeCallback [@25](/bitcoin-core-multiprocess/contributor/25/) () -> (result :FooCallback) method whose returned object is owned only by its ProxyServer, like a BlockTemplate, and sends raw Cap'n Proto requests on the client's capability, as a pycapnp or capnp-rust client could.
destroythencallon the same capability: SIGSEGV on the server worker thread; with-DSANITIZERS=address,undefined,proxy.h:289:16: runtime error: member call on null pointer of type 'mp::test::FooCallback'.- A hook holds a
callinside the implementation whiledestroyis sent on a second server thread frommakeThread.destroyreturns while the call is still running; when the call is released, ASan reportsheap-use-after-free ... in mp::test::OwnedCallback::call(int),freed by thread ... in ProxyServerBase<...>::invokeDestroy(). Without a sanitizer the call returned-258435454instead of102.
Both happen on every run (the hook forces the interleaving in the second). A test patch is ready and can be attached here or opened as a PR. Details: https://ipcbugs.ofsky.org/bugs/mp-2026-call-after-destroy-null-impl/#reproduction and https://ipcbugs.ofsky.org/bugs/mp-2026-destroy-during-call-race/#reproduction
Reported privately in an independent security review, 2026-08-13, and publicly in bitcoin/bitcoin#36502 (2026-10-11, found with fuzzamoto), which hits the same null dereference at proxy.h:289 after BlockTemplate.destroy followed by getBlock. The functional test in that report would work as a Bitcoin Core regression test.
Links
- ipcbugs pages: https://ipcbugs.ofsky.org/bugs/mp-2026-call-after-destroy-null-impl/, https://ipcbugs.ofsky.org/bugs/mp-2026-destroy-during-call-race/, https://ipcbugs.ofsky.org/audits/ar-17/
- bitcoin/bitcoin#36502 (first public report)
- #278 (documents
destroyindoc/design.md), bitcoin/bitcoin#33923 (multi-threaded use of one template)