VYPR
Critical severity9.6NVD Advisory· Published Apr 22, 2026· Updated Apr 24, 2026

CVE-2026-33471

CVE-2026-33471

Description

nimiq-block contains block primitives to be used in Nimiq's Rust implementation. SkipBlockProof::verify computes its quorum check using BitSet.len(), then iterates BitSet indices and casts each usize index to u16 (slot as u16) for slot lookup. Prior to version 1.3.0, if an attacker can get a SkipBlockProof verified where MultiSignature.signers contains out-of-range indices spaced by 65536, these indices inflate len() but collide onto the same in-range u16 slot during aggregation. This makes it possible for a malicious validator with far fewer than 2f+1 real signer slots to pass skip block proof verification by multiplying a single BLS signature by the same factor. The patch for this vulnerability is included as part of v1.3.0. No known workarounds are available.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nimiq-blockcrates.io
<= 0.2.0

Affected products

1

Patches

1
d02059053181

Fix quorum bypass via out-of-range BitSet signer indices

https://github.com/nimiq/core-rs-albatrossJose Daniel HernandezMar 27, 2026via ghsa
4 files changed · +60 21
  • primitives/block/src/equivocation_proof.rs+17 9 modified
    @@ -18,7 +18,7 @@ use nimiq_transaction::{
     };
     use thiserror::Error;
     
    -use crate::{MacroHeader, MicroHeader};
    +use crate::{multisig::checked_signer_slots, MacroHeader, MicroHeader};
     
     /// An equivocation proof proves that a validator misbehaved.
     ///
    @@ -527,6 +527,11 @@ impl DoubleVoteProof {
             validators: &Validators,
             validator_slots: Range<u16>,
         ) -> Result<(), EquivocationProofError> {
    +        let checked_signers1 = checked_signer_slots(&self.signers1)
    +            .ok_or(EquivocationProofError::InvalidJustification)?;
    +        let checked_signers2 = checked_signer_slots(&self.signers2)
    +            .ok_or(EquivocationProofError::InvalidJustification)?;
    +
             // Check that the proposals are not equal and in the right order:
             match self.proposal_hash1.cmp(&self.proposal_hash2) {
                 Ordering::Less => {}
    @@ -538,24 +543,27 @@ impl DoubleVoteProof {
             #[allow(clippy::redundant_clone)]
             if !validator_slots
                 .clone()
    -            .any(|s| self.signers1.contains(s as usize) && self.signers2.contains(s as usize))
    +            .any(|s| checked_signers1.contains(&s) && checked_signers2.contains(&s))
             {
                 return Err(EquivocationProofError::NoOverlap);
             }
     
             let verify =
    -            |proposal_hash, signers: &BitSet, signature| -> Result<(), EquivocationProofError> {
    +            |proposal_hash, signers: &[u16], signature| -> Result<(), EquivocationProofError> {
                     // Calculate the message that was actually signed by the validators.
                     let message = TendermintVote {
                         proposal_hash,
                         id: self.tendermint_id.clone(),
                     };
                     // Verify the signatures.
                     let mut agg_pk = AggregatePublicKey::new();
    -                for (i, pk) in validators.voting_keys().iter().enumerate() {
    -                    if signers.contains(i) {
    -                        agg_pk.aggregate(pk);
    -                    }
    +                for slot in signers {
    +                    let pk = validators
    +                        .get_validator_by_slot_number(*slot)
    +                        .voting_key
    +                        .uncompress()
    +                        .expect("Failed to uncompress CompressedPublicKey");
    +                    agg_pk.aggregate(pk);
                     }
                     if !agg_pk.verify(&message, signature) {
                         return Err(EquivocationProofError::InvalidJustification);
    @@ -565,12 +573,12 @@ impl DoubleVoteProof {
     
             verify(
                 self.proposal_hash1.clone(),
    -            &self.signers1,
    +            &checked_signers1,
                 &self.signature1,
             )?;
             verify(
                 self.proposal_hash2.clone(),
    -            &self.signers2,
    +            &checked_signers2,
                 &self.signature2,
             )?;
             Ok(())
    
  • primitives/block/src/multisig.rs+11 0 modified
    @@ -37,3 +37,14 @@ impl MultiSignature {
             }
         }
     }
    +
    +pub(crate) fn checked_signer_slots(signers: &BitSet) -> Option<Vec<u16>> {
    +    let mut slots = Vec::with_capacity(signers.len());
    +    for slot in signers.iter() {
    +        if slot >= Policy::SLOTS as usize || slot > u16::MAX as usize {
    +            return None;
    +        }
    +        slots.push(slot as u16);
    +    }
    +    Some(slots)
    +}
    
  • primitives/block/src/skip_block.rs+15 6 modified
    @@ -9,7 +9,7 @@ use nimiq_primitives::{
     use nimiq_serde::{Deserialize, Serialize, SerializedMaxSize};
     use nimiq_vrf::VrfEntropy;
     
    -use crate::{MicroBlock, MultiSignature};
    +use crate::{multisig::checked_signer_slots, MicroBlock, MultiSignature};
     
     pub type SignedSkipBlockInfo = SignedMessage<SkipBlockInfo>;
     
    @@ -59,8 +59,18 @@ impl SkipBlockProof {
         /// Verifies the proof. This only checks that the proof is valid for this skip block, not that
         /// the skip block itself is valid.
         pub fn verify(&self, skip_block: &SkipBlockInfo, validators: &Validators) -> bool {
    +        let signer_slots = match checked_signer_slots(&self.sig.signers) {
    +            Some(slots) => slots,
    +            None => {
    +                error!(
    +                    "SkipBlockProof verification failed: signer set contains out-of-range slots."
    +                );
    +                return false;
    +            }
    +        };
    +
             // Check if there are enough votes.
    -        if self.sig.signers.len() < Policy::TWO_F_PLUS_ONE as usize {
    +        if signer_slots.len() < Policy::TWO_F_PLUS_ONE as usize {
                 error!(
                     "SkipBlockProof verification failed: Not enough slots signed the skip block message."
                 );
    @@ -70,12 +80,11 @@ impl SkipBlockProof {
             // Get the public key for each SLOT present in the signature and add them together to get
             // the aggregated public key.
             let agg_pk =
    -            self.sig
    -                .signers
    -                .iter()
    +            signer_slots
    +                .into_iter()
                     .fold(AggregatePublicKey::new(), |mut aggregate, slot| {
                         let pk = validators
    -                        .get_validator_by_slot_number(slot as u16)
    +                        .get_validator_by_slot_number(slot)
                             .voting_key
                             .uncompress()
                             .expect("Failed to uncompress CompressedPublicKey");
    
  • primitives/block/src/tendermint.rs+17 6 modified
    @@ -6,7 +6,7 @@ use nimiq_primitives::{
     };
     use nimiq_serde::{Deserialize, Serialize, SerializedMaxSize};
     
    -use crate::{MacroBlock, MultiSignature};
    +use crate::{multisig::checked_signer_slots, MacroBlock, MultiSignature};
     
     /// The proof for a block produced by Tendermint.
     #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, SerializedMaxSize)]
    @@ -35,8 +35,16 @@ impl TendermintProof {
                 Some(x) => x,
             };
     
    +        let signer_slots = match checked_signer_slots(&justification.sig.signers) {
    +            Some(slots) => slots,
    +            None => {
    +                error!("Invalid justification - signer set contains out-of-range slots!");
    +                return false;
    +            }
    +        };
    +
             // Check if there are enough votes.
    -        if justification.votes() < Policy::TWO_F_PLUS_ONE {
    +        if signer_slots.len() < Policy::TWO_F_PLUS_ONE as usize {
                 error!("Invalid justification - not enough votes!");
                 return false;
             }
    @@ -59,10 +67,13 @@ impl TendermintProof {
             // (if they are part of the Multisignature Bitset).
             let mut agg_pk = AggregatePublicKey::new();
     
    -        for (i, pk) in current_validators.voting_keys().iter().enumerate() {
    -            if justification.sig.signers.contains(i) {
    -                agg_pk.aggregate(pk);
    -            }
    +        for slot in signer_slots {
    +            let pk = current_validators
    +                .get_validator_by_slot_number(slot)
    +                .voting_key
    +                .uncompress()
    +                .expect("Failed to uncompress CompressedPublicKey");
    +            agg_pk.aggregate(pk);
             }
     
             // Verify the aggregated signature against our aggregated public key.
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.