fix(l1): update existing contact ENR on NODES response#6172
fix(l1): update existing contact ENR on NODES response#6172ElFantasma merged 190 commits intomainfrom
Conversation
**Motivation** <!-- Why does this pull request exist? What are its goals? --> **Description** <!-- A clear and concise general description of the changes this PR introduces --> <!-- Link to issues: Resolves #111, Resolves #222 --> **Checklist** - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync. Closes #5574 and #5575. Co-authored-by: Esteban Dimitroff Hodi <esteban.dimitroff@lambdaclass.com>
**Checklist** - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync. Closes #issue_number
**Motivation** I saw current new_nonce impl allocated a vector when it can just return a fixed size array. **Description** Removes the needless vec **Checklist** - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync.
**Motivation** In order to start merging discv5 code into main, to avoid having a huge PR at the end of the development, we should create a feature flag disabled by default. Closes #5639
**Motivation** <!-- Why does this pull request exist? What are its goals? --> **Description** <!-- A clear and concise general description of the changes this PR introduces --> <!-- Link to issues: Resolves #111, Resolves #222 --> **Checklist** - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync.
**Motivation** <!-- Why does this pull request exist? What are its goals? --> **Description** <!-- A clear and concise general description of the changes this PR introduces --> <!-- Link to issues: Resolves #111, Resolves #222 --> **Checklist** - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync. Closes #5570 Closes #5571
6af59eb to
60f1455
Compare
60f1455 to
a1a0e15
Compare
Addressing agent reviewsFixes applied
False positives dismissed
|
| let is_fork_id_valid = | ||
| if let Some(remote_fork_id) = node_record.decode_pairs().eth { | ||
| backend::is_fork_id_valid(&self.store, &remote_fork_id) | ||
| .await | ||
| .ok() | ||
| .or(Some(false)) | ||
| } else { | ||
| Some(false) | ||
| }; |
There was a problem hiding this comment.
Extracted an evaluate_fork_id helper to remove the duplication: 1f39ea9
| Some(false) | ||
| }; | ||
| let contact = occupied_entry.get_mut(); | ||
| contact.node = node; |
There was a problem hiding this comment.
When updating an existing contact in the Occupied branch, contact.node = node can change the contact's IP address, but validation_timestamp is not reset. The validate_contact method (line 798) relies on was_validated() + an IP match to prevent amplification attacks (see the anti-amplification comment on lines 806-809). After this update, a previously-validated contact now appears validated at a new IP it was never actually pinged at.
Attack scenario: A malicious node M, previously validated at IP_M, creates a legitimately-signed ENR with IP_victim and higher seq. When this ENR arrives in a NODES response, we update M's contact: node.ip = IP_victim, while validation_timestamp is preserved. A spoofed FindNode packet with src = IP_victim now passes validate_contact, causing us to send a large NODES response to the victim.
The fix would be to check if the IP changed and, if so, reset validation_timestamp (and ping_req_id). This is a new concern — on main, new_contact_records only inserted vacant entries, so a contact's IP could never change through this path.
There was a problem hiding this comment.
The specific attack scenario doesn't quite work as described. Let me trace through it:
-
In discv5, messages are encrypted with session keys established via ECDH handshake. A third party can't forge a FindNode as node M without M's session key.
-
More importantly,
validate_contact(which checkssender_ip == contact.node.ip) is not called in the discv5handle_find_nodehandler at all — that check only exists in discv4. Sovalidation_timestampis irrelevant to this path. -
handle_find_noderesponds tocontact.node.udp_addr()(the stored IP from the ENR), not to the actualsender_addrfrom the UDP packet. This is a pre-existing behavior — before this PR, the handler was already responding to the stored contact IP without any IP check.
What our PR changes: it makes it possible for a peer to update its stored IP via a NODES response (previously only possible during initial discovery). This marginally broadens the existing issue but doesn't create it.
The real pre-existing issue is that discv5's handle_find_node lacks the anti-amplification defense that discv4 has. The proper fix (as a separate PR) would be:
- Pass
sender_addrtohandle_find_nodeand respond to it instead ofcontact.node.udp_addr() - Add a
sender_addr.ip() == contact.node.ipcheck (like discv4'svalidate_contact)
Neither alone is sufficient (responding to stored IP allows ENR-based redirection; responding to sender_addr alone allows UDP spoofing), but together they make amplification much harder.
That said, resetting validation_timestamp on IP change is cheap and reasonable as defense-in-depth — happy to add it. But it won't fix the actual gap since validate_contact isn't used in the discv5 FindNode path.
Motivation
new_contact_records()only inserted new contacts (Entry::Vacant), silently discarding updated ENRs for already-known peers. This made the FINDNODE(distance=0) request from #5910 ineffective — the request was sent but the response couldn't update the existing record.Description
Entry::Occupiedinnew_contact_records(): when the incoming record has a higherseqthan the existing one, update the contact'snode,record, andis_fork_id_valid.verify_signature()) upfront for all records, which was previously missing even for new contacts.matchto avoid duplication.Closes #6166