diff --git a/consensus/engine/rejected_block.go b/consensus/engine/rejected_block.go new file mode 100644 index 0000000000..bb745ac4a6 --- /dev/null +++ b/consensus/engine/rejected_block.go @@ -0,0 +1,30 @@ +package engine + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// ErrRejectedBlock is returned for a block that consensus must never accept. +var ErrRejectedBlock = errors.New("block rejected by hash") + +var rejectedBlockHashes = map[common.Hash]struct{}{ + // Shard 0 retains block 92,730,034. Reject its original child so a dirty + // rolled-back database cannot reattach the abandoned branch. + common.HexToHash("0x5de06979a333f20afb8b245a8cf44472dc5bfc7383a57ddee48e1809bcee7c5d"): {}, + // Keep the first confirmed malicious shard-0 block rejected as defense in + // depth, including for embedded block references. + common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"): {}, + common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"): {}, +} + +// ValidateBlockHash rejects an abandoned chain anchor. Descendants cannot +// attach once their anchor is rejected. +func ValidateBlockHash(hash common.Hash) error { + if _, rejected := rejectedBlockHashes[hash]; rejected { + return fmt.Errorf("%w: %s", ErrRejectedBlock, hash.Hex()) + } + return nil +} diff --git a/consensus/engine/rejected_block_test.go b/consensus/engine/rejected_block_test.go new file mode 100644 index 0000000000..9c37413538 --- /dev/null +++ b/consensus/engine/rejected_block_test.go @@ -0,0 +1,42 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestValidateBlockHashRejectsAbandonedChainAnchors(t *testing.T) { + tests := []struct { + name string + hash common.Hash + want error + }{ + { + name: "reject shard 0 first abandoned child at block 92730035", + hash: common.HexToHash("0x5de06979a333f20afb8b245a8cf44472dc5bfc7383a57ddee48e1809bcee7c5d"), + want: ErrRejectedBlock, + }, + { + name: "reject shard 0 first confirmed malicious block at 92730036", + hash: common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"), + want: ErrRejectedBlock, + }, + { + name: "reject shard 1 abandoned chain anchor", + hash: common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"), + want: ErrRejectedBlock, + }, + {name: "allow replacement hash", hash: common.HexToHash("0x01")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateBlockHash(test.hash) + if !errors.Is(err, test.want) { + t.Fatalf("ValidateBlockHash(%s) error = %v, want %v", test.hash.Hex(), err, test.want) + } + }) + } +} diff --git a/consensus/rejected_block_test.go b/consensus/rejected_block_test.go new file mode 100644 index 0000000000..a7fe4123ea --- /dev/null +++ b/consensus/rejected_block_test.go @@ -0,0 +1,21 @@ +package consensus + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + consensusengine "github.com/harmony-one/harmony/consensus/engine" +) + +func TestValidateNewBlockRejectsAbandonedChainAnchorBeforeVerifiedCache(t *testing.T) { + hash := common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8") + log := NewFBFTLog() + log.verifiedBlocks[hash] = struct{}{} + consensus := &Consensus{fBFTLog: log} + + _, err := consensus.validateNewBlock(&FBFTMessage{BlockHash: hash}) + if !errors.Is(err, consensusengine.ErrRejectedBlock) { + t.Fatalf("validateNewBlock() error = %v, want %v", err, consensusengine.ErrRejectedBlock) + } +} diff --git a/consensus/validator.go b/consensus/validator.go index 874edb0ef8..40f96981c2 100644 --- a/consensus/validator.go +++ b/consensus/validator.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/rlp" msg_pb "github.com/harmony-one/harmony/api/proto/message" + consensusengine "github.com/harmony-one/harmony/consensus/engine" "github.com/harmony-one/harmony/consensus/signature" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" @@ -26,6 +27,16 @@ func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { Msg("[OnAnnounce] Unparseable leader message") return } + // Reject an abandoned block before it can enter the FBFT log or cause this + // validator to sign PREPARE. validateNewBlock repeats this check for every + // other entry point. + if err := consensusengine.ValidateBlockHash(recvMsg.BlockHash); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Str("MsgBlockHash", recvMsg.BlockHash.Hex()). + Msg("[OnAnnounce] Rejected block") + return + } // NOTE let it handle its own logs if !consensus.onAnnounceSanityChecks(recvMsg) { @@ -86,6 +97,9 @@ func (consensus *Consensus) ValidateNewBlock(recvMsg *FBFTMessage) (*types.Block return consensus.validateNewBlock(recvMsg) } func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block, error) { + if err := consensusengine.ValidateBlockHash(recvMsg.BlockHash); err != nil { + return nil, err + } if consensus.fBFTLog.IsBlockVerified(recvMsg.BlockHash) { var blockObj *types.Block diff --git a/core/block_validator.go b/core/block_validator.go index cfe6c118eb..c4dd2565f8 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -54,6 +54,9 @@ func NewBlockValidator(blockchain BlockChain) *BlockValidator { // ValidateBody verifies the block header's transaction root. // The headers are assumed to be already validated at this point. func (v *BlockValidator) ValidateBody(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } // Check whether the block's known, and if not, that it's linkable if v.bc.HasBlockAndState(block.Hash(), block.NumberU64()) { return errors.WithMessage(ErrKnownBlock, "validate body: has block and state") diff --git a/core/blockchain_impl.go b/core/blockchain_impl.go index 8a93304418..c7b7fbccbd 100644 --- a/core/blockchain_impl.go +++ b/core/blockchain_impl.go @@ -951,6 +951,9 @@ func (bc *BlockChainImpl) ExportN(w io.Writer, first uint64, last uint64) error } func (bc *BlockChainImpl) WriteHeadBlock(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } return bc.writeHeadBlock(block) } @@ -1008,6 +1011,9 @@ func (bc *BlockChainImpl) writeHeadBlock(block *types.Block) error { // tikvFastForward writes a new head block in tikv mode, used for reader node or follower writer node func (bc *BlockChainImpl) tikvFastForward(block *types.Block, logs []*types.Log) error { + if err := validateBlockHashes(block); err != nil { + return err + } bc.currentBlock.Store(block) headBlockGauge.Update(int64(block.NumberU64())) @@ -1439,6 +1445,9 @@ func (bc *BlockChainImpl) InsertReceiptChain(blockChain types.Blocks, receiptCha batch = bc.db.NewBatch() ) for i, block := range blockChain { + if err := validateBlockHashes(block); err != nil { + return i, err + } receipts := receiptChain[i] // Short circuit insertion if shutting down or processing failed if atomic.LoadInt32(&bc.procInterrupt) == 1 { @@ -1523,6 +1532,9 @@ func (bc *BlockChainImpl) InsertReceiptChain(blockChain types.Blocks, receiptCha var lastWrite uint64 func (bc *BlockChainImpl) WriteBlockWithoutState(block *types.Block) (err error) { + if err := validateBlockHashes(block); err != nil { + return err + } bc.chainmu.Lock() defer bc.chainmu.Unlock() @@ -1540,6 +1552,9 @@ func (bc *BlockChainImpl) WriteBlockWithState( paid reward.Reader, state *state.DB, ) (status WriteStatus, err error) { + if err := validateBlockHashes(block); err != nil { + return NonStatTy, err + } currentBlock := bc.CurrentBlock() if currentBlock == nil { return NonStatTy, errors.New("Current block is nil") @@ -1688,6 +1703,12 @@ func (bc *BlockChainImpl) GetMaxGarbageCollectedBlockNumber() int64 { } func (bc *BlockChainImpl) InsertChain(chain types.Blocks, verifyHeaders bool) (int, error) { + for i, block := range chain { + if err := validateBlockHashes(block); err != nil { + return i, err + } + } + // if in tikv mode, writer node need preempt master or come be a follower if bc.isInitTiKV() && !bc.tikvPreemptMaster(bc.rangeBlock(chain)) { return len(chain), nil diff --git a/core/epochchain.go b/core/epochchain.go index 2dab284713..e2e3df6230 100644 --- a/core/epochchain.go +++ b/core/epochchain.go @@ -123,6 +123,9 @@ func (bc *EpochChain) InsertChain(blocks types.Blocks, _ bool) (int, error) { <-bc.mu }() for i, block := range blocks { + if err := validateBlockHashes(block); err != nil { + return i, err + } if !block.IsLastBlockInEpoch() { return i, ErrNotLastBlockInEpoch } @@ -282,6 +285,9 @@ func (bc *EpochChain) writeShardStateBytes(db rawdb.DatabaseWriter, // WriteHeadBlock writes a new head block. func (bc *EpochChain) WriteHeadBlock(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } batch := bc.db.NewBatch() se, err := bc.writeHeadBlock(batch, block) if err != nil { diff --git a/core/offchain.go b/core/offchain.go index a469477607..9470bc4228 100644 --- a/core/offchain.go +++ b/core/offchain.go @@ -31,6 +31,9 @@ func (bc *BlockChainImpl) CommitOffChainData( payout reward.Reader, state *state.DB, ) (status WriteStatus, err error) { + if err := validateBlockHashes(block); err != nil { + return NonStatTy, err + } // Write receipts of the block if err := rawdb.WriteReceipts(batch, block.Hash(), block.NumberU64(), receipts); err != nil { return NonStatTy, err diff --git a/core/rejected_block.go b/core/rejected_block.go new file mode 100644 index 0000000000..71268382f7 --- /dev/null +++ b/core/rejected_block.go @@ -0,0 +1,41 @@ +package core + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + blockv0 "github.com/harmony-one/harmony/block/v0" + consensus_engine "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +func validateBlockHashes(block *types.Block) error { + return validateBlockHashesWith(block, consensus_engine.ValidateBlockHash) +} + +func validateBlockHashesWith(block *types.Block, validateHash func(common.Hash) error) error { + if err := validateHash(block.Hash()); err != nil { + return err + } + + _, isV0 := block.Header().Header.(*blockv0.Header) + if !isV0 { + encoded := block.Header().CrossLinks() + var crossLinks types.CrossLinks + if len(encoded) > 0 && rlp.DecodeBytes(encoded, &crossLinks) == nil { + for i := range crossLinks { + if err := validateHash(crossLinks[i].Hash()); err != nil { + return err + } + } + } + } + + for _, proof := range block.IncomingReceipts() { + if proof != nil && proof.Header != nil { + if err := validateHash(proof.Header.Hash()); err != nil { + return err + } + } + } + return nil +} diff --git a/core/rejected_block_test.go b/core/rejected_block_test.go new file mode 100644 index 0000000000..f94492a404 --- /dev/null +++ b/core/rejected_block_test.go @@ -0,0 +1,76 @@ +package core + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +const rejectedShard1BlockHash = "0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878" + +func TestValidateBlockHashesRejectsEmbeddedCrossLink(t *testing.T) { + block := blockWithRejectedCrossLink(t) + + if err := validateBlockHashes(block); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("validateBlockHashes() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func TestWriteBlockWithoutStateRejectsEmbeddedCrossLinkBeforeDatabaseWrite(t *testing.T) { + block := blockWithRejectedCrossLink(t) + var chain *BlockChainImpl + + if err := chain.WriteBlockWithoutState(block); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("WriteBlockWithoutState() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func blockWithRejectedCrossLink(t *testing.T) *types.Block { + t.Helper() + crossLinks := types.CrossLinks{{ + ShardIDF: 1, + BlockNumberF: big.NewInt(94978279), + ViewIDF: new(big.Int), + HashF: common.HexToHash(rejectedShard1BlockHash), + EpochF: new(big.Int), + }} + encoded, err := rlp.EncodeToBytes(crossLinks) + if err != nil { + t.Fatal(err) + } + header := blockfactory.NewTestHeader().With().CrossLinks(encoded).Header() + return types.NewBlock(header, nil, nil, nil, nil, nil) +} + +func TestValidateBlockHashesRejectsIncomingReceiptSource(t *testing.T) { + rejectedHeader := blockfactory.NewTestHeader().With().Extra([]byte("rejected source header")).Header() + block := types.NewBlock( + blockfactory.NewTestHeader(), nil, nil, nil, + []*types.CXReceiptsProof{{Header: rejectedHeader}}, nil, + ) + + validateHash := func(hash common.Hash) error { + if hash == rejectedHeader.Hash() { + return engine.ErrRejectedBlock + } + return nil + } + if err := validateBlockHashesWith(block, validateHash); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("validateBlockHashes() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func TestValidateBlockHashesLeavesMalformedCrossLinksToSemanticValidation(t *testing.T) { + header := blockfactory.NewTestHeader().With().CrossLinks([]byte("not rlp")).Header() + block := types.NewBlock(header, nil, nil, nil, nil, nil) + + if err := validateBlockHashes(block); err != nil { + t.Fatalf("validateBlockHashes() error = %v, want nil", err) + } +} diff --git a/internal/chain/engine.go b/internal/chain/engine.go index 8b701a3dce..1c4c413e60 100644 --- a/internal/chain/engine.go +++ b/internal/chain/engine.go @@ -66,6 +66,9 @@ func NewEngine() *engineImpl { // VerifyHeader checks whether a header conforms to the consensus rules of the bft engine. // Note that each block header contains the bls signature of the parent block func (e *engineImpl) VerifyHeader(chain engine.ChainReader, header *block.Header, seal bool) error { + if err := engine.ValidateBlockHash(header.Hash()); err != nil { + return err + } parentHeader := chain.GetHeader(header.ParentHash(), header.Number().Uint64()-1) if parentHeader == nil { return engine.ErrUnknownAncestor @@ -636,6 +639,9 @@ func applySlashes( // i.e. this header verification api is more flexible since the caller specifies which commit signature and bitmap to use // for verifying the block header, which is necessary for cross-shard block header verification. Example of such is cross-shard transaction. func (e *engineImpl) VerifyHeaderSignature(chain engine.ChainReader, header *block.Header, commitSig bls_cosi.SerializedSignature, commitBitmap []byte) error { + if err := engine.ValidateBlockHash(header.Hash()); err != nil { + return err + } if chain.CurrentHeader().Number().Uint64() <= uint64(1) { return nil } @@ -647,6 +653,9 @@ func (e *engineImpl) VerifyHeaderSignature(chain engine.ChainReader, header *blo // VerifyCrossLink verifies the signature of the given CrossLink. func (e *engineImpl) VerifyCrossLink(chain engine.ChainReader, cl types.CrossLink) error { + if err := engine.ValidateBlockHash(cl.Hash()); err != nil { + return err + } if cl.BlockNum() <= 1 { return errors.New("crossLink BlockNumber should greater than 1") } diff --git a/internal/chain/rejected_block_test.go b/internal/chain/rejected_block_test.go new file mode 100644 index 0000000000..bf02ea96fc --- /dev/null +++ b/internal/chain/rejected_block_test.go @@ -0,0 +1,32 @@ +package chain + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +func TestVerifyCrossLinkRejectsAbandonedChainAnchors(t *testing.T) { + tests := []types.CrossLink{ + { + ShardIDF: 0, + BlockNumberF: big.NewInt(92730036), + HashF: common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"), + }, + { + ShardIDF: 1, + BlockNumberF: big.NewInt(94978279), + HashF: common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"), + }, + } + + for _, crossLink := range tests { + if err := NewEngine().VerifyCrossLink(nil, crossLink); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("VerifyCrossLink() error = %v, want %v", err, engine.ErrRejectedBlock) + } + } +}