multi: parse own-DB blocks leniently in getblock and indexer init
What changed, and why it matters
This commit fixes two related bugs in btcd, a Bitcoin node implementation. First, it prevents the getblock RPC from using database memory after it has expired, which could previously cause crashes or corrupted block data. Second, it makes block reading from the node's own database more forgiving of old trailing garbage bytes, so historical blocks written by older btcd versions remain readable. The change is defensive maintenance rather than an active exploit fix, and it includes new tests to verify the behavior.
Review and merge if not already deployed. Operators running btcd with legacy databases should upgrade to ensure getblock and indexer initialization remain reliable. No immediate emergency response is indicated, but the use-after-free-like pattern in getblock is worth backporting to stable branches.
Security signals we found
Use of transaction-scoped database buffer after view lifetime (getblock RPC)
Lenient parsing of own-DB blocks to avoid permanent unreadability of legacy data
Addition of regression test for buffer invalidation after DB.View
Strict parsing still enforced for external sources (p2p, RPC) per code comment
Evidence from the diff
The patch introduces/relocates DBBlockFromBytes, a lenient block deserializer that tolerates trailing bytes in locally-stored blocks and strips them before caching the serialization. It applies this parser in two places: (1) the getblock RPC handler, which now copies FetchBlock bytes out of the database view before parsing, fixing a use-after-free-like bug where zero-copy DB buffers become invalid after the transaction ends; and (2) the indexer manager during index tip rollback. The log level for trailing bytes is reduced from Warn to Debug to avoid log flooding. A regression test models a database that invalidates its buffer when the view returns, confirming the copy is performed.
Changed components
rpcserver.go handleGetBlockblockchain/chainio.go DBBlockFromBytesblockchain/indexers/manager.go Init index tip rollbackrpcserver_test.go regression testsInspect captured patch +113 / −40
diff --git a/blockchain/chainio.go b/blockchain/chainio.go
index 9d183a7..be5c9a2 100644
--- a/blockchain/chainio.go
+++ b/blockchain/chainio.go
@@ -1168,6 +1168,35 @@ func (b *BlockChain) createChainState() error {
return err
}
+// DBBlockFromBytes deserializes a block fetched from the local database,
+// tolerating trailing bytes rather than rejecting them outright. Databases
+// written by older btcd versions may have persisted blocks with trailing
+// bytes, and failing here would make such blocks permanently unreadable.
+// Instead, any trailing bytes are logged, ignored, and excluded from the
+// serialization cached in the returned block.
+//
+// This lenient parsing is only appropriate for blocks read back from the
+// node's own database. Blocks from external sources (p2p, RPC) should be
+// parsed with the strict btcutil.NewBlockFromBytes instead.
+func DBBlockFromBytes(blockBytes []byte, hash chainhash.Hash) (*btcutil.Block,
+ error) {
+
+ blockReader := bytes.NewReader(blockBytes)
+ var msgBlock wire.MsgBlock
+ if err := msgBlock.Deserialize(blockReader); err != nil {
+ return nil, err
+ }
+ if trailing := blockReader.Len(); trailing > 0 {
+ log.Debugf("Block %v has %d trailing bytes in the database; "+
+ "ignoring them", hash, trailing)
+ blockBytes = blockBytes[:len(blockBytes)-trailing]
+ }
+
+ // Cache the exact serialization on the block so downstream consumers
+ // of the raw bytes never observe the trailing bytes.
+ return btcutil.NewBlockFromBlockAndBytes(&msgBlock, blockBytes), nil
+}
+
// initChainState attempts to load and initialize the chain state from the
// database. When the db does not yet contain any chain state, both it and the
// chain state are initialized to the genesis block.
@@ -1374,35 +1403,6 @@ func dbFetchHeaderByHeight(dbTx database.Tx, height int32) (*wire.BlockHeader, e
return dbFetchHeaderByHash(dbTx, hash)
}
-// DBBlockFromBytes deserializes a block fetched from the local database,
-// tolerating trailing bytes rather than rejecting them outright. Databases
-// written by older btcd versions may have persisted blocks with trailing
-// bytes, and failing here would make such blocks permanently unreadable.
-// Instead, any trailing bytes are logged, ignored, and excluded from the
-// serialization cached in the returned block.
-//
-// This lenient parsing is only appropriate for blocks read back from the
-// node's own database. Blocks from external sources (p2p, RPC) should be
-// parsed with the strict btcutil.NewBlockFromBytes instead.
-func DBBlockFromBytes(blockBytes []byte, hash chainhash.Hash) (*btcutil.Block,
- error) {
-
- blockReader := bytes.NewReader(blockBytes)
- var msgBlock wire.MsgBlock
- if err := msgBlock.Deserialize(blockReader); err != nil {
- return nil, err
- }
- if trailing := blockReader.Len(); trailing > 0 {
- log.Warnf("Block %v has %d trailing bytes in the database; "+
- "ignoring them", hash, trailing)
- blockBytes = blockBytes[:len(blockBytes)-trailing]
- }
-
- // Cache the exact serialization on the block so downstream consumers
- // of the raw bytes never observe the trailing bytes.
- return btcutil.NewBlockFromBlockAndBytes(&msgBlock, blockBytes), nil
-}
-
// dbFetchBlockByNode uses an existing database transaction to retrieve the
// raw block for the provided node, deserialize it, and return a btcutil.Block
// with the height set.
diff --git a/blockchain/indexers/manager.go b/blockchain/indexers/manager.go
index 28f608f..533f007 100644
--- a/blockchain/indexers/manager.go
+++ b/blockchain/indexers/manager.go
@@ -309,7 +309,9 @@ func (m *Manager) Init(chain *blockchain.BlockChain, interrupt <-chan struct{})
if err != nil {
return err
}
- block, err = btcutil.NewBlockFromBytes(blockBytes)
+ block, err = blockchain.DBBlockFromBytes(
+ blockBytes, *hash,
+ )
if err != nil {
return err
}
diff --git a/rpcserver.go b/rpcserver.go
index fb5f066..dcc97fc 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -1082,9 +1082,17 @@ func handleGetBlock(s *rpcServer, cmd interface{}, closeChan <-chan struct{}) (i
}
var blkBytes []byte
err = s.cfg.DB.View(func(dbTx database.Tx) error {
- var err error
- blkBytes, err = dbTx.FetchBlock(hash)
- return err
+ dbBlockBytes, err := dbTx.FetchBlock(hash)
+ if err != nil {
+ return err
+ }
+
+ // FetchBlock bytes are only valid for the lifetime of the
+ // transaction. Copy them before returning from the view so the
+ // lenient parser below owns its backing memory.
+ blkBytes = append([]byte(nil), dbBlockBytes...)
+
+ return nil
})
if err != nil {
return nil, &btcjson.RPCError{
@@ -1092,6 +1100,21 @@ func handleGetBlock(s *rpcServer, cmd interface{}, closeChan <-chan struct{}) (i
Message: "Block not found",
}
}
+ // Deserialize the block. Blocks read back from the node's own
+ // database are parsed leniently: trailing bytes persisted by older
+ // btcd versions are stripped rather than treated as an error, so the
+ // bytes served below are always the exact block serialization.
+ blk, err := blockchain.DBBlockFromBytes(blkBytes, *hash)
+ if err != nil {
+ context := "Failed to deserialize block"
+ return nil, internalRPCError(err.Error(), context)
+ }
+ blkBytes, err = blk.Bytes()
+ if err != nil {
+ context := "Failed to serialize block"
+ return nil, internalRPCError(err.Error(), context)
+ }
+
// If verbosity is 0, return the serialized block as a hex encoded string.
if c.Verbosity != nil && *c.Verbosity == 0 {
return hex.EncodeToString(blkBytes), nil
@@ -1099,13 +1122,6 @@ func handleGetBlock(s *rpcServer, cmd interface{}, closeChan <-chan struct{}) (i
// Otherwise, generate the JSON object and return it.
- // Deserialize the block.
- blk, err := btcutil.NewBlockFromBytes(blkBytes)
- if err != nil {
- context := "Failed to deserialize block"
- return nil, internalRPCError(err.Error(), context)
- }
-
// Get the block height from chain.
blockHeight, err := s.cfg.Chain.BlockHeightByHash(hash)
if err != nil {
diff --git a/rpcserver_test.go b/rpcserver_test.go
index e462564..d3e9da0 100644
--- a/rpcserver_test.go
+++ b/rpcserver_test.go
@@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/btcsuite/btcd/chainhash/v2"
+ "github.com/btcsuite/btcd/database"
"github.com/btcsuite/btcd/mempool"
"github.com/btcsuite/btcd/wire/v2"
"github.com/stretchr/testify/mock"
@@ -111,6 +112,60 @@ func blockHexWithTrailingByte(t *testing.T) string {
return hex.EncodeToString(append(block.Bytes(), 0x00))
}
+// invalidatingBlockDB clears fetched block bytes as soon as its managed view
+// ends. This models database backends whose zero-copy buffers are only valid
+// for the lifetime of a transaction.
+type invalidatingBlockDB struct {
+ database.DB
+ blockBytes []byte
+}
+
+// View runs the callback with a transaction backed by the configured block
+// bytes, then invalidates those bytes before returning.
+func (d *invalidatingBlockDB) View(fn func(database.Tx) error) error {
+ err := fn(&invalidatingBlockTx{blockBytes: d.blockBytes})
+ clear(d.blockBytes)
+
+ return err
+}
+
+// invalidatingBlockTx returns the parent database's transaction-scoped block
+// bytes.
+type invalidatingBlockTx struct {
+ database.Tx
+ blockBytes []byte
+}
+
+// FetchBlock returns bytes that are invalidated when the enclosing view ends.
+func (t *invalidatingBlockTx) FetchBlock(*chainhash.Hash) ([]byte, error) {
+ return t.blockBytes, nil
+}
+
+// TestHandleGetBlockCopiesTransactionBytes verifies getblock does not retain
+// transaction-scoped database memory after its managed view ends.
+func TestHandleGetBlockCopiesTransactionBytes(t *testing.T) {
+ t.Parallel()
+
+ var serializedBlock bytes.Buffer
+ err := chaincfg.MainNetParams.GenesisBlock.Serialize(&serializedBlock)
+ require.NoError(t, err)
+
+ wantBytes := serializedBlock.Bytes()
+ dbBytes := append([]byte(nil), wantBytes...)
+ dbBytes = append(dbBytes, 0x00)
+ db := &invalidatingBlockDB{blockBytes: dbBytes}
+
+ verbosity := 0
+ cmd := btcjson.NewGetBlockCmd(
+ chaincfg.MainNetParams.GenesisHash.String(), &verbosity,
+ )
+ result, err := handleGetBlock(
+ &rpcServer{cfg: rpcserverConfig{DB: db}}, cmd, make(chan struct{}),
+ )
+ require.NoError(t, err)
+ require.Equal(t, hex.EncodeToString(wantBytes), result)
+}
+
// TestHandleSendRawTransactionRejectsTrailingBytes ensures sendrawtransaction
// rejects byte strings that contain a valid transaction plus trailing data.
func TestHandleSendRawTransactionRejectsTrailingBytes(t *testing.T) {
Why this scored 31/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.