VYPR
High severityNVD Advisory· Published Mar 12, 2026· Updated Apr 16, 2026

CVE-2026-32129

CVE-2026-32129

Description

soroban-poseidon provides Poseidon and Poseidon2 cryptographic hash functions for Soroban smart contracts. Poseidon V1 (PoseidonSponge) accepts variable-length inputs without injective padding. When a caller provides fewer inputs than the sponge rate (inputs.len() < T - 1), unused rate positions are implicitly zero-filled. This allows trivial hash collisions: for any input vector [m1, ..., mk] hashed with a sponge of rate > k, hash([m1, ..., mk]) equals hash([m1, ..., mk, 0]) because both produce identical pre-permutation states. This affects any use of PoseidonSponge or poseidon_hash where the number of inputs is less than T - 1 (e.g., hashing 1 input with T=3). Poseidon2 (Poseidon2Sponge) is not affected.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
soroban-poseidoncrates.io
< 25.0.125.0.1

Affected products

1

Patches

1
ceb20d3593fc

Fix Poseidon V1 suffix-zero collision (#10)

8 files changed · +43 65
  • Cargo.lock+11 10 modified
    @@ -1337,9 +1337,9 @@ dependencies = [
     
     [[package]]
     name = "soroban-ledger-snapshot"
    -version = "25.0.2"
    +version = "25.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ab9d1bfa6f7d57307bf8241789b13d3703438e7afa0527aa098a357ef757d3a2"
    +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5"
     dependencies = [
      "serde",
      "serde_json",
    @@ -1358,9 +1358,9 @@ dependencies = [
     
     [[package]]
     name = "soroban-sdk"
    -version = "25.0.2"
    +version = "25.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9953e782d6da30974eea520c2b5f624c28bbc518c3bb926ec581242dd3f9d2a2"
    +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae"
     dependencies = [
      "arbitrary",
      "bytes-lit",
    @@ -1382,9 +1382,9 @@ dependencies = [
     
     [[package]]
     name = "soroban-sdk-macros"
    -version = "25.0.2"
    +version = "25.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "8a8cecb6acc735670dad3303c6a9d2b47e51adfb11224ad5a8ced55fd7b0a600"
    +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1"
     dependencies = [
      "darling 0.20.11",
      "heck",
    @@ -1402,21 +1402,22 @@ dependencies = [
     
     [[package]]
     name = "soroban-spec"
    -version = "25.0.2"
    +version = "25.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "c79501d0636f86fe2c9b1dd7e88b9397415b3493a59b34f466abd7758c84b92b"
    +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed"
     dependencies = [
      "base64",
    + "sha2",
      "stellar-xdr",
      "thiserror",
      "wasmparser",
     ]
     
     [[package]]
     name = "soroban-spec-rust"
    -version = "25.0.2"
    +version = "25.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b520b5fb013fde70796d9a6057591f53817aa0c38f8bad460126f97f59394af9"
    +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256"
     dependencies = [
      "prettyplease",
      "proc-macro2",
    
  • Cargo.toml+1 1 modified
    @@ -14,7 +14,7 @@ rust-version = "1.84.0"
     license = "Apache-2.0"
     
     [workspace.dependencies]
    -soroban-sdk = "25.0.2"
    +soroban-sdk = "25.3.0"
     soroban-poseidon = { path = "." }
     
     [package]
    
  • README.md+1 1 modified
    @@ -82,7 +82,7 @@ let hash2 = sponge.compute_hash(&inputs2);
     
     ## Limitations / Future Work
     
    -1. **Multi-round absorption**: Currently, inputs must fit within a single rate (i.e., `inputs.len() <= T - 1`). Future versions will support absorbing inputs larger than the state size across multiple permutation rounds.
    +1. **Multi-round absorption**: Currently, for Poseidon, inputs must exactly fill the rate (i.e., `inputs.len() == T - 1`), matching circom's behavior where `nInputs` determines `T = nInputs + 1`. Poseidon2 requires inputs to fit within a single rate (i.e., `inputs.len() <= T - 1`). Future versions will support absorbing inputs larger than the state size across multiple permutation rounds.
     
     2. **Persistent parameters**: Make `PoseidonParams` / `Poseidon2Params` a `#[contracttype]` so they can be stored as contract data and reduce the contract size.
     
    
  • src/lib.rs+2 3 modified
    @@ -62,7 +62,7 @@ impl Field for BlsScalar {
     ///
     /// # Type Parameters
     ///
    -/// - `T`: State size. Must be ≥ `inputs.len() + 1` (rate = T-1, capacity = 1).
    +/// - `T`: State size. Must equal `inputs.len() + 1` (rate = T-1, capacity = 1).
     /// - `F`: Field type. Use [`BnScalar`] for BN254 or [`BlsScalar`] for BLS12-381.
     ///
     /// # Supported Configurations
    @@ -72,8 +72,7 @@ impl Field for BlsScalar {
     ///
     /// # Panics
     ///
    -/// - if `inputs.is_empty()`
    -/// - if `inputs.len() > T - 1` (rate exceeded)
    +/// - if `inputs.len() != T - 1`
     /// - if any input value ≥ the field modulus (inputs must be valid field elements)
     ///
     /// # Example
    
  • src/poseidon2/sponge.rs+7 2 modified
    @@ -174,8 +174,13 @@ where
         }
     
         pub(crate) fn absorb(&mut self, inputs: &Vec<U256>) {
    -        // Absorb into rate portion of state (positions 0..RATE)
    -        assert!(inputs.len() <= Self::RATE);
    +        // <= is safe here because IV = input_len << 64 provides domain
    +        // separation for different-length inputs. This differs from Poseidon V1
    +        // (which uses IV=0 and therefore requires == RATE).
    +        assert!(
    +            inputs.len() <= Self::RATE,
    +            "Poseidon2: inputs.len() must not exceed rate (T - 1)"
    +        );
             let modulus = F::modulus(&self.env);
             for i in 0..inputs.len() {
                 let v = inputs.get_unchecked(i);
    
  • src/poseidon/sponge.rs+8 7 modified
    @@ -217,7 +217,10 @@ where
         }
     
         pub(crate) fn absorb(&mut self, inputs: &Vec<U256>) {
    -        assert!(inputs.len() <= Self::RATE);
    +        assert!(
    +            inputs.len() == Self::RATE,
    +            "Poseidon: inputs.len() must equal rate (T - 1)"
    +        );
             let modulus = F::modulus(&self.env);
             for i in 0..inputs.len() {
                 let v = inputs.get_unchecked(i);
    @@ -243,15 +246,13 @@ where
         /// implementation](https://github.com/iden3/circomlib/blob/master/circuits/poseidon.circom).
         ///
         /// # Panics
    -    /// - if `inputs.is_empty()`. Empty inputs are not allowed because
    -    ///   `hash([])` would collide with `hash([0])`. Circom also disallows empty
    -    ///   inputs.
    -    /// - if `inputs.len() > RATE` (i.e., `T - 1`). For larger inputs,
    -    ///   multi-round absorption would be needed (not yet implemented).
    +    /// - if `inputs.len() != RATE` (i.e., must equal `T - 1` exactly).
    +    ///   This matches circom's Poseidon where `nInputs` determines
    +    ///   `T = nInputs + 1`, ensuring the rate is always fully used and
    +    ///   preventing suffix-zero collisions from implicit zero-padding.
         /// - if any input value is greater than or equal to the field modulus.
         ///   All inputs must be valid field elements (i.e., less than the modulus).
         pub fn compute_hash(&mut self, inputs: &Vec<U256>) -> U256 {
    -        assert!(!inputs.is_empty(), "Poseidon: inputs cannot be empty");
             self.reset_state();
             self.absorb(inputs);
             self.squeeze()
    
  • src/tests/poseidon2.rs+1 1 modified
    @@ -852,7 +852,7 @@ fn test_poseidon2_bn254_partial_rate_t4_2_inputs() {
     // ============================================================================
     
     #[test]
    -#[should_panic(expected = "assertion failed")]
    +#[should_panic(expected = "Poseidon2: inputs.len() must not exceed rate (T - 1)")]
     fn test_poseidon2_sponge_inputs_exceed_rate_t4() {
         let env = Env::default();
     
    
  • src/tests/poseidon.rs+12 40 modified
    @@ -711,53 +711,31 @@ fn test_poseidon_sponge_matches_hash_function() {
     }
     
     // ============================================================================
    -// Partial rate tests (inputs.len() < RATE)
    +// Partial rate rejection tests
     // ============================================================================
     
    -// Note: Circom's Poseidon always uses T = inputs.len() + 1 (full rate), so there are
    -// no reference test vectors for partial rate scenarios. These tests verify that:
    -// 1. Our implementation handles partial rate correctly (zero-padding)
    -// 2. Results are deterministic
    -// 3. Different T values produce different results (as expected)
    +// Poseidon V1 requires inputs.len() == RATE (full rate), matching circom's
    +// behavior where nInputs determines T = nInputs + 1. Partial-rate inputs are
    +// rejected to prevent suffix-zero collisions from implicit zero-padding.
     
    -// Test hashing 1 input with T=3 (rate=2) - partial rate usage
    -// This verifies zero-padding works correctly when inputs don't fill the rate
     #[test]
    -fn test_poseidon_bn254_partial_rate_t3_1_input() {
    +#[should_panic(expected = "inputs.len() must equal rate")]
    +fn test_poseidon_bn254_rejects_partial_rate() {
         let env = Env::default();
     
    -    // 1 input with T=3 (rate=2) - only half the rate is used
    -    let inputs = vec![
    -        &env,
    -        U256::from_be_bytes(
    -            &env,
    -            &bytesn!(
    -                &env,
    -                0x0000000000000000000000000000000000000000000000000000000000000001
    -            )
    -            .into(),
    -        ),
    -    ];
    +    // 1 input with T=3 (rate=2) - partial rate must be rejected
    +    let inputs = vec![&env, U256::from_u32(&env, 1)];
     
         let mut sponge = PoseidonSponge::<3, BnScalar>::new(&env);
    -    let result = sponge.compute_hash(&inputs);
    -
    -    // Result should be deterministic
    -    let result2 = sponge.compute_hash(&inputs);
    -    assert_eq!(result, result2);
    -
    -    // Verify it's different from using T=2 (full rate with 1 input)
    -    let mut sponge_t2 = PoseidonSponge::<2, BnScalar>::new(&env);
    -    let result_t2 = sponge_t2.compute_hash(&inputs);
    -    assert_ne!(result, result_t2);
    +    sponge.compute_hash(&inputs); // should panic
     }
     
     // ============================================================================
     // Failure mode tests
     // ============================================================================
     
     #[test]
    -#[should_panic(expected = "assertion failed")]
    +#[should_panic(expected = "inputs.len() must equal rate")]
     fn test_poseidon_sponge_inputs_exceed_rate() {
         let env = Env::default();
     
    @@ -896,15 +874,9 @@ fn test_poseidon_bls12_381_input_below_modulus_accepted() {
         let _ = sponge.compute_hash(&inputs);
     }
     
    -// Empty inputs are explicitly rejected in Poseidon because:
    -// 1. Circom rejects them
    -// 2. With IV=0, hash([]) would collide with hash([0]) since both result in
    -//    permuting state [0, 0, ...]
    -//
    -// This differs from Poseidon2, which uses IV = `input_len << 64`, making
    -// hash([]) and hash([0]) produce different outputs.
    +// Empty inputs are rejected because inputs.len() must equal RATE (>= 1).
     #[test]
    -#[should_panic(expected = "Poseidon: inputs cannot be empty")]
    +#[should_panic(expected = "inputs.len() must equal rate")]
     fn test_poseidon_bn254_empty_inputs_rejected() {
         let env = Env::default();
     
    

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.