Bug 21
Client proxy code casts to a derived object before it exists or after it is gone
- Status
- Fixed
- Symptom
- Undefined behavior; worst effect seen: sanitizer report only
- Where seen
- Bitcoin Core unit tests
- Trigger
- Normal use; no special event is needed
- First reported
- 2024-11-22 (first report)
- Fixed
- 2025-01-16
- Shipped to Bitcoin Core
- bitcoin#31740
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
Every time a ProxyClient was constructed or destroyed, the base class ProxyClientBase did a static_cast from itself to the derived ProxyClient type inside its own constructor and destructor. At those points the derived object does not exist yet, or no longer exists. That is undefined behavior, and UBSan reported it as a dynamic-type-mismatch downcast in Bitcoin Core's ipc_tests. It had no practical effect, because the generated ProxyClient classes add no state of their own. Source builds of v22 through v28 with multiprocess enabled have it. Fixed in two steps, mp#121 (constructor) and mp#127 (destructor).
What happened
- bitcoin#30975 ("ci: build multiprocess on most jobs") built multiprocess in the ASan+LSan+UBSan CI job, and @Sjors reported that
ipc_testsfailed there (2024-11-22) withinclude/mp/proxy.h:95:45: runtime error: downcast of address ... which does not point to an object of type 'ProxyClient<Interface>' (aka 'ProxyClient<ipc::capnp::messages::Init>'), raised fromself()in theProxyClientBaseconstructor. - With the constructor fixed by mp#121 ("ProxyClientBase: avoid static_cast to partially constructed object"), the same job reported the same source line from the
ProxyClientBasedestructor ofProxyClient<Echo>(2025-01-10). Sjors filed it as mp#125 ("Bitcoin Core UBSan failure for interfaces::Echo"); ryanofsky called it basically the same bug. It only appeared after the constructor case was fixed because UBSan stops at the first report.
Root cause
ProxyClientBase<Interface, Impl> (include/mp/proxy.h) had a self() helper, static_cast<ProxyClient<Interface>&>(*this), used to call the generated subclass's optional construct() and destroy() hooks. The constructor (include/mp/proxy-io.h) called self().construct() as its last statement, and the destructor's cleanup lambda called self().destroy() and used self().m_client. At both points the most-derived ProxyClient object is not alive, so the cast is undefined behavior under the object-lifetime rules, though nothing could go wrong in practice since the derived classes have no members of their own.
The cast has been there since the library's initial commit. Nothing reported it until Bitcoin Core built libmultiprocess with -fsanitize=undefined.
The code
Quoted from 5b8119284760^. ProxyClientBase (include/mp/proxy.h) is the base of every generated ProxyClient<Interface> class, and reaches the generated construct() and destroy() methods through a downcast:
void construct() {}
void destroy() {}
ProxyClient<Interface>& self() { return static_cast<ProxyClient<Interface>&>(*this); }
The base-class constructor in include/mp/proxy-io.h calls it as its last statement, and registers a cleanup lambda that ~ProxyClientBase runs, which calls it again:
m_context.cleanup.emplace_front([this, destroy_connection, cleanup]{
if (m_context.connection) {
// ...
self().destroy();
// FIXME: Could just invoke removed addCleanup fn here instead of duplicating code
m_context.connection->m_loop.sync([&]() {
// Release client capability by move-assigning to temporary.
{
typename Interface::Client(std::move(self().m_client));
}
// ...
});
self().construct();
}
In the constructor the ProxyClient part of the object has not been constructed yet, and in ~ProxyClientBase it has already been destroyed, so both static_casts name an object that does not exist. That is undefined behaviour, and UBSan reports it as "downcast of address ... which does not point to an object of type 'ProxyClientProxyClient classes add no data members, so no memory was misread in practice.
The first fix (5b8119284760) moved the construct() call into a generated ProxyClient constructor. The second (63a39d4c9bc2) removed self(), made construct and destroy static functions taking the base object, and reverted the generator change; its src/mp/gen.cpp hunks are omitted:
--- a/include/mp/proxy.h
+++ b/include/mp/proxy.h
@@ -89,10 +91,8 @@ public:
- void construct() {}
- void destroy() {}
-
- ProxyClient<Interface>& self() { return static_cast<ProxyClient<Interface>&>(*this); }
+ static void construct(Super&) {}
+ static void destroy(Super&) {}
--- a/include/mp/proxy-io.h
+++ b/include/mp/proxy-io.h
@@ -412,13 +412,13 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
- self().destroy();
+ Sub::destroy(*this);
// FIXME: Could just invoke removed addCleanup fn here instead of duplicating code
m_context.connection->m_loop.sync([&]() {
// Release client capability by move-assigning to temporary.
{
- typename Interface::Client(std::move(self().m_client));
+ typename Interface::Client(std::move(m_client));
}
@@ -432,6 +432,7 @@ ProxyClientBase<Interface, Impl>::ProxyClientBase(typename Interface::Client cli
+ Sub::construct(*this);
}
Sub::construct(*this) and Sub::destroy(*this) only use the ProxyClient class's static functions and pass the base object, which is alive at both points, so no reference to the missing derived object is formed.
Fix
- 5b8119284760 in mp#121 removes
self().construct()from theProxyClientBaseconstructor and hasmpgen, the code generator, emit a forwardingProxyClientconstructor that callsconstruct()after the base is built. - 63a39d4c9bc2 in mp#127 ("ProxyClientBase: avoid static_cast to partially destructed object") makes
constructanddestroystatic functions taking the base object (Sub::construct(*this),Sub::destroy(*this)), usesm_clientdirectly in the cleanup lambda, removesself(), and reverts the mp#121 generator change, which was no longer needed. Its commit message says "In practice there is no real bug here". mp#126, an earlier and more invasive attempt at the destructor case, was closed in favor of it.
No test was added; the UBSan job running ipc_tests covers it.
Timeline
- 2019-06-25:
self()cast in the constructor and destructor present since the library's initial commit 06e1045baba0. - 2024-11-22: constructor case reported by @Sjors on bitcoin#30975.
- 2025-01-10: destructor case seen; filed as mp#125 on 2025-01-13.
- 2025-01-13: mp#121 merged; 2025-01-16: mp#127 merged.
- 2025-01-29: both shipped to Bitcoin Core in bitcoin#31740 ("depends: Update libmultiprocess library before converting to subtree").
- Releases: v28.x and earlier have the undefined behavior in source builds with multiprocess enabled; v29.0 has the fix.
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 Built or torn down in the wrong order: A base class reaching a derived object that is not built yet or already gone
- Prevented by a different API shape
- Earliest detector UBSan (did not exist then)
- Diagnosis easy
- Fix design moderate
- Debugging aids sanitizer output
- Rust yes
Would Rust have prevented it? Yes. Rust has no base-class constructors or destructors that run on a partially built object; a struct is fully initialized before any method can see it, so the downcast-from-base hook cannot be written. More on Rust
Could it have been prevented?
Yes, by the shape the final fix chose. ProxyClientBase reached its generated
subclass's construct() and destroy() hooks through
static_cast<ProxyClient<Interface>&>(*this) from its own constructor and destructor,
the two places where the derived object is by definition not alive. Hooks that a base
class calls during its own lifetime must not need the derived object: mp#127
(63a39d4c9bc2)
makes them static functions taking the base by reference, which removes self() and
with it the possibility of the cast. The rule ("no downcast in a base constructor or
destructor") is standard C++ guidance and was knowable in 2019; the cast was written
because the derived classes carry no state, which is also why it never mattered in
practice.
Could it have been found sooner?
Yes, by compiling the library itself with -fsanitize=undefined and running any test
that constructs a proxy: every ProxyClient construction trips it, deterministically.
Bitcoin Core had a UBSan job in 2019 but libmultiprocess was an external depends
package it never built with sanitizers, and the library had no sanitizer CI of its own;
bitcoin#30975 built multiprocess in
the ASan+UBSan job and found it on the first run. The destructor site appeared only
after the constructor site was fixed, because UBSan halts on the first report, which
is why one bug became two PRs.
What made it hard to debug?
Nothing about the cause: the sanitizer named the line and the missing derived type.
The effort was in the fix. mp#121 addressed only the constructor by having the code
generator emit a forwarding ProxyClient constructor; the destructor's cleanup lambda
still went through self(), and the first attempt at that
(mp#126) was more invasive
than wanted and was closed for mp#127, which also reverted the mp#121 generator
change. Treating the first report as one site rather than asking where else self()
was called cost a second CI round; a grep for the helper would have found both.
Lesson
Hooks that a base class invokes from its constructor or destructor must take the base object, never the derived one; and when a sanitizer reports a pattern, fix every use of the pattern rather than the reported line, because it stops at the first.
Classification
The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.
| Field | Value |
|---|---|
| Symptom | ub |
| Worst effect observed | sanitizer-report-only |
| Seen in | unit-test |
| Scope | production |
| Trigger | Normal use (ordinary-path) |
| Phase | any |
| Side | client |
| Component | proxy-io |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| Detection | ubsan |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | yes |
| Defect ever on master | yes |
| Released | source |
| Confidence | high |