What changed, and why it matters
This commit fixes a memory-safety bug in btcd's Bitcoin transaction decoder. When decoding a transaction failed partway through, the partially built transaction could still hold pointers to a shared memory area (an 'arena') used for scripts. That arena would then be reused for later decodes, meaning stale or attacker-controlled data could leak between unrelated transactions. The fix clears the transaction's inputs and outputs on any public decode error before returning the arena to the pool. The commit also adds tests covering every truncated prefix and both buffered and streaming readers.
Treat this as a security-relevant hardening fix. Review any code that reuses a MsgTx receiver after BtcDecode errors, and upgrade to a release containing this commit. No immediate remote exploit is demonstrated, but the stale-arena pattern is a real memory-safety risk in a network-facing parser.
Security signals we found
Use-after-free-like pattern: staged slices referencing a reusable arena after decode failure
Shared memory pool (script arena) reused across decodes
Potential information disclosure or memory corruption if stale arena-backed scripts are accessed
Fix is defensive cleanup on error path, not a change to successful decode behavior
Comprehensive regression tests for truncated prefixes and both buffered/streaming readers
Evidence from the diff
In wire/msgtx.go, MsgTx.BtcDecode now nils msg.TxIn and msg.TxOut when msg.btcDecode returns an error, before the borrowed script arena (ar) is released. This prevents staged script slices from outliving the arena they point into. The arena comment in wire/arena.go is updated to note that failed public transaction decodes clear inputs/outputs, while block decoding discards the failed transaction. Tests are added/updated: TestTruncatedTxDecodeBudget now calls the internal btcDecode directly so public cleanup cannot mask an oversized arena reservation, and TestTxDecodeErrorClearsScripts verifies that every truncated prefix of base and witness encodings leaves TxIn/TxOut nil and that the same receiver can be successfully reused afterward.
Changed components
wire/msgtx.go - MsgTx.BtcDecode public decode pathwire/arena.go - script arena ownership documentationwire/decode_budget_test.go - truncated transaction decode budget testwire/msgtx_test.go - new TestTxDecodeErrorClearsScriptsInspect captured patch +76 / −18
### wire/arena.go
@@ -14,9 +14,9 @@ import (
// decoded from the wire. Once a transaction is fully decoded, all of its
// scripts are copied into a single exactly-sized allocation before the arena
// is reused. A successful decode therefore leaves no arena-backed scripts
-// in the transaction. A failed decode may leave staged scripts in the
-// partially populated transaction; those scripts must not be accessed after
-// the call returns. This ownership contract makes arena reuse safe without
+// in the transaction. A failed public transaction decode clears its inputs
+// and outputs before releasing the arena, while block decoding discards the
+// failed transaction. This ownership contract makes arena reuse safe without
// reference counting.
//
// The arena replaces the previous fixed 4 MiB script slab. The slab approach
### wire/decode_budget_test.go
@@ -196,12 +196,25 @@ func TestTruncatedVectorDecodeBudget(t *testing.T) {
// TestTruncatedTxDecodeBudget covers each count-driven transaction slice.
func TestTruncatedTxDecodeBudget(t *testing.T) {
- t.Run("inputs", func(t *testing.T) {
- payload := countPayload(t, make([]byte, 4), maxTxInPerMessage)
- var msg MsgTx
- err := msg.BtcDecode(
+ // Inspect the internal decoder before BtcDecode clears failed results,
+ // so public error cleanup cannot hide an oversized reservation.
+ decode := func(t *testing.T, payload []byte) (*MsgTx, error) {
+ t.Helper()
+ ar := borrowScriptArena()
+ t.Cleanup(ar.release)
+ buf := binarySerializer.Borrow()
+ t.Cleanup(func() { binarySerializer.Return(buf) })
+ msg := new(MsgTx)
+ err := msg.btcDecode(
bytes.NewReader(payload), ProtocolVersion, WitnessEncoding,
+ buf, ar,
)
+ return msg, err
+ }
+
+ t.Run("inputs", func(t *testing.T) {
+ payload := countPayload(t, make([]byte, 4), maxTxInPerMessage)
+ msg, err := decode(t, payload)
if err == nil {
t.Fatal("expected a truncated input error")
}
@@ -216,10 +229,7 @@ func TestTruncatedTxDecodeBudget(t *testing.T) {
t.Run("outputs", func(t *testing.T) {
payload := countPayload(t, inputPrefix, maxTxOutPerMessage)
- var msg MsgTx
- err := msg.BtcDecode(
- bytes.NewReader(payload), ProtocolVersion, WitnessEncoding,
- )
+ msg, err := decode(t, payload)
if err == nil {
t.Fatal("expected a truncated output error")
}
@@ -237,10 +247,7 @@ func TestTruncatedTxDecodeBudget(t *testing.T) {
t, witnessPrefix, maxWitnessItemsPerInput,
)
- var msg MsgTx
- err := msg.BtcDecode(
- bytes.NewReader(payload), ProtocolVersion, WitnessEncoding,
- )
+ msg, err := decode(t, payload)
if err == nil {
t.Fatal("expected a truncated witness error")
}
### wire/msgtx.go
@@ -414,9 +414,9 @@ func (msg *MsgTx) Copy() *MsgTx {
// This is part of the Message interface implementation.
// See Deserialize for decoding transactions stored to disk, such as in a
// database, as opposed to decoding transactions from the wire.
-// If decoding fails, the receiver may be partially populated and its script
-// slices may reference recycled memory. The receiver's contents must not be
-// used unless a subsequent decode succeeds.
+// If decoding fails, TxIn and TxOut are cleared. Other fields may be partially
+// populated, so the receiver's contents must not be used unless a subsequent
+// decode succeeds.
func (msg *MsgTx) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) error {
buf := binarySerializer.Borrow()
defer binarySerializer.Return(buf)
@@ -425,6 +425,11 @@ func (msg *MsgTx) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) error
defer ar.release()
err := msg.btcDecode(r, pver, enc, buf, ar)
+ if err != nil {
+ // Drop staged scripts before their arena is returned to the pool.
+ msg.TxIn = nil
+ msg.TxOut = nil
+ }
return err
}
### wire/msgtx_test.go
@@ -402,6 +402,52 @@ func TestTxWire(t *testing.T) {
}
}
+// TestTxDecodeErrorClearsScripts checks that failed public decodes drop all
+// script references and allow a later successful decode into the same receiver.
+func TestTxDecodeErrorClearsScripts(t *testing.T) {
+ for _, enc := range []MessageEncoding{BaseEncoding, WitnessEncoding} {
+ for _, buffered := range []bool{false, true} {
+ name := fmt.Sprintf("encoding=%d/buffered=%t", enc, buffered)
+ t.Run(name, func(t *testing.T) {
+ tx := NewMsgTx(2)
+ var witness TxWitness
+ if enc == WitnessEncoding {
+ witness = TxWitness{{0x01, 0x02}, {0x03}}
+ }
+ tx.AddTxIn(NewTxIn(
+ &OutPoint{}, []byte{0x04, 0x05}, witness,
+ ))
+ tx.AddTxOut(NewTxOut(1, []byte{0x06, 0x07}))
+ tx.LockTime = 123
+
+ var serialized bytes.Buffer
+ require.NoError(t, tx.BtcEncode(&serialized, 0, enc))
+ data := serialized.Bytes()
+ reader := func(data []byte) io.Reader {
+ r := bytes.NewReader(data)
+ if buffered {
+ return r
+ }
+ return struct{ io.Reader }{r}
+ }
+
+ // Include early errors on a populated receiver as well
+ // as errors after scripts and witnesses have been staged.
+ for end := 0; end < len(data); end++ {
+ decoded := tx.Copy()
+ err := decoded.BtcDecode(reader(data[:end]), 0, enc)
+ require.Error(t, err, "prefix length %d", end)
+ require.Nil(t, decoded.TxIn, "prefix length %d", end)
+ require.Nil(t, decoded.TxOut, "prefix length %d", end)
+
+ require.NoError(t, decoded.BtcDecode(reader(data), 0, enc))
+ require.Equal(t, tx, decoded)
+ }
+ })
+ }
+ }
+}
+
// TestTxWireErrors performs negative tests against wire encode and decode
// of MsgTx to confirm error paths work correctly.
func TestTxWireErrors(t *testing.T) {Why this scored 61/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.