Bug 23
Scripts passed to the node over IPC are decoded in the wrong format
- Status
- Fixed
- Symptom
- Crash; worst effect seen: crash
- Where seen
- Branch-build
- Trigger
- Normal use; no special event is needed
- First reported
- 2024-12-02 (first report)
- Fixed
- 2025-01-27
- Fix
- mp#135, bitcoin#31740
- Shipped to Bitcoin Core
- bitcoin#31740
- In released binaries
- No
- Defect ever on master
- Yes
Summary
A CScript passed to bitcoin-node over IPC, for example the coinbase script given to Mining::createNewBlock, was decoded wrongly. libmultiprocess converts each C++ type to and from a Cap'n Proto field with a pair of overloaded functions, one to build the field and one to read it. For scripts the sender wrote raw bytes, but there was no matching read function, so the receiver parsed the bytes with Bitcoin serialization, which expects a length prefix first. @Sjors reported it as a crash while testing a Stratum v2 template provider, and @maflcko reproduced it under valgrind as a memory error in SpanReader::read. The read is bounds-checked, so a release build most likely threw an exception from the server method or decoded a wrong script rather than segfaulting; the exact failure in a normal build was not recorded. No release was affected: v28 had no mining IPC interface and v29 has the fix. Fixed in mp#135 by adding a read function that matches the raw-bytes build function, shipped in bitcoin#31740.
What happened
@Sjors hit a crash in bitcoin-node while testing his Template Provider branch against the Mining interface and filed mp#122 ("Possible crash when (not) writing the compact size of the script/prevector", 2024-12-02), pointing at a comment on an issue in his fork of Bitcoin Core. @maflcko reproduced it the same day by adding mining->createNewBlock({}) to the bitcoin-mine sample client from bitcoin#30437 ("ipc: add bitcoin-mine test program", then still a draft), and posted a valgrind trace with an error in SpanReader::read below CScript::Unserialize. ryanofsky diagnosed it within the hour.
Root cause
Bitcoin Core's src/ipc/capnp/common-types.h had a CustomBuildField overload that sent any type convertible to a byte span as a raw capnp::Data field. It was added for GCSFilter::ElementSet and later used for CScript and others, and it had no matching CustomReadField; its own comment said so. On the receiving side, overload resolution fell through to the lower-priority generic overload for types with an Unserialize method, which expects Bitcoin's serialization format: a CompactSize length followed by the script bytes. The reader took the first script byte as the length. SpanReader::read checks bounds, so a length past the end of the data throws SpanReader::read(): end of data, and a shorter length decodes a wrong script.
CScript was the first type sent over IPC that is both convertible to a byte span and has Unserialize. No merged code called createNewBlock with a script over IPC, so only an external client or the draft bitcoin-mine could hit it. Earlier users of the build overload were types whose generic read path happened to be compatible.
The code
The defect is in Bitcoin Core's src/ipc/capnp/common-types.h, quoted from 2221c8814d7^, the Bitcoin Core commit that replaced it with the libmultiprocess fix. The sending side writes any byte-span-like value, such as a CScript, as its raw bytes:
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>) &&
(std::convertible_to<Value, std::span<const std::byte>> ||
std::convertible_to<Value, std::span<const char>> ||
std::convertible_to<Value, std::span<const unsigned char>> ||
std::convertible_to<Value, std::span<const signed char>>)
{
auto data = std::span{value};
auto result = output.init(data.size());
memcpy(result.begin(), data.data(), data.size());
}
There is no matching CustomReadField, so the receiving side picks the best remaining overload, which in the same file is the one for any type with an Unserialize method:
template <typename LocalType, typename Input, typename ReadDest>
decltype(auto) CustomReadField(TypeList<LocalType>, Priority<1>, InvokeContext& invoke_context, Input&& input, ReadDest&& read_dest)
requires Unserializable<LocalType, DataStream> && (!ipc::capnp::Deserializable<LocalType>)
{
return read_dest.update([&](auto& value) {
// ...
SpanReader stream({data.begin(), data.end()});
auto wrapper{ipc::capnp::Wrap(stream)};
value.Unserialize(wrapper);
});
}
CScript::Unserialize expects Bitcoin's serialization format, a CompactSize length followed by the bytes, so it reads the script's first byte as the length. A length past the end of the buffer throws SpanReader::read(): end of data, and a shorter one silently produces a different, truncated script.
The fix (7d59b8d14c86) moves the build overload into libmultiprocess as include/mp/type-data.h, limited to types that can also be constructed from a byte range, and adds the matching reader (concept definitions and the unchanged build overload omitted):
+template <typename LocalType, typename Input, typename ReadDest>
+decltype(auto) CustomReadField(TypeList<LocalType>, Priority<2>, InvokeContext& invoke_context, Input&& input, ReadDest&& read_dest)
+requires (std::is_same_v<decltype(input.get()), ::capnp::Data::Reader> && IsByteSpan<LocalType>)
+{
+ using ByteType = decltype(std::span{std::declval<LocalType>().begin(), std::declval<LocalType>().end()})::element_type;
+ const kj::byte *begin{input.get().begin()}, *end{input.get().end()};
+ return read_dest.construct(reinterpret_cast<const ByteType*>(begin), reinterpret_cast<const ByteType*>(end));
+}
Both overloads now have the same IsByteSpan<LocalType> constraint and Priority<2>, so any type written as raw bytes is also read back as raw bytes, and the Priority<1> Unserialize reader is never chosen for it.
Fix
The fix commit 7d59b8d14c86, part of mp#135 ("refactor: proxy-types.h API cleanup"), moves the overload into libmultiprocess as include/mp/type-data.h. It restricts the Data build overload to types that are both convertible to and constructible from a byte span, and adds a matching Priority<2> CustomReadField that constructs the value from the raw bytes, so both sides always use the same encoding. bitcoin#31740, the update that brought the fix into Bitcoin Core, adds a passScript test case to src/test/ipc_test.cpp.
@maflcko argued that mismatched build and read overloads should fail to compile rather than be fixed one type at a time; ryanofsky answered that the problem came from this one unpaired overload, and pairing it is what landed.
Timeline
- 2024-09-25: the unpaired span
CustomBuildFieldreaches Bitcoin Core master in 06882f84017f ("multiprocess: Add serialization code for vector"), merged as part of bitcoin#30510 ("multiprocess: Add IPC wrapper for Mining interface"). - 2024-12-02: mp#122 filed by @Sjors; reproduced by @maflcko.
- 2025-01-27: fix merged in mp#135, closing mp#122.
- 2025-01-29: shipped to Bitcoin Core in bitcoin#31740 ("depends: Update libmultiprocess library before converting to subtree") with the regression test.
- Releases: none affected.
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: An encoder without its decoder
- Prevented by a different API shape, the type system
- Earliest detector a unit test (did not exist then)
- Diagnosis easy
- Fix design moderate
- Debugging aids a reproduction script, sanitizer output, symbolized stack traces
- Rust partly
Would Rust have prevented it? Partly. Rust has no priority-ranked overloads, so a blanket impl for Unserialize types and a byte-span impl for the same type would be a coherence error rather than a silent fallback to the wrong decoder, but nothing makes the author write the read side when adding the build side. More on Rust
Could it have been prevented?
Yes. The encoding of a type over the wire is one decision that two functions implement,
and the byte-span CustomBuildField was added without its CustomReadField; its own
comment said so, and for the types it was first used on the fallback Unserialize
reader happened to agree. The API shape that prevents this is the one the fix
(7d59b8d14c86)
adopts: build and read overloads defined together, in one header, with the same
constraint and the same priority, so a type cannot match one without matching the
other. maflcko's stronger form, a compile-time failure whenever the overloads chosen
for a type differ, was
discussed
and not built; the pairing rule was applied to the one overload that lacked it. This
was knowable when the overload was written in 2017, and the comment shows it was
known, just judged harmless for the types then in use.
Could it have been found sooner?
Yes, by a round-trip test of every type an interface sends. ipc_test.cpp had
round-trip cases since 2021 but none for CScript, and no merged code called
createNewBlock with a script over IPC, so the first CScript on the wire came from
Sjors's template provider three months after the Mining interface landed.
bitcoin#31740 added the passScript
case with the fix. The general check, "every type named in the .capnp schema has a
round-trip test", is mechanical and would have caught it on the day the Mining
interface was wrapped.
What made it hard to debug?
Nothing. maflcko reduced it to a one-line createNewBlock({}) in the bitcoin-mine
draft and posted a
valgrind trace
ending in CScript::Unserialize under CustomReadField, and ryanofsky
named the unpaired overload
within the hour. The eight weeks to merge were spent on the design question (pair
the overload, or make mismatches fail to compile) and on landing it inside the
proxy-types.h API cleanup rather than as a standalone fix.
Lesson
Serialization is a pair of functions, and adding one without the other is a bug that waits for the first type whose fallback decoder disagrees; define both sides together under one constraint, and round-trip every type an interface carries.
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 | branch-build |
| Scope | production |
| Trigger | Normal use (ordinary-path) |
| Phase | steady-state |
| Side | server |
| Component | type-data |
| Root cause kind | assumption-violated |
| Fix kind | new-code-path |
| Detection | manual-test |
| Needs a peer that is not libmultiprocess C++ | no |
| Needed a sanitizer to see | no |
| Defect ever on master | yes |
| Released | no |
| Confidence | medium |