fix(p2p): canonicalize yParity attestation signatures before L1 bundle#24515
Merged
PhilWindle merged 2 commits intoJul 5, 2026
Conversation
A committee member (or any peer mutating the recovery byte in flight) can emit an
attestation signature in yParity form (v = 0/1) that still recovers to the same signer,
because coordination-signature recovery allows yParity as v. On the proposer side,
`CommitteeAttestationsAndSigners.packAttestations` treats v === 0 as an empty (address-only)
slot while `getSigners` treats a signature as empty only when r, s and v are all zero. The two
disagree for a real signature with v = 0, so the bitmap popcount and the signers array diverge
and an honest proposer's `propose()` reverts with AttestationLib__SignersSizeMismatch. A v = 1
signature instead passes propose() but reverts epoch proving, where verifyAttestations runs
ECDSA.recover and rejects v not in {27,28}. Either way a single actor can repeatably brick block
production or proving for slots they attest to.
Canonicalize the recovery byte to v in {27,28} in `orderAttestations` — the honest path that
builds the L1 bundle, running before any adversarial-injection manipulation — and on ingress to
the attestation pool as defense in depth. Normalization keeps (r, s) and the recovery bit, so the
recovered address (and the signer's vote) is preserved.
…ures Defense in depth for the yParity attestation DoS. `packAttestations` classified a slot as empty on `v === 0` alone, while `getSigners` uses the full empty check (r, s and v all zero); the two disagreed for a signature with v = 0 and non-zero (r, s), so the bitmap popcount and the signers array diverged. Align the empty check to r, s and v all zero, and canonicalize a yParity recovery byte (v = 0/1) to 27/28 when packing so a signature that reaches the class already in yParity form is still consistent and accepted by L1 ECDSA.recover at proving time. Any other v is left untouched so a genuinely malformed signature still fails on L1. Does not touch (r, s), so the injectHighSValueAttestation / injectUnrecoverableSignatureAttestation adversarial paths keep producing the malformed signatures they rely on to verify L1 rejection.
PhilWindle
approved these changes
Jul 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
A committee member — or any peer mutating the recovery byte in flight — can emit an attestation signature in yParity form (
v = 0/1) that still recovers to the same signer, since coordination-signature recovery allows yParity asv. This is enough to brick an honest proposer.On the proposer side,
CommitteeAttestationsAndSigners.packAttestationstreatedv === 0as an empty (address-only) slot, whilegetSignerstreats a signature as empty only whenr,s, andvare all zero. The two disagree for a real signature withv = 0: the packed bitmap popcount excludes it but the signers array keeps it, so an honest proposer'spropose()reverts on L1 withAttestationLib__SignersSizeMismatch. Av = 1signature instead passespropose()but reverts epoch proving, whereverifyAttestationsrunsECDSA.recoverand rejectsv ∉ {27,28}. Either variant lets a single actor repeatably halt block production or proving for slots they attest to, with no slashing signal (they did attest).Approach
Canonicalize the attester signature's recovery byte to
v ∈ {27,28}(which keeps(r, s)and the recovery bit, so the recovered address and the signer's vote are preserved), at three layers:orderAttestations— the honest path that assembles the L1 bundle, via the existingnormalizeSignature. It runs before the adversarial-injection manipulation used by the invalid-attestation e2e tests, so those deliberately-malformed signatures are left untouched.tryAddCheckpointAttestationandaddOwnCheckpointAttestations) — defense in depth, so stored and re-gossiped bytes are always canonical.CommitteeAttestationsAndSigners.packAttestationsitself — its empty-slot check now matchesgetSigners()(r,s,vall zero, not justv === 0), and it canonicalizes a yParity recovery byte (0/1 → 27/28) when packing. This rewrites only thevbyte — it does not touch(r, s)and leaves any othervvalue as-is — so theinjectHighSValueAttestation/injectUnrecoverableSignatureAttestation/MaliciousCommitteeAttestationsAndSignerspaths (which inject malformed signatures afterorderAttestationsto verify L1 rejection) still behave as before. A fullnormalizeSignatureinside the class would break them; thev-byte-only fix does not.Regression tests cover both
v = 0andv = 1at theorderAttestationslayer, the pool ingress layer, and directly onpackAttestations(each asserted red first, e.g. bitmap popcount 3/4 vs signers 4, then green).Fixes A-1351