Skip to content

Remove aggregated signature verification bypass for small proof_data#198

Merged
MegaRedHand merged 2 commits intomainfrom
fix/aggregated-signature-verification-bypass
Mar 11, 2026
Merged

Remove aggregated signature verification bypass for small proof_data#198
MegaRedHand merged 2 commits intomainfrom
fix/aggregated-signature-verification-bypass

Conversation

@pablodeymo
Copy link
Collaborator

@pablodeymo pablodeymo commented Mar 11, 2026

Motivation

During a security review, we identified that verify_aggregated_signature() in crates/common/crypto/src/lib.rs silently returned Ok(()) for any proof_data under 10 bytes — including empty proofs — without performing any cryptographic verification.

This was reachable from two production P2P network paths:

  1. Gossip aggregated attestations (store.rs:459) — any peer can send a SignedAggregatedAttestation with empty proof_data. No proposer key required.
  2. Block attestation signatures (store.rs:1197) — a malicious proposer can include attestations with fabricated aggregation_bits and empty proof_data.

A malicious peer could forge attestation votes to manipulate fork choice (LMD-GHOST head selection) and influence justification/finalization thresholds.

Note: proposer individual XMSS signatures are NOT affected — they use ValidatorSignature::is_valid(), a separate code path with no size-based bypass.

Origin of the bypass

AggregatedSignatureProof::empty() creates proofs with empty proof_data for the verify=false test path. The < 10 guard was likely added to prevent SSZ deserialization errors when these empty proofs reached the verifier during development. However, the verify=false path never calls verify_signatures, so the guard was unnecessary — and it converted what should be an error into a silent success on the production verify=true path.

Description

Remove the early-return bypass in verify_aggregated_signature():

// REMOVED:
if proof_data.len() < 10 {
    return Ok(());
}

Without this guard, empty/small proof_data will naturally fail at the SSZ deserialization step (Devnet2XmssAggregateSignature::from_ssz_bytes) and return VerificationError::DeserializationFailed.

How to test

All spec tests pass without the bypass:

cargo test -p ethlambda-blockchain --test forkchoice_spectests --release     # 26/26 passed
cargo test -p ethlambda-state-transition --test stf_spectests --release      # 14/14 passed
cargo test -p ethlambda-blockchain --test signature_spectests --release      #  8/8 passed

Related

  • StoreError::ParticipantsMismatch is defined at store.rs:899 but never raised. Could be addressed in a follow-up.

  verify_aggregated_signature() returned Ok(()) for any proof_data under 10 bytes,
  silently accepting unverified aggregated signatures. This was reachable from two
  production P2P paths: block attestation verification and gossip aggregated
  attestation verification.

  The bypass was likely added during development to avoid SSZ deserialization errors
  when empty proofs (from AggregatedSignatureProof::empty()) reached the verifier.
  However, the verify=false path that uses empty proofs never calls verify_signatures,
  so the guard was unnecessary — and it converted what should be an error into a
  silent success on the production verify=true path.

  A cross-client review confirmed no other lean consensus client (Zeam, Ream, Lantern,
  Grandine) has this bypass. All reject empty proof_data via SSZ deserialization
  failure, explicit guards, or invariant panics.

  All spec tests pass without the bypass (forkchoice, state transition, signatures).
@github-actions
Copy link

🤖 Kimi Code Review

Security Vulnerability: Signature Bypass

File: crates/common/crypto/src/lib.rs
Lines: 143-146

The removal of the length check if proof_data.len() < 10 { return Ok(()); } introduces a critical security vulnerability. This check was likely serving as a guard against malformed or empty signature data that could otherwise pass verification incorrectly.

Impact: Without this check, empty or very short proof_data could potentially be accepted as valid aggregated signatures, allowing malicious blocks or attestations to bypass signature verification entirely.

Recommendation: Restore the length check or verify its necessity. If the check was overly permissive, replace it with a more precise validation (e.g., checking for exact expected lengths based on XMSS parameters). The magic number 10 should be replaced with a named constant explaining the minimum valid signature size.

Additional concern: The comment // Convert public keys on line 148 is misleading - this section converts public keys to the internal LeanSigPubKey format, not the signature data itself.


Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

  1. No blocking findings in this PR. The change removes a critical verification bypass in verify_aggregated_signature where proof_data.len() < 10 previously returned Ok(()), allowing malformed/forged aggregated proofs to be accepted without cryptographic checks. The new flow always deserializes + verifies, which is the correct security posture for consensus signatures (lib.rs).

  2. Non-blocking improvement: add a regression test that passes a deliberately short/garbage proof_data and asserts Err(VerificationError::DeserializationFailed) (or equivalent failure), near the existing verification tests (lib.rs). This protects against accidental reintroduction of the bypass.

  3. Consensus-layer scope check: this PR does not alter fork-choice, justification/finalization, slot/block transition, or SSZ/XMSS aggregation logic itself; it hardens the signature verification gate used by attestation processing paths.


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

🤖 Claude Code Review

Now I have all the context I need to write the review.


Review: Remove aggregated signature verification bypass for small proof_data

Summary

A minimal, surgical fix removing a critical security bypass in verify_aggregated_signature(). The change is correct and well-motivated.


Security Impact

The removed guard was a critical vulnerability on two production P2P paths:

  • store.rs:459 — gossip attestations: any peer could send a SignedAggregatedAttestation with 0–9 bytes of proof_data and have it accepted as valid
  • store.rs:1197 — block attestation verification: a malicious proposer could build blocks with fabricated aggregation_bits and empty proof_data, forging arbitrary validator votes

Both paths feed directly into fork choice weight updates and the 3SF-mini justification/finalization thresholds. The attacker surface was unauthenticated (gossip), so no validator key was needed to exploit it.

The fix is correct: removing the guard causes Devnet2XmssAggregateSignature::from_ssz_bytes to fail on empty/undersized data, returning VerificationError::DeserializationFailed, matching Zeam/Ream behavior.


Code Review

crates/common/crypto/src/lib.rs:140–142 (removed)

if proof_data.len() < 10 {
    return Ok(());
}

Removal is correct. The verify=false test path that creates AggregatedSignatureProof::empty() (store.rs:632) never calls verify_signatures, so there is no regression risk. All 48 spec tests confirm this.

store.rs:1177–1197verify_signatures (unchanged, but worth noting)

The function correctly derives public_keys from attestation.aggregation_bits, not from aggregated_proof.participants. This means a malicious proposer could still submit a block where aggregated_proof.participants lists different validators than attestation.aggregation_bits — the proof would be verified against the wrong set of public keys and likely fail, but the mismatch itself is not explicitly rejected. StoreError::ParticipantsMismatch is defined at line 899 but never raised. Zeam and Lantern both enforce this check. The PR body flags this as a follow-up; it should not block merging, but should be tracked.

store.rs:459 — gossip path

ethlambda_crypto::verify_aggregated_signature(
    &aggregated.proof.proof_data,
    ...
)

After this fix, an empty proof_data will correctly return Err(VerificationError::DeserializationFailed). The calling code should propagate this as a protocol violation. Worth confirming the gossip path rejects (and optionally penalizes/disconnects) the sending peer rather than silently dropping the attestation — but that is pre-existing behavior, not introduced by this PR.


Test Coverage

The three spec test suites (forkchoice: 26/26, stf: 14/14, signature: 8/8) adequately cover the removal. There is no fast (non-#[ignore]) unit test asserting that verify_aggregated_signature returns Err on empty input. Consider adding one:

#[test]
fn test_empty_proof_data_is_rejected() {
    let empty = ByteListMiB::new(vec![]).unwrap();
    let result = verify_aggregated_signature(&empty, vec![], &H256::ZERO, 0);
    assert!(result.is_err(), "empty proof_data must not verify");
}

This would act as a regression guard without requiring leanVM and wouldn't need #[ignore].


Verdict

Approve. The fix is minimal, correct, and closes a critical unauthenticated bypass. The follow-up items (ParticipantsMismatch enforcement, fast regression test) are non-blocking but worth tracking.


Automated review by Claude (Anthropic) · custom prompt

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR removes a 4-line early-return bypass in verify_aggregated_signature() that silently returned Ok(()) for any proof_data shorter than 10 bytes — including empty proofs — without performing any cryptographic verification. The fix closes a security vulnerability reachable from two production P2P paths (gossip aggregated attestations and block attestation signatures), where a malicious peer could forge attestation votes to manipulate fork-choice head selection and influence justification/finalization thresholds.

Key points:

  • The fix is minimal, correct, and well-motivated: removing the guard means short/empty proof_data now fails at SSZ deserialization (Devnet2XmssAggregateSignature::from_ssz_bytes) and returns VerificationError::DeserializationFailed, consistent with Ream and Zeam behavior.
  • The verify=false test path (which uses AggregatedSignatureProof::empty()) never calls verify_signatures, so the bypass was always unnecessary on that path.
  • All 48 spec tests (forkchoice, state-transition, signature) pass without the bypass, confirming no legitimate code path depended on it.
  • No dedicated regression test exists to prevent reintroduction of this bypass; the existing verify_aggregated_signature tests are all marked #[ignore = "too slow"]. A fast unit test with empty/small proof_data would be a valuable follow-up.
  • The PR also notes that StoreError::ParticipantsMismatch (defined at store.rs:899) is never raised; Zeam and Lantern enforce this check. This is deferred to a follow-up but worth tracking.

Confidence Score: 5/5

  • This PR is safe to merge — it removes a clearly incorrect bypass with no legitimate production use, and all spec tests confirm correctness.
  • The change is a well-scoped 4-line deletion of a verified security bypass. The PR description provides thorough cross-client comparison and root-cause analysis. All 48 spec tests pass. The only gap is a missing fast regression test, which is a style/best-practice concern rather than a blocking issue.
  • No files require special attention; the single changed file is straightforward.

Important Files Changed

Filename Overview
crates/common/crypto/src/lib.rs Removes a 4-line early-return bypass that silently accepted any proof_data shorter than 10 bytes without cryptographic verification; empty/small proofs now correctly fail at SSZ deserialization. No regression test guards against reintroduction.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["P2P message received"] --> B{"verify == true?"}
    B -- No --> C["Skip verify_signatures\nEmpty proofs OK"]
    B -- Yes --> D["verify_signatures"]
    D --> E["verify_aggregated_signature\nproof_data, pubkeys, msg, slot"]

    E --> F{"BEFORE: proof_data.len() less than 10?"}
    F -- Yes --> G["return Ok - BYPASS\nNo crypto performed"]
    F -- No --> H["from_ssz_bytes deserialization"]

    E --> K["AFTER: from_ssz_bytes deserialization"]
    K -- "Fails for empty/short data" --> L["return Err DeserializationFailed"]
    K -- Succeeds --> M["xmss_verify_aggregated_signatures"]
    H --> M
    M --> N["return Ok"]

    style G fill:#ff4444,color:#fff
    style L fill:#44aa44,color:#fff
    style N fill:#44aa44,color:#fff
Loading

Comments Outside Diff (1)

  1. crates/common/crypto/src/lib.rs, line 159 (link)

    Missing regression test for the removed bypass

    The test suite has no fast, non-ignored test verifying that empty or short proof_data now returns Err(VerificationError::DeserializationFailed). All the meaningful verify_aggregated_signature tests are marked #[ignore = "too slow"], which means CI won't catch a reintroduction of this bypass.

    Adding a dedicated unit test requires no XMSS key generation and would run in milliseconds:

    #[test]
    fn test_empty_proof_data_rejects_with_deserialization_error() {
        let empty_proof = ByteListMiB::new(vec![]).expect("empty list should be valid");
        let result = verify_aggregated_signature(&empty_proof, vec![], &H256::ZERO, 0);
        assert!(
            matches!(result, Err(VerificationError::DeserializationFailed)),
            "empty proof_data must be rejected, got: {:?}",
            result
        );
    }

    A complementary test with 1–9 bytes of junk data would also harden coverage of the formerly-bypassed range.

Last reviewed commit: ba28031

Copy link
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch!! 🎣

@MegaRedHand MegaRedHand merged commit bd100eb into main Mar 11, 2026
2 checks passed
@MegaRedHand MegaRedHand deleted the fix/aggregated-signature-verification-bypass branch March 11, 2026 21:45
pablodeymo added a commit that referenced this pull request Mar 12, 2026
…participants (#202)

## Motivation

`StoreError::ParticipantsMismatch` was defined (`store.rs:899`) but
never raised anywhere in the codebase. During block signature
verification, `verify_signatures` extracts validator IDs from
`attestation.aggregation_bits` and verifies the crypto proof against
those public keys — but never checks that
`aggregated_proof.participants` matches `aggregation_bits`.

This is a problem because the block-building path uses
`proof.participants` (not `aggregation_bits`) as the source of truth for
which validators a stored proof covers.

### Why these two bitfields exist

A `SignedBlockWithAttestation` contains both:

- **`block.body.attestations[i].aggregation_bits`** — in the block body,
declares "these validators attested"
- **`signature.attestation_signatures[i].participants`** — in the
signatures section, declares "this proof covers these validators"

For an honest block, these are always identical. But since they come
from untrusted network input (a peer's block), they can be crafted to
differ.

### The data flow that makes this exploitable

```
RECEIVE PATH (on_block_core, verify_signatures)
────────────────────────────────────────────────
1. verify_signatures extracts validator IDs from aggregation_bits
2. Collects public keys for those validators
3. Verifies crypto proof against those public keys → passes
4. Stores the WHOLE proof object (including proof.participants) in the cache

BUILD PATH (select_aggregated_proofs, line 1118-1135)
─────────────────────────────────────────────────────
1. Reads stored proofs from cache
2. Uses proof.participants to determine coverage (line 1121)
3. Sets new block's aggregation_bits = proof.participants (line 1135)

If participants ≠ aggregation_bits, the stored proof has wrong metadata,
and the receiver will build blocks with incorrect aggregation_bits.
```

### Attack scenario

A malicious proposer (who is legitimately the proposer for a slot)
crafts a block where:

| Field | Value |
|-------|-------|
| `attestation.aggregation_bits` | `{1, 2, 3}` |
| `proof.participants` | `{1, 2, 3, 4, 5}` |
| `proof.proof_data` | valid proof for `{1, 2, 3}` |

**What happens on the victim node:**

1. `verify_signatures` checks proof against `aggregation_bits` = `{1, 2,
3}` → **passes** ✅
2. Proof is stored in the cache with `participants = {1, 2, 3, 4, 5}` →
**poisoned cache**
3. Victim later becomes proposer, calls `select_aggregated_proofs`
4. `select_aggregated_proofs` reads `proof.participants` → thinks proof
covers 5 validators
5. New block sets `aggregation_bits = {1, 2, 3, 4, 5}` (from
`proof.participants`, line 1135)
6. Other nodes verify this block: proof checked against 5 public keys,
but proof only covers 3
7. **Block is rejected** → victim produces invalid blocks

**Impact:** The victim node builds invalid blocks that the rest of the
network rejects. This damages the victim's reputation and causes missed
proposal rewards. Requires the attacker to be a legitimate proposer for
at least one slot.

### Other clients

Both Zeam and Lantern enforce this check:

- **Zeam** compares the two bitfields element-by-element in
`verifySignatures()`, returns `InvalidBlockSignatures` on mismatch
- **Lantern** does `memcmp` on the participant/aggregation bitfields in
`signed_block_signatures_are_valid()`

## Description

Add a `BitList` equality check in `verify_signatures` (before the crypto
verification loop body) that compares `attestation.aggregation_bits`
against `aggregated_proof.participants`. On mismatch, return the
already-defined `StoreError::ParticipantsMismatch`.

```rust
// Added at the top of the loop, before any expensive operations
if attestation.aggregation_bits != aggregated_proof.participants {
    return Err(StoreError::ParticipantsMismatch);
}
```

This runs before the public key collection and crypto verification, so
invalid blocks are rejected cheaply without wasting CPU on signature
verification.

## How to test

All spec tests pass:

```bash
cargo test -p ethlambda-blockchain --test forkchoice_spectests --release     # 26/26 passed
cargo test -p ethlambda-state-transition --test stf_spectests --release      # 14/14 passed
cargo test -p ethlambda-blockchain --test signature_spectests --release      #  8/8 passed
make lint                                                                    # clean
```

## Related

- #198 — Removes the `proof_data.len() < 10` bypass in
`verify_aggregated_signature` (separate but related fix)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants