Bug 81
Node crashes when a client calls a method on an object it already destroyed
- Status
- Open
- Symptom
- Crash; worst effect seen: crash
- Where seen
- A fuzz target, libmultiprocess tests (mptest)
- Trigger
- Unusual input from a peer or caller
- First reported
- Reported privately in an independent security review, 2026-08-13
- Fixed
- Not yet
- In released binaries
- Yes (v30.0 onward)
- Defect ever on master
- Yes
Summary
An IPC client that calls destroy on an object and then calls any other method on the same object makes the server call a virtual function through a null pointer, and bitcoin-node crashes with SIGSEGV. For BlockTemplate, one destroy followed by getBlock, waitNext or interruptWait is enough. The libmultiprocess C++ client cannot do this, so only a client written with another Cap'n Proto library (pycapnp, capnp-rust), or a buggy one, can trigger it. It was reported privately in an independent security review. A test that sends the calls directly reproduces the crash every time; it has not been seen in practice. The code has been this way since the library's first commit; released binaries from v30.0 on, which accept external IPC clients with -ipcbind, are affected. No fix yet.
What happened
Nothing has crashed in a deployment. The problem was reported privately in an independent security review of Bitcoin Core's IPC code (2026-08-13), found by reading the proxy dispatch code. The same report described a related race between destroy and a call still running, which is tracked separately as its own bug. The report's proposed fix adds a lease that makes calls after destroy fail with an error; its first test covered only the new API; a later revision added a functional test over real IPC that the report says crashes a release build. The first public report is bitcoin#36502 (2026-10-11), found independently with the fuzzamoto fuzzer: bitcoin-node crashed with an AddressSanitizer SEGV at the same line after a client destroyed a BlockTemplate and then called getBlock on it.
Root cause
On the server, each object a client holds is wrapped in a ProxyServer, which owns the real implementation through its m_impl pointer. Interfaces that declare a Cap'n Proto destroy method (Bitcoin Core's BlockTemplate and Echo) have it routed by ServerDestroy (include/mp/proxy-types.h) to ProxyServerBase::invokeDestroy() (include/mp/proxy-io.h):
m_impl.reset();
CleanupRun(m_context.cleanup_fns);
The ProxyServer itself stays alive and reachable for as long as the client holds the capability, because only the client can release it. Every other method goes through ProxyMethodTraits::invoke (include/mp/proxy.h), which calls (server_context.proxy_server.m_impl.get()->*ProxyMethod<MethodParams>::impl)(...) without checking that m_impl is still set. A call after destroy therefore makes a virtual call through a null pointer, and the vtable load faults. BlockTemplate methods with a Context parameter (getBlock, submitSolution, waitNext, ...) crash on their worker thread; interruptWait, which has no Context, crashes on the event loop thread. A second destroy is harmless, since resetting an empty pointer does nothing.
The design assumes the libmultiprocess C++ client, which sends destroy only from ~ProxyClientBase, after which no C++ code can make another call through that proxy. A pycapnp or capnp-rust client holds a plain capability and can keep using it after destroy, for example from a stale task that still holds a template.
Fix
No fix yet; tracked in mp#378 (opened 2026-10-15), which covers both destroy bugs. The private report proposes a lease on m_impl that rejects calls after destroy with an error instead of dereferencing null.
Reproduction
The test adds a makeCallback method to the test interface in mptest, the libmultiprocess unit test program. It returns a FooCallback object that the server owns outright, as it owns a BlockTemplate. The test then sends destroy followed by call on the same capability as raw Cap'n Proto requests, as a pycapnp or capnp-rust client could. It was run on 2026-09-26 against Bitcoin Core master bfdcd97, which carries libmultiprocess v13 in its subtree.
Patch: mptest-repro.patch. It applies to Bitcoin Core master; last checked against badc957 on 2026-10-15. From a Bitcoin Core checkout:
curl -sO https://ipcbugs.ofsky.org/repro/mptest-repro.patch
git apply mptest-repro.patch
cmake --build build --target mptest
build/src/ipc/libmultiprocess/test/mptest -f test.cpp:627
In a Debug build the server worker thread crashes with SIGSEGV in ProxyMethodTraits<FooCallback::CallParams>::invoke. In a build configured with -DSANITIZERS=address,undefined:
proxy.h:289:16: runtime error: member call on null pointer of type 'mp::test::FooCallback'
*** Received signal #11: Segmentation fault
The result is the same on every run. The test patch is available on request and will be included in the GitHub issue for this bug when it is opened.
Next steps
- Open the drafted libmultiprocess issue (an issue to be opened). It covers this bug and the destroy-during-call race, since both need a decision on how
destroyinteracts with other calls. - Make method dispatch throw a clear "method called after destroy" error when
m_implis null. This small fix is enough for this bug. The privately reported lease onm_implwould also reject such calls, and it is one of the options for the race. - Add the reproduction test (
destroy, thencall, sent as raw requests on a server-owned object) as a regression test. After the fix is merged, update Bitcoin Core's libmultiprocess subtree.
Timeline
- 2019-06-25: present since the library's initial commit 06e1045baba0, whose
invokeDestroyalready nulledm_impland whoseinvokealready called through it. - 2024-09-25: reachable by external clients once Bitcoin Core exposed the mining interface, including
BlockTemplate.destroy, over IPC in bitcoin#30510 ("multiprocess: Add IPC wrapper for Mining interface"), which external clients reach through-ipcbind. - 2026-08-13: reported privately in an independent security review.
- 2026-09-26: reproduced with a test against Bitcoin Core master.
- 2026-10-11: reported publicly in bitcoin#36502, found with fuzzamoto.
- 2026-10-15: mp#378 opened to track the fix.
- Releases: v30.0 and later have the bug.
Lessons
What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.
Pattern An in-process interface trusting what now comes from another process: Assuming every peer behaves like our own client
- Prevented by a different API shape, a design rule
- Earliest detector a test with a non-C++ client (did not exist then)
- Diagnosis easy
- Fix design moderate
- Debugging aids sanitizer output, a reproduction script
- Rust partly
Would Rust have prevented it? Partly. M_impl would be an Option, so a method call after destroy cannot dereference null unnoticed: the dispatcher either returns an error to the client or unwrap()s and panics, and a panic on the server thread still ends bitcoin-node. More on Rust
Could it have been prevented?
Yes, by an API shape in which destroy leaves the server object in a state dispatch can
see. invokeDestroy resets m_impl and leaves the ProxyServer reachable for as long
as the client holds the capability, while ProxyMethodTraits::invoke calls through
m_impl.get() with no check; the two halves have looked like this since the initial
commit (06e1045baba0).
Either a null check that answers with an error, or the lease the private report proposes,
makes a call after destroy a protocol error rather than a null virtual call. The
missing rule is the general one for a wire boundary: the order of calls is peer input,
not a precondition, even for a method the library's own client only ever sends from a
destructor. That became true the day
bitcoin#30510 put BlockTemplate with
its destroy method in front of -ipcbind clients.
Could it have been found sooner?
Yes, by any client that does not go through ProxyClientBase. The C++ client cannot
send a call after destroy because destroy is sent from the proxy's destructor, so
every C++ test is structurally unable to reach the path; a Python or Rust client, or a
fuzzer sending raw Cap'n Proto requests, hits it on the first destroy followed by any
method. Neither existed against the library until 2025, and the pycapnp test that did
exist exercised default values, not call ordering. The survey's 2026-09-26 mptest case
sends the two raw requests and crashes every time, which is what a foreign-client test
would have done at any point since 2019.
What made it hard to debug?
Nothing once reproduced: a null this in a virtual call is a SIGSEGV on the vtable load,
and UBSan names the line ("member call on null pointer of type ..."). The cost was in
recognition, since the report came from reading dispatch code in an independent security
review rather than from a crash, and its proposed test exercised the new lease API
instead of the crash. Deciding the fix is the harder part: whether a call after
destroy should fail with a Cap'n Proto exception, whether destroy itself should
become idempotent-and-final, and how the answer interacts with the destroy-during-call
race reported alongside it
(bug page).
Lesson
When a server-side object can be told to release its implementation by a remote peer, every other entry point must be able to answer after that happens, because the peer, not the server, chooses the order. Test each interface with a client that does not share your proxy code, since your own client will never send the sequences your dispatch does not handle.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | crash |
| Worst effect observed | crash |
| Seen in | fuzz-target, mptest |
| Scope | production |
| Trigger | Unusual input (unusual-input) |
| Phase | steady-state |
| Side | server |
| Component | proxy-io |
| Root cause kind | lifetime |
| Fix kind | none |
| Detection | review |
| Needs a peer that is not libmultiprocess C++ | yes |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | binary |
| Confidence | high |