Bug 31
Reading IPC fields calls a function through a dangling reference, without visible effect
- Status
- Fixed
- Symptom
- Undefined behavior; worst effect seen: nothing observed
- Where seen
- Never observed
- Trigger
- Normal use; no special event is needed
- First reported
- 2025-06-19 (first report)
- Fixed
- 2025-06-26
- Fix
- mp#184
- Shipped to Bitcoin Core
- bitcoin#32345
- In released binaries
- No; the code was in source releases only
- Defect ever on master
- Yes
Summary
ReadDestEmplace, a helper libmultiprocess uses when reading fields of incoming messages, stored a reference to the function object passed to it. ReadDestTemp passed it a temporary lambda, so every field read through ReadDestTemp called that lambda through a dangling reference. This is undefined behavior in the standard's terms, but the lambda had no state and its call never touched the object, so no misbehavior was ever seen; only clang-tidy's static analyzer flagged it. Fixed in mp#184 by storing the function object by value. The code was in Bitcoin Core source releases v25 through v29, which did not ship IPC binaries.
What happened
No runtime failure was ever observed. Bitcoin Core's clang-tidy CI job on bitcoin#31802 ("Add bitcoin-{node,gui} to release binaries for IPC") printed, without failing the job:
include/mp/proxy-types.h:134:5: warning: Address of stack memory associated with temporary object of type '(lambda ...)' is still referred to by a temporary object on the stack upon returning to the caller. This will be a dangling reference [clang-analyzer-core.StackAddressEscape]
reached from the generated foo.capnp.proxy-server.c++ through serverInvoke, PassField and ReadField. @fanquake asked (2025-06-19) for the tidy output to be fixed or suppressed so the job stayed usable.
Root cause
ReadDestEmplace in include/mp/proxy-types.h took its emplace callable as EmplaceFn&& and kept it in a reference member, EmplaceFn& m_emplace_fn. The helper ReadDestTemp<LocalType>() returned
return ReadDestEmplace{TypeList<LocalType>(), [&](auto&&... args) -> decltype(auto) {
return LocalType{std::forward<decltype(args)>(args)...};
}};
so the returned object referred to a lambda temporary that was destroyed at the end of the return statement, and construct() later called m_emplace_fn(...) through that reference. Calling a member function through a reference to a destroyed object is undefined behavior, but this lambda captures nothing and its operator() never reads this, so in practice the call runs the lambda's code and nothing different happens. The fix's commit message says the same: it "should not be a real problem ... But taking the reference could technically be undefined behavior."
The path ran on every read through ReadDestTemp, which was used for bool vector elements in type-vector.h and by the foo-types.h test types.
The code
Quoted from 060a73926956^, include/mp/proxy-types.h. ReadDestEmplace keeps a reference to the callable it is given and calls it later from construct():
template <typename LocalType, typename EmplaceFn>
struct ReadDestEmplace
{
ReadDestEmplace(TypeList<LocalType>, EmplaceFn&& emplace_fn) : m_emplace_fn(emplace_fn) {}
// ...
template <typename... Args>
decltype(auto) construct(Args&&... args)
{
return m_emplace_fn(std::forward<Args>(args)...);
}
// ...
EmplaceFn& m_emplace_fn;
};
// ...
template <typename LocalType>
auto ReadDestTemp()
{
return ReadDestEmplace{TypeList<LocalType>(), [&](auto&&... args) -> decltype(auto) {
return LocalType{std::forward<decltype(args)>(args)...};
}};
}
The lambda in ReadDestTemp is a temporary that is destroyed when the return statement ends, but the ReadDestEmplace returned to the caller still refers to it. A caller such as ReadField(..., ReadDestTemp<std::string>()) then calls construct(), which invokes the destroyed lambda through m_emplace_fn. That is undefined behaviour, and clang-tidy's StackAddressEscape check reports it. The lambda has no captures and its call operator reads no state, so the call ran the right code in practice.
The fix (060a73926956) stores the callable by value and drops the unused [&] capture (the .clang-tidy change enabling the check is omitted):
@@ -92,7 +92,7 @@ struct StructField
- ReadDestEmplace(TypeList<LocalType>, EmplaceFn&& emplace_fn) : m_emplace_fn(emplace_fn) {}
+ ReadDestEmplace(TypeList<LocalType>, EmplaceFn emplace_fn) : m_emplace_fn(std::move(emplace_fn)) {}
@@ -123,7 +123,7 @@ struct ReadDestEmplace
- EmplaceFn& m_emplace_fn;
+ EmplaceFn m_emplace_fn;
};
@@ -131,7 +131,7 @@ struct ReadDestEmplace
- return ReadDestEmplace{TypeList<LocalType>(), [&](auto&&... args) -> decltype(auto) {
+ return ReadDestEmplace{TypeList<LocalType>(), [](auto&&... args) -> decltype(auto) {
return LocalType{std::forward<decltype(args)>(args)...};
}};
Each ReadDestEmplace now owns a copy of its callable, so the callable lives as long as the object that calls it, whatever the lifetime of the lambda it was built from.
Fix
The fix commit 060a73926956 takes EmplaceFn by value and stores it by value (m_emplace_fn(std::move(emplace_fn))), and drops the unneeded [&] capture from the ReadDestTemp lambda (+3/-3 in proxy-types.h). The same commit enables clang-analyzer-core.StackAddressEscape in .clang-tidy. mp#184 ("Add CI jobs and fix clang-tidy and iwyu errors") also added libmultiprocess's own clang-tidy and IWYU CI jobs and fixed other tidy warnings; the three bugprone-use-after-move warnings it addressed were false positives (no object was actually moved from before use).
Timeline
- 2022-10-04: introduced by 70d108bc6086 ("Add ReadDestTemp function", mp#81), which added both the reference member and
ReadDestTemp. - 2025-06-19: clang-tidy output reported by @fanquake on bitcoin#31802.
- 2025-06-26: fix merged in mp#184.
- 2025-08-18: shipped to Bitcoin Core master in bitcoin#32345 ("ipc: Handle unclean shutdowns better"), which included a subtree update with mp#184.
- Releases: present in the source of v25 through v29; v30.0, the first release with IPC binaries, 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 An object outliving what it points to: A stored reference to a temporary
- Prevented by a design rule, a review checklist item
- Earliest detector static analysis (did not exist then)
- Diagnosis easy
- Fix design easy
- Debugging aids other
- Rust yes
Would Rust have prevented it? Yes. A struct that borrows a temporary created in the same return statement cannot be returned; the borrow checker rejects it, so the callable would be stored by value from the start. More on Rust
Could it have been prevented?
Yes, by a rule that is common in C++ style guides and that the fix applies: a class
that stores a callable it will invoke later stores it by value, never as a reference
member bound from a constructor parameter, because the constructor cannot know whether
its argument is a temporary. ReadDestEmplace took EmplaceFn&& and kept
EmplaceFn& m_emplace_fn; the only caller that mattered, ReadDestTemp, passed a
lambda that died at the end of its return statement
(70d108bc6086,
mp#81). The reference member
and the temporary were introduced in the same commit, so a review item "does any
reference member outlive what it was bound to?" would have caught it on the day. The
fix (060a73926956)
is three lines: take by value, store by value, drop the unused [&] capture.
Could it have been found sooner?
Yes, by the check that did find it, clang-analyzer's StackAddressEscape, which reports
exactly this shape without running anything. Neither project ran it on this code in
2022: libmultiprocess had no clang-tidy job at all until
mp#184 added one as part of
this fix, and Bitcoin Core's tidy job did not compile the library until
bitcoin#31802 ran it over the
src/ipc/libmultiprocess subtree in June 2025, when fanquake
noticed the warning
in the output of a passing job. No runtime check could have found it: the lambda has
no captures and its call operator never reads this, so ASan and UBSan see no bad
access, and nothing ever misbehaved in the three years the code ran.
What made it hard to debug?
Nothing. The warning names the header line and the mechanism ("still referred to by a
temporary object on the stack upon returning to the caller"), and the fix commit was
written within a day of the report. The only effort was in classifying it: the same
tidy run produced several bugprone-use-after-move warnings that turned out to be
false positives, and this one had to be recognized as a real, if harmless, defect
rather than suppressed with them. The commit body records that judgement ("should not
be a real problem ... could technically be undefined behavior"), which is what a
future reader needs to know.
Lesson
Store callables and other constructor arguments by value unless the class is documented as a non-owning view; and run the static analyzer on library code from the first commit, because a static check is the only kind that reports undefined behavior that happens to work.
Notes
- The code was unchanged for almost three years; it surfaced only when bitcoin#31802 ran Bitcoin Core's tidy job with clang-analyzer checks over the
src/ipc/libmultiprocesssubtree.
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 | none |
| Seen in | never |
| Scope | production |
| Trigger | Normal use (ordinary-path) |
| Phase | any |
| Side | both |
| Component | proxy-types |
| Root cause kind | lifetime |
| Fix kind | local-fix |
| Detection | clang-tidy |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | source |
| Confidence | low |