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

Timeline

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