Audit report AR-02
Reported privately in an independent security review, 2026-08-07.
- Report date
- 2026-08-07
- Kind
- Design
- Status on master
- Present
- Proposed issue state
- Open
Summary
When an interface method takes another interface by C++ reference (Impl&), libmultiprocess passes it to the peer as a capability backed by a shared_ptr with a no-op deleter, so holding the capability does not keep the object alive. The report argues that a peer can keep the capability after the call and use it after the object is gone. The ownership shape is real and documented, but no Bitcoin Core interface on master passes an interface by reference over IPC, and the one method on the unmerged multiprocess branch keeps the capability on purpose. Design note, not reachable today.
The report
CustomBuildField(TypeList<Impl&>) in include/mp/type-interface.h serializes a reference argument as:
std::shared_ptr<Impl>(&value, [](Impl*){})
The resulting ProxyServer is an ordinary Cap'n Proto capability whose lifetime is controlled by the remote side's references, but its m_impl pointer neither owns nor extends the lifetime of value. The report says the generated server code used by a well-behaved libmultiprocess peer creates a temporary ProxyClient for the duration of the call, and that this is a convention, not a wire-level rule. A peer written independently against the Cap'n Proto schema could keep the capability from the request and call it after the caller's C++ reference has gone out of scope. The result would be a virtual call through freed stack or heap memory in the process that exported the reference.
For reachability the report points to Init::makeWalletLoader(Chain& chain) in Bitcoin Core's IPC surface. It says that the normal wallet keeps that chain reference alive for a long time, so ordinary use does not trigger the problem; its point is that the generic contract depends on application-specific lifetime discipline that a malicious peer does not have to follow.
The reproduction is two standalone C++ programs, not tests against the library. One checks that a copy of the no-op-deleter shared_ptr outlives the call frame. The other exports a stack object this way, leaves its scope, and calls through the retained pointer; AddressSanitizer reports stack-use-after-scope.
Proposed change
The report asks that a bare Impl& no longer silently become a retainable capability. Options it lists: reject reference interface parameters at code generation; require shared_ptr, unique_ptr or an explicit lifetime token; make the capability revocable and invalidate it before the reference may die; or have the generated server reject every call after the parent call completes. Its patch sketch deletes the overload:
-template <typename Impl, typename Output>
- requires InterfaceField<Output>
-void CustomBuildField(TypeList<Impl&>, Priority<1>, InvokeContext& ctx, Impl& value, Output&& output)
-{
- using Interface = typename decltype(output.get())::Calls;
- output.set(CustomMakeProxyServer<Interface, Impl>(ctx,
- std::shared_ptr<Impl>(&value, [](Impl*){})));
-}
+template <typename Impl, typename Output>
+void CustomBuildField(TypeList<Impl&>, Priority<1>, InvokeContext&, Impl&, Output&&) = delete;
This makes any interface-by-reference parameter a compile error. It also suggests a raw Cap'n Proto test that retains a reference parameter after the call returns.
Assessment
The code is unchanged on libmultiprocess master, and the shape is intentional: the comment on ProxyServerBase::m_impl in include/mp/proxy.h says servers wrapping Impl& arguments do not own their object and that custom code on the other side is responsible for cleanup.
Two parts of the reachability argument do not match the code:
makeWalletLoader(Chain&)is not in Bitcoin Core'sinit.capnp, either at the commit the report examined or on master. The upstream schemas (init,mining,echo,rpc) have no interface-typed parameter. The C++ virtualinterfaces::Init::makeWalletLoaderexists but is not exposed over IPC. The IPC method exists only on the unmerged multiprocess branch (bitcoin#10102).- There is no generic server path that makes a call-scoped
ProxyClientfor anImpl¶meter.type-interface.honly hasCustomReadFieldoverloads forunique_ptrandshared_ptrinterface types, so a reference parameter needs hand-written server code. On the multiprocess branch that code keeps theChainclient alive for the whole lifetime of the returnedWalletLoader, deliberately, and the referent is the node's chain interface, which lives until node shutdown.
So retaining the capability is the intended protocol there. The residual question is shutdown ordering on that branch: a wallet process that keeps calling Chain after the node destroyed it. That would be a specific bug to watch for if the wallet split lands, not a generic library use-after-free.
Under Bitcoin Core's trust model a process that can reach the IPC socket already has unauthenticated RPC access, including stop. The report's impact assumes a compromised peer attacking another process, which is not a configuration Bitcoin Core supports today.
Related: AR-17 describes a different path in which a server's owned implementation is used after destroy.
Reproduction (2026-09-26). Two tests against Bitcoin Core master bfdcd97 (libmultiprocess v13 subtree). In the first, a peer written without the generated server code keeps the FooCallback capability it was passed by reference and calls it after the client has deleted the object: AddressSanitizer reports a heap-use-after-free in the exporting process. So the use-after-free is real for a peer that does not use the generated code. In the second, the server uses the generated code and keeps a copy of the capability: the generated code wraps the argument in a temporary client that sends destroy when the call ends, so a later call through the retained copy hits the null implementation of AR-17 instead of freed memory. The exporting side therefore always depends on the peer's destroy. Reachability on Bitcoin Core master is unchanged: no upstream schema passes an interface by reference. The test patch is available on request.
Status
Present on master by design. The deleted-overload patch would break the multiprocess branch's intended use. A gentler next step is to document the lifetime contract next to the overload ("the exporting side keeps the referent alive while the peer may hold the capability"), or to require an explicit lifetime annotation for reference parameters. Idea for discussion; no issue open.
Reported privately in an independent security review on 2026-08-07.