Bug 66

Sending an empty byte string over IPC is undefined behavior

Status
Fixed
Symptom
Undefined behavior; worst effect seen: sanitizer report only
Where seen
A fuzz target, libmultiprocess tests (mptest)
Trigger
Unusual input from a peer or caller
First reported
2026-07-01 (first report)
Fixed
2026-07-13
Fix
mp#305
Shipped to Bitcoin Core
bitcoin#35720
In released binaries
Yes (v30.0-v31.1)
Defect ever on master
Yes

Summary

Serializing an empty byte vector into a Cap'n Proto Data field (the schema type for byte strings such as scripts) called memcpy with a null source pointer, which is undefined behaviour even though nothing is copied, and UBSan reports it. It was found by the new IPC fuzz target in Bitcoin Core's fuzzer,address,undefined,integer CI job. There is no known observable effect on any real platform. Released binaries v30.0 through v31.1 contain it (and v29 source builds with IPC enabled). Fixed in mp#305 and shipped to Bitcoin Core master by the subtree update bitcoin#35720.

What happened

Root cause

CustomBuildField for byte spans in include/mp/type-data.h did memcpy(result.begin(), data.data(), data.size()). For an empty vector, data() may be null and size() is 0. memcpy declares both pointers non-null regardless of the count, so the call is undefined behaviour and UBSan's nonnull-attribute check reports it. Any empty byte vector in a Data field reaches it, for example an empty script sent by a client.

It took a fuzzer to produce an empty vector on a Data field under UBSan; the unit tests and Bitcoin Core's functional tests never sent empty byte strings.

The code

The generic Data field builder in include/mp/type-data.h, as of fac7b9b7f688^, used for any byte-span type such as std::vector<unsigned char>:

template <typename LocalType, typename Value, typename Output>
void CustomBuildField(TypeList<LocalType>, Priority<2>, InvokeContext& invoke_context, Value&& value, Output&& output)
requires (std::is_same_v<decltype(output.get()), ::capnp::Data::Builder> && IsByteSpan<LocalType>)
{
    auto data = std::span{value};
    auto result = output.init(data.size());
    memcpy(result.begin(), data.data(), data.size());
}

For an empty vector data.data() can be null. memcpy requires non-null pointers even when the count is 0, so the call is undefined behaviour, and UBSan reports a null pointer passed as an argument declared never null. Nothing is copied, so no real platform misbehaves; the cost was a failing fuzz job.

The fix uses std::ranges::copy, which is defined for empty ranges (fac7b9b7f688; the <ranges> include and the new passData test are omitted):

@@ -33,7 +34,7 @@ requires (std::is_same_v<decltype(output.get()), ::capnp::Data::Builder> && IsBy
     auto data = std::span{value};
     auto result = output.init(data.size());
-    memcpy(result.begin(), data.data(), data.size());
+    std::ranges::copy(data, result.begin());
 }

std::ranges::copy copies nothing for an empty span and never touches the pointers, so there is no call with a null argument left for UBSan to flag.

Fix

fac7b9b7f688 (maflcko) replaces the call with std::ranges::copy(data, result.begin()), which is defined for empty ranges (+2/-1), and adds the passData test method and a test that round-trips an empty vector. maflcko described it as "UB in theory (not sure if a compiler exists in practise to exploit this)". In his ACK ryanofsky asked whether optimizers could drop nearby null checks, and accepted the change as an improvement either way.

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: Empty input

  • Prevented by a different API shape, a review checklist item
  • Earliest detector UBSan (did not exist then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids sanitizer output, a clear assertion message
  • Rust yes

Would Rust have prevented it? Yes. Slice::copy_from_slice is defined for empty slices and a Rust slice never carries a null data pointer, so there is no non-null contract to violate; the C++ memcpy was UB only on paper. More on Rust

Could it have been prevented?

Yes, by the fix's own choice of API. std::ranges::copy (or std::copy, or a span assign) is defined for an empty range and never dereferences or passes the pointers, while memcpy declares both arguments non-null whatever the count. A house rule to use the standard algorithms rather than raw memcpy on containers, or a review question "what does this do for an empty input?", would have caught it when the byte-span builder was written for Bitcoin Core's common-types.h in 2017 and again when 7d59b8d14c86 moved it into the library. The memcpy non-null rule is obscure enough that neither the author nor the reviewers of either version thought of it, and maflcko's fix commit still calls it a workaround for a UBSan warning rather than a bug, so "prevented" here means a lint-level habit, not a design insight.

Could it have been found sooner?

Yes, by any UBSan run that sends an empty byte vector through a Data field. Bitcoin Core has had UBSan CI jobs for years, and the library's mptest round-trips many types, but no test in either project sent an empty byte string; the fuzz target in bitcoin#35118 was the first thing to feed arbitrary values into the generated serialization code, and it hit the empty vector at once. The only reason it was not reported earlier from that PR is that the author had guarded the case away; marcofleon's question about the guard is what turned a workaround into a report. The fix adds passData and an empty-vector round trip to mptest, which is the unit-level detector that was missing.

What made it hard to debug?

Nothing. UBSan's nonnull-attribute message named the file, line and argument (type-data.h:36:11: null pointer passed as argument 2), enirox001 identified the memcpy the same day, and maflcko reproduced it in mptest and fixed it in three lines (fac7b9b7f688). The only discussion was whether it mattered: maflcko called it "UB in theory", and ryanofsky's ACK asked whether an optimizer could drop nearby null checks on the strength of the non-null attribute, which is the practical risk a formal-UB finding carries.

Lesson

Every serializer and copier has an empty-input case; write a test for it, and prefer standard algorithms whose empty-range behaviour is defined over C calls whose pointer preconditions hold even when the count is zero.

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 fuzz-target, mptest
Scope production
Trigger Unusual input (unusual-input)
Phase steady-state
Side both
Component type-data
Root cause kind api-misuse
Fix kind one-line-tweak
Detection fuzz
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see yes
Defect ever on master yes
Released binary
Confidence medium