Skip to content

Commit 9485452

Browse files
committed
wit2: address adversarial-review safety findings (TDD)
Three high-severity issues from the Codex adversarial review on PR #2208, each with a TDD regression test added first then fixed: 1. Header-race: signed announce arriving before header was silently downgraded. handleSignedWitnessAnnouncements called peer.AddKnownAnnounce unconditionally before the verification gate, leaving a peer marked announce-known even on bad-signature / header-unknown rejection paths. That suppressed our own re-relay back to that peer if a valid version of the same hash arrived from someone else, killing the natural recovery path. Fix: gate AddKnownAnnounce on acceptSignedAnnouncement success so the announce-known bit only reflects verified delivery. Test: TestHandleSignedWitnessAnnouncementsBadSigDoesNotMarkAnnounceKnown. 2. pendingWitnessBodies TTL didn't actually evict. get() observed expiry and returned false but left the entry in the map; gcLocked only ran from put(), so a node that stopped receiving witnesses retained up to capacity (10) ~50MB blobs indefinitely. Fix: when get() observes an expired entry, upgrade to write lock and delete it (re-checking under the write lock to avoid clobbering a concurrent put). Test: TestPendingWitnessBodyCacheGetEvictsExpired. 3. Honest body-server dropped on bad producer commitment. verifyAgainstSignedHash dropped the byte-server on every signed-hash mismatch, but the announcement only proves *some* BP signed *some* hash — not that the hash matches the canonical witness. A faulty or malicious scheduled producer that signed a bogus hash would weaponise this to disconnect every honest peer serving the real witness. Fix: reject the bytes (don't cache for serving) and back off the request without dropping the byte-server. TODO comment left for follow-up signer-quarantine work, which needs (signer, relayer) provenance the manager doesn't currently have. Test: TestProcessWitnessResponseDoesNotDropOnByteMismatch (replaces the previous TestProcessWitnessResponseDropsOnHashMismatch, whose policy this commit reverses).
1 parent 12368a3 commit 9485452

5 files changed

Lines changed: 140 additions & 40 deletions

File tree

eth/fetcher/witness_manager.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -723,9 +723,18 @@ func (m *witnessManager) verifyAgainstSignedHash(peer string, hash common.Hash,
723723
actual := stateless.WitnessCommitHash(encoded)
724724
if actual != expected {
725725
witnessByteMismatchMeter.Mark(1)
726-
log.Warn("[wm] Witness bytes do not match BP-signed hash; dropping peer",
726+
// We cannot blame the byte-server on signed-hash disagreement alone:
727+
// the announcement only proves *some* BP signed *some* hash. A faulty
728+
// or malicious scheduled producer that signed a bogus hash would
729+
// otherwise weaponise this path to disconnect every honest peer
730+
// serving the canonical witness. Reject the bytes (don't cache for
731+
// serving), back off the pending request so another peer/announcement
732+
// gets tried, and let import-time execution validation pin blame.
733+
// TODO(wit2): wire signer-quarantine once the manager has access to
734+
// (signer, announcement-relayer) provenance from the handler.
735+
log.Warn("[wm] Witness bytes do not match BP-signed hash; not caching, retrying with another peer",
727736
"peer", peer, "block", hash, "expected", expected, "actual", actual)
728-
m.handleWitnessFetchFailureExt(hash, peer, errors.New("witness hash mismatch"), false)
737+
m.handleWitnessFetchFailureExt(hash, "", errors.New("witness hash mismatch"), false)
729738
return nil, common.Hash{}, false
730739
}
731740
return encoded, expected, true

eth/fetcher/witness_manager_wit2_test.go

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88

99
"github.com/ethereum/go-ethereum/common"
1010
"github.com/ethereum/go-ethereum/core/stateless"
11-
"github.com/ethereum/go-ethereum/core/types"
1211
"github.com/ethereum/go-ethereum/eth/protocols/eth"
1312
)
1413

@@ -26,65 +25,60 @@ func blockAnnounceForTest(origin string, hash common.Hash, number uint64) *block
2625
}
2726
}
2827

29-
// TestProcessWitnessResponseDropsOnHashMismatch is the load-bearing safety
30-
// guarantee for WIT2 pre-import serving: a peer that returns bytes whose
31-
// keccak256 doesn't match the BP-signed witnessHash must be dropped, even
32-
// if every other check passes.
28+
// TestProcessWitnessResponseDoesNotDropOnByteMismatch encodes the post-
29+
// adversarial-review safety policy: when the served witness bytes do not
30+
// match the BP-signed witnessHash on file, the manager must back off and
31+
// retry, but it MUST NOT drop the byte-server. The accepted announcement
32+
// only proves *some* BP signed *some* hash — not that the hash matches the
33+
// canonical witness. A faulty or malicious scheduled producer that signs a
34+
// bogus hash would otherwise weaponise this code path to disconnect every
35+
// honest peer serving the real witness.
3336
//
34-
// Without this, a malicious server could pollute downstream relayers with
35-
// bytes the BP never committed to, and the relayers would face state-root
36-
// failures during execution that they cannot attribute to the right party.
37-
func TestProcessWitnessResponseDropsOnHashMismatch(t *testing.T) {
37+
// The mismatched bytes are still rejected (not cached for serving), and the
38+
// pending state stays alive with a fresh back-off so another peer (or another
39+
// announcement) gets a chance. Blame-pinning belongs at execution time, where
40+
// import-side validation can attribute fault to signer vs. server vs. caller.
41+
func TestProcessWitnessResponseDoesNotDropOnByteMismatch(t *testing.T) {
3842
tw := newTestWitnessManager()
3943
defer tw.Close()
4044

4145
block := createTestBlock(101)
4246
hash := block.Hash()
4347

44-
// Prepare a "correct" witness that the BP signed over.
45-
correct := createTestWitnessForBlock(block)
46-
var buf bytes.Buffer
47-
if err := correct.EncodeRLP(&buf); err != nil {
48-
t.Fatalf("encode: %v", err)
49-
}
50-
signedWitnessHash := stateless.WitnessCommitHash(buf.Bytes())
51-
52-
// The peer will return a different witness — same block number, but
53-
// the trie differs, producing different bytes and a different hash.
54-
differentHeader := types.CopyHeader(block.Header())
55-
differentHeader.GasUsed = 999_999_999
56-
differentBlock := types.NewBlockWithHeader(differentHeader)
57-
rogueWitness := createTestWitnessForBlock(differentBlock)
48+
// The honest server returns the canonical witness for this block — its
49+
// keccak commitment is `canonical`.
50+
canonical := createTestWitnessForBlock(block)
5851

59-
// Inject the signed-witness lookup so processWitnessResponse uses it.
52+
// Simulate a malicious / faulty BP that signed a bogus, unrelated hash.
53+
// processWitnessResponse will see canonical bytes whose hash does not
54+
// match what parentSignedWitnessHash reports.
55+
rogueSignedHash := common.HexToHash("0xdeadbeef")
6056
tw.manager.parentSignedWitnessHash = func(h common.Hash) (common.Hash, bool) {
6157
if h == hash {
62-
return signedWitnessHash, true
58+
return rogueSignedHash, true
6359
}
6460
return common.Hash{}, false
6561
}
6662

67-
// Seed pending state so the failure handler back-off path is exercised.
6863
tw.manager.mu.Lock()
6964
tw.manager.pending[hash] = &witnessRequestState{
70-
op: &blockOrHeaderInject{origin: "rogue", block: block},
71-
announce: blockAnnounceForTest("rogue", hash, block.NumberU64()),
65+
op: &blockOrHeaderInject{origin: "honest", block: block},
66+
announce: blockAnnounceForTest("honest", hash, block.NumberU64()),
7267
}
7368
tw.manager.mu.Unlock()
7469

75-
// Fabricate the response container expected by processWitnessResponse.
7670
res := &eth.Response{
7771
Time: time.Millisecond,
7872
Done: make(chan error, 1),
79-
Res: []*stateless.Witness{rogueWitness},
73+
Res: []*stateless.Witness{canonical},
8074
}
8175

82-
tw.manager.processWitnessResponse("rogue", hash, res, time.Now())
76+
tw.manager.processWitnessResponse("honest-server", hash, res, time.Now())
8377

8478
tw.mu.Lock()
8579
defer tw.mu.Unlock()
86-
if len(tw.droppedPeers) != 1 || tw.droppedPeers[0] != "rogue" {
87-
t.Fatalf("expected the lying peer to be dropped, got drops=%v", tw.droppedPeers)
80+
if len(tw.droppedPeers) != 0 {
81+
t.Fatalf("byte-server must not be dropped on signed-hash mismatch (BP may have signed bogus); drops=%v", tw.droppedPeers)
8882
}
8983
}
9084

eth/handler_wit.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,23 @@ func (h *witHandler) handleSignedWitnessAnnouncements(peer *wit.Peer, anns []wit
167167
}
168168

169169
for _, ann := range anns {
170-
// Sender saw this announcement; suppress relay back to them. Do NOT
171-
// mark them as a body-holder — they may be relaying without bytes.
172-
peer.AddKnownAnnounce(ann.BlockHash)
173-
174170
if !h.acceptSignedAnnouncement(peer, ann) {
171+
// Verification failed (bad signature, signer ≠ producer, or
172+
// header not yet local). MUST NOT mark the sender as
173+
// announce-known: doing so would (a) suppress our own later
174+
// re-relay back to this peer if we receive a valid version of
175+
// the same hash from someone else, and (b) leave us no path
176+
// to recover from a header-arrival race once a re-gossip for
177+
// the same hash arrives. Recovery on this branch relies on
178+
// re-receipt, which the empty knownAnnounces set permits.
175179
continue
176180
}
177181

182+
// Sender produced a valid announcement; suppress relay back to them.
183+
// Do NOT mark them as a body-holder — they may be relaying without
184+
// bytes. Body fetches are gated on knownWitnesses, set elsewhere.
185+
peer.AddKnownAnnounce(ann.BlockHash)
186+
178187
// Cache + dedup. Skip relay if we've already relayed this hash recently.
179188
if !h.signedWitnesses.putIfNewer(ann) {
180189
wit2DuplicateMeter.Mark(1)

eth/handler_wit2.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,27 @@ func (c *pendingWitnessBodyCache) put(blockHash common.Hash, bytes []byte, witne
194194

195195
func (c *pendingWitnessBodyCache) get(blockHash common.Hash) ([]byte, common.Hash, bool) {
196196
c.mu.RLock()
197-
defer c.mu.RUnlock()
198197
e, ok := c.entries[blockHash]
199198
if !ok {
199+
c.mu.RUnlock()
200200
return nil, common.Hash{}, false
201201
}
202202
if time.Since(e.receivedAt) > wit2AnnounceTTL {
203+
// Expired: drop the large byte slice now rather than waiting for the
204+
// next put() to gc. Without this, a node that stops receiving witness
205+
// bodies retains up to capacity (10) ~50MB blobs indefinitely past the
206+
// TTL, since gcLocked() only fires on put().
207+
c.mu.RUnlock()
208+
c.mu.Lock()
209+
// Re-check under the write lock: a concurrent put() may have replaced
210+
// the entry with a fresh one we should not delete.
211+
if cur, ok2 := c.entries[blockHash]; ok2 && cur == e {
212+
delete(c.entries, blockHash)
213+
}
214+
c.mu.Unlock()
203215
return nil, common.Hash{}, false
204216
}
217+
c.mu.RUnlock()
205218
return e.bytes, e.witnessHash, true
206219
}
207220

eth/handler_wit2_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,81 @@ func TestVerifyScheduledProducerDeferredWhenHeaderUnknown(t *testing.T) {
510510
}
511511
}
512512

513+
// TestHandleSignedWitnessAnnouncementsBadSigDoesNotMarkAnnounceKnown is the
514+
// regression for the verification-ordering bug: handleSignedWitnessAnnouncements
515+
// must not mark a peer as announce-known until the announcement has passed the
516+
// signature/producer-binding gate. The previous order called
517+
// peer.AddKnownAnnounce(hash) unconditionally before acceptSignedAnnouncement,
518+
// so a peer relaying a structurally invalid announcement still became
519+
// announce-known for that hash. Two bad consequences flowed from that:
520+
// - this node refused to ever relay a *valid* later announcement back to that
521+
// peer for the same hash, leaving them unable to recover;
522+
// - this node short-circuited its own re-evaluation paths when a good
523+
// announcement for the same hash arrived from another peer, because the
524+
// original sender's announce-known bit served as a relay-suppression hint.
525+
//
526+
// Using a structurally invalid signature (length 3) is sufficient to drive the
527+
// reject path through verifySignedAnnouncement → strikeWit2Peer without needing
528+
// a bor engine or block header.
529+
func TestHandleSignedWitnessAnnouncementsBadSigDoesNotMarkAnnounceKnown(t *testing.T) {
530+
h := newTestHandler()
531+
defer h.close()
532+
533+
witH := (*witHandler)(h.handler)
534+
peer, cleanup := newTestWit2PeerWithReader()
535+
defer cleanup()
536+
537+
blockHash := common.HexToHash("0xfeedface")
538+
ann := wit.SignedWitnessAnnouncement{
539+
BlockHash: blockHash,
540+
BlockNumber: 1,
541+
WitnessHash: common.HexToHash("0xc0ffee"),
542+
Signature: []byte{0x00, 0x01, 0x02}, // structurally invalid
543+
}
544+
545+
if err := witH.handleSignedWitnessAnnouncements(peer, []wit.SignedWitnessAnnouncement{ann}); err != nil {
546+
t.Fatalf("handleSignedWitnessAnnouncements: %v", err)
547+
}
548+
549+
if peer.KnownAnnounceContainsHash(blockHash) {
550+
t.Fatal("peer marked announce-known despite invalid signature; verification ordering is broken")
551+
}
552+
if _, ok := h.handler.signedWitnesses.get(blockHash); ok {
553+
t.Fatal("signed announcement cached despite invalid signature")
554+
}
555+
}
556+
557+
// TestPendingWitnessBodyCacheGetEvictsExpired pins the leak fix for the TTL
558+
// path. Before the fix, get() returned false on expiry but left the entry in
559+
// the map; gcLocked only ran from put(), so a node that stopped receiving new
560+
// witnesses retained up to capacity (10) full witness blobs (~50 MiB each)
561+
// indefinitely, producing a long-lived OOM risk under bursty traffic.
562+
//
563+
// The contract this test enforces: any get() that observes an expired entry
564+
// MUST delete it in place so memory pressure does not persist past the TTL.
565+
func TestPendingWitnessBodyCacheGetEvictsExpired(t *testing.T) {
566+
c := newPendingWitnessBodyCache(4)
567+
hash := common.HexToHash("0xfade")
568+
c.put(hash, []byte("expensive-body"), common.HexToHash("0xab"))
569+
570+
// Force the entry's receivedAt back beyond the TTL, mirroring the same
571+
// approach used by TestSignedWitnessCacheTTLExpiry above.
572+
c.mu.Lock()
573+
c.entries[hash].receivedAt = time.Now().Add(-2 * wit2AnnounceTTL)
574+
c.mu.Unlock()
575+
576+
if _, _, ok := c.get(hash); ok {
577+
t.Fatal("expired entry must not be returned")
578+
}
579+
580+
c.mu.RLock()
581+
entriesAfter := len(c.entries)
582+
c.mu.RUnlock()
583+
if entriesAfter != 0 {
584+
t.Fatalf("expired entry must be deleted on get; len(entries)=%d, want 0", entriesAfter)
585+
}
586+
}
587+
513588
// TestVerifyScheduledProducerRejectsBlockNumberMismatch covers the case where
514589
// the local header is present but disagrees with the announce on block
515590
// number. This is a confirmed bad announce and the caller must strike, so

0 commit comments

Comments
 (0)