diff --git a/discovery/syncer.go b/discovery/syncer.go index 37f670545d1..33f88882e54 100644 --- a/discovery/syncer.go +++ b/discovery/syncer.go @@ -587,7 +587,7 @@ func (g *GossipSyncer) channelGraphSyncer(ctx context.Context) { if err != nil { log.Errorf("Unable to "+ "process chan range "+ - "query: %v", err) + "reply: %v", err) return } continue @@ -647,9 +647,18 @@ func (g *GossipSyncer) channelGraphSyncer(ctx context.Context) { // If this is the final reply to one of our // queries, then we'll loop back into our query // state to send of the remaining query chunks. - _, ok := msg.(*lnwire.ReplyShortChanIDsEnd) + reply, ok := msg.(*lnwire.ReplyShortChanIDsEnd) if ok { - g.setSyncState(queryNewChannels) + err := g.processScidEndReply( + ctx, reply, + ) + if err != nil { + log.Errorf("Unable to "+ + "process short chan "+ + "id end reply: %v", err) + + return + } continue } @@ -919,6 +928,14 @@ func isLegacyReplyChannelRange(query *lnwire.QueryChannelRange, func (g *GossipSyncer) processChanRangeReply(_ context.Context, msg *lnwire.ReplyChannelRange) error { + // If the peer sent us a reply with a different chain hash, then we are + // not going to process the message any further. + if g.cfg.chainHash != msg.ChainHash { + return fmt.Errorf("reply includes channels for "+ + "chain=%v, we're on chain=%v", msg.ChainHash, + g.cfg.chainHash) + } + // isStale returns whether the timestamp is too far into the past. isStale := func(timestamp time.Time) bool { return time.Since(timestamp) > graph.DefaultChannelPruneExpiry @@ -1103,6 +1120,25 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context, return nil } +// processScidEndReply is called when the GossipSyncer receives the final reply +// to one of our query SCIDs requests, after which we transition back into the +// query state. +func (g *GossipSyncer) processScidEndReply(_ context.Context, + msg *lnwire.ReplyShortChanIDsEnd) error { + + // If the peer sent us a reply with a different chain hash, then we are + // not going to process the message any further. + if g.cfg.chainHash != msg.ChainHash { + return fmt.Errorf("reply includes channels for "+ + "chain=%v, we're on chain=%v", msg.ChainHash, + g.cfg.chainHash) + } + + g.setSyncState(queryNewChannels) + + return nil +} + // genChanRangeQuery generates the initial message we'll send to the remote // party when we're kicking off the channel graph synchronization upon // connection. The historicalQuery boolean can be used to generate a query from @@ -1433,6 +1469,13 @@ func (g *GossipSyncer) replyShortChanIDs(ctx context.Context, func (g *GossipSyncer) ApplyGossipFilter(ctx context.Context, filter *lnwire.GossipTimestampRange) error { + // If the peer sent us a gossip with a different chain hash, then we are + // not going to process the message any further. + if g.cfg.chainHash != filter.ChainHash { + return fmt.Errorf("gossip filter specifies chain=%v, we're on "+ + "chain=%v", filter.ChainHash, g.cfg.chainHash) + } + g.Lock() g.remoteUpdateHorizon = filter diff --git a/discovery/syncer_atomic_test.go b/discovery/syncer_atomic_test.go index ea1a6088631..47d1fa3b188 100644 --- a/discovery/syncer_atomic_test.go +++ b/discovery/syncer_atomic_test.go @@ -90,6 +90,7 @@ func TestGossipSyncerSingleBacklogSend(t *testing.T) { // Now we'll create a filter, then apply it in a goroutine. filter := &lnwire.GossipTimestampRange{ + ChainHash: syncer.cfg.chainHash, FirstTimestamp: uint32(time.Now().Unix() - 3600), TimestampRange: 7200, } diff --git a/discovery/syncer_queue_test.go b/discovery/syncer_queue_test.go index 704328cd1ea..e7d1eabca9f 100644 --- a/discovery/syncer_queue_test.go +++ b/discovery/syncer_queue_test.go @@ -380,6 +380,7 @@ func TestGossipSyncerQueueOrder(t *testing.T) { orderMu.Lock() processedRanges = append( processedRanges, &lnwire.GossipTimestampRange{ + ChainHash: syncer.cfg.chainHash, FirstTimestamp: uint32( req.start.Unix(), ), @@ -408,7 +409,7 @@ func TestGossipSyncerQueueOrder(t *testing.T) { var queuedMessages []*lnwire.GossipTimestampRange for i := 0; i < numMessages; i++ { msg := &lnwire.GossipTimestampRange{ - ChainHash: chainhash.Hash{}, + ChainHash: syncer.cfg.chainHash, FirstTimestamp: uint32(1000 + i*100), TimestampRange: 3600, } diff --git a/discovery/syncer_test.go b/discovery/syncer_test.go index 2313d1c1d5e..0151ffba49e 100644 --- a/discovery/syncer_test.go +++ b/discovery/syncer_test.go @@ -204,6 +204,7 @@ func newTestSyncer(hID lnwire.ShortChannelID, msgChan := make(chan []lnwire.Message, 20) cfg := gossipSyncerCfg{ + chainHash: *chaincfg.MainNetParams.GenesisHash, channelSeries: newMockChannelGraphTimeSeries(hID), encodingType: encodingType, chunkSize: chunkSize, @@ -555,6 +556,7 @@ func TestGossipSyncerApplyNoHistoricalGossipFilter(t *testing.T) { // We'll apply this gossip horizon for the remote peer. remoteHorizon := &lnwire.GossipTimestampRange{ + ChainHash: syncer.cfg.chainHash, FirstTimestamp: unixStamp(25000), TimestampRange: uint32(1000), } @@ -615,6 +617,7 @@ func TestGossipSyncerApplyGossipFilter(t *testing.T) { // We'll apply this gossip horizon for the remote peer. remoteHorizon := &lnwire.GossipTimestampRange{ + ChainHash: syncer.cfg.chainHash, FirstTimestamp: unixStamp(25000), TimestampRange: uint32(1000), } @@ -844,9 +847,11 @@ func TestGossipSyncerReplyShortChanIDs(t *testing.T) { queryReply := []lnwire.Message{ &lnwire.ChannelAnnouncement1{ + ChainHash: syncer.cfg.chainHash, ShortChannelID: lnwire.NewShortChanIDFromInt(20), }, &lnwire.ChannelUpdate1{ + ChainHash: syncer.cfg.chainHash, ShortChannelID: lnwire.NewShortChanIDFromInt(20), Timestamp: unixStamp(999999), }, @@ -879,6 +884,7 @@ func TestGossipSyncerReplyShortChanIDs(t *testing.T) { // With our set up above complete, we'll now attempt to obtain a reply // from the channel syncer for our target chan ID query. err := syncer.replyShortChanIDs(ctx, &lnwire.QueryShortChanIDs{ + ChainHash: syncer.cfg.chainHash, ShortChanIDs: queryChanIDs, }) require.NoError(t, err, "unable to query for chan IDs") @@ -951,6 +957,7 @@ func TestGossipSyncerReplyChanRangeQuery(t *testing.T) { const numBlocks = 50 const endingBlockHeight = startingBlockHeight + numBlocks - 1 query := &lnwire.QueryChannelRange{ + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: uint32(startingBlockHeight), NumBlocks: uint32(numBlocks), } @@ -1118,18 +1125,21 @@ func TestGossipSyncerReplyChanRangeQueryBlockRange(t *testing.T) { queryReqs := []*lnwire.QueryChannelRange{ // full range example { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: uint32(0), NumBlocks: uint32(math.MaxUint32), }, // small query example that does not overflow { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: uint32(1000), NumBlocks: uint32(100), }, // overflow example { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: uint32(1000), NumBlocks: uint32(math.MaxUint32), }, @@ -1231,6 +1241,7 @@ func TestGossipSyncerReplyChanRangeQueryNoNewChans(t *testing.T) { // Next, we'll craft a query to ask for all the new chan ID's after // block 100. query := &lnwire.QueryChannelRange{ + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 100, NumBlocks: 50, } @@ -1387,6 +1398,7 @@ func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) { // last block. replies := []*lnwire.ReplyChannelRange{ { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 0, NumBlocks: 11, ShortChanIDs: []lnwire.ShortChannelID{ @@ -1396,6 +1408,7 @@ func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) { }, }, { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 11, NumBlocks: 1, ShortChanIDs: []lnwire.ShortChannelID{ @@ -1405,6 +1418,7 @@ func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) { }, }, { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 12, NumBlocks: 1, ShortChanIDs: []lnwire.ShortChannelID{ @@ -1414,6 +1428,7 @@ func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) { }, }, { + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 13, NumBlocks: query.NumBlocks - 13, Complete: 1, @@ -1590,6 +1605,85 @@ func testGossipSyncerProcessChanRangeReply(t *testing.T, legacy bool) { } } +// TestGossipSyncerProcessMalformedChanRangeReply verifies that the +// GossipSyncer rejects a ReplyChannelRange message if its ChainHash does not +// match the configured chain. +func TestGossipSyncerProcessMalformedChanRangeReply(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + reply := &lnwire.ReplyChannelRange{ + ChainHash: *chaincfg.RegressionNetParams. + GenesisHash, + FirstBlockHeight: 0, + NumBlocks: 11, + Complete: 1, + ShortChanIDs: []lnwire.ShortChannelID{ + { + BlockHeight: 10, + TxIndex: 1, + }, + }, + } + + require.ErrorContains( + t, syncer.processChanRangeReply(ctx, reply), + "reply includes channels for chain", + ) +} + +// TestGossipSyncerProcessMalformedShortChanIDsEndReply verifies that the +// GossipSyncer rejects a ReplyShortChanIDsEnd message if its ChainHash does not +// match the configured chain. +func TestGossipSyncerProcessMalformedShortChanIDsEndReply(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + reply := &lnwire.ReplyShortChanIDsEnd{ + ChainHash: *chaincfg.RegressionNetParams.GenesisHash, + Complete: 1, + } + + require.ErrorContains( + t, syncer.processScidEndReply(ctx, reply), + "reply includes channels for chain", + ) +} + +// TestGossipSyncerProcessMalformedGossipTimestampRange verifies that the +// GossipSyncer rejects a GossipTimestampRange message if its ChainHash does not +// match the configured chain. +func TestGossipSyncerProcessMalformedGossipTimestampRange(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + gossip := &lnwire.GossipTimestampRange{ + ChainHash: *chaincfg.RegressionNetParams.GenesisHash, + FirstTimestamp: uint32(time.Now().Unix()), + TimestampRange: math.MaxUint32, + } + + require.ErrorContains( + t, syncer.ApplyGossipFilter(ctx, gossip), + "gossip filter specifies chain", + ) +} + // TestGossipSyncerSynchronizeChanIDs tests that we properly request chunks of // the short chan ID's which were unknown to us. We'll ensure that we request // chunk by chunk, and after the last chunk, we return true indicating that we @@ -2243,6 +2337,9 @@ func TestGossipSyncerSyncTransitions(t *testing.T) { firstTimestamp := uint32(time.Now().Unix()) assertMsgSent( t, mChan, &lnwire.GossipTimestampRange{ + ChainHash: *chaincfg. + MainNetParams. + GenesisHash, FirstTimestamp: firstTimestamp, TimestampRange: math.MaxUint32, }, @@ -2255,6 +2352,9 @@ func TestGossipSyncerSyncTransitions(t *testing.T) { // updates. assertMsgSent( t, mChan, &lnwire.GossipTimestampRange{ + ChainHash: *chaincfg. + MainNetParams. + GenesisHash, FirstTimestamp: uint32( zeroTimestamp.Unix(), ), @@ -2283,6 +2383,8 @@ func TestGossipSyncerSyncTransitions(t *testing.T) { // updates. firstTimestamp := uint32(time.Now().Unix()) assertMsgSent(t, msgChan, &lnwire.GossipTimestampRange{ + ChainHash: *chaincfg.MainNetParams. + GenesisHash, FirstTimestamp: firstTimestamp, TimestampRange: math.MaxUint32, }) @@ -2363,6 +2465,7 @@ func TestGossipSyncerHistoricalSync(t *testing.T) { // We should expect to see a single lnwire.QueryChannelRange message be // sent to the remote peer with a FirstBlockHeight of 0. expectedMsg := &lnwire.QueryChannelRange{ + ChainHash: syncer.cfg.chainHash, FirstBlockHeight: 0, NumBlocks: latestKnownHeight, QueryOptions: lnwire.NewTimestampQueryOption(), diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 4a1fffad6b6..2487cdd7fc1 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -75,6 +75,11 @@ transitions during startup, avoiding lost unlocks during slow database initialization. +* [Fixed the gossiper accepting malformed gossip +messages](https://github.com/lightningnetwork/lnd/pull/10581) by enforcing chain +hash validation on `ReplyChannelRange`, `ReplyShortChanIDsEnd` and +`GossipTimestampRange`. + # New Features - Basic Support for [onion messaging forwarding](https://github.com/lightningnetwork/lnd/pull/9868)