Skip to content

Latest commit

 

History

History
323 lines (235 loc) · 11.9 KB

File metadata and controls

323 lines (235 loc) · 11.9 KB

Critical Vulner: How a Single Vote Could Halt UltraDAG Nodes

Published: April 12, 2026
Severity: Critical (CVSS 8.6)
Status: ✅ Patched in this commit
Reporter: Sumitshah00 (via GitHub Security Advisory)


Summary

We recently received a critical vulnerability report from security researcher Sumitshah00 through our bug bounty program. The vulnerability allowed any network participant (even those without special privileges) to trigger a fatal node halt by submitting a single malformed governance vote transaction.

Impact: Complete denial-of-service — affected nodes would exit with code 101, requiring manual restart and potentially causing network-wide consensus failures if multiple validators were targeted.

Root Cause: A subtle bug in transaction processing order caused state mutation (fee debit and nonce increment) to occur before authorization checks, leading to supply accounting mismatches that the node correctly treated as unrecoverable corruption.

Fix: Reorder validation to check authorization before any state mutation, ensuring atomic transaction semantics.


The Vulnerability in Detail

Attack Vector

UltraDAG supports "Smart Operations" (SmartOps) — a flexible transaction type that enables various account operations including governance voting, proposal creation, staking, delegation, and more. These operations are signed using authorized keys (Ed25519, P256, or WebAuthn passkeys).

The vulnerability existed in the apply_smart_op_tx function in crates/ultradag-coin/src/state/engine.rs:

pub fn apply_smart_op_tx(&mut self, tx: &SmartOpTx, current_round: u64) -> Result<(), CoinError> {
    // ❌ BUG: Fee debited BEFORE authorization check
    if tx.fee > 0 {
        self.debit(&tx.from, tx.fee)?;
    }
    // ❌ BUG: Nonce incremented BEFORE authorization check
    self.increment_nonce(&tx.from);

    match &tx.operation {
        // ... other operations ...
        
        SmartOpType::Vote { proposal_id, approve } => {
            // ✅ Authorization check happens TOO LATE
            if !self.is_council_member(&tx.from) {
                return Err(CoinError::ValidationError("only council members can vote".into()));
            }
            self.votes.insert((*proposal_id, tx.from), *approve);
        }
        // ...
    }
    
    Ok(())
}

The Fatal Error Path

When a non-council member submitted a Vote SmartOp, here's what happened:

  1. Fee Debit: MIN_FEE_SATS (10,000 sats) burned from attacker's balance
  2. Nonce Increment: Attacker's nonce advanced from 0 → 1
  3. Authorization Check: "Only council members can vote" → ERROR RETURNED
  4. Outer Error Handler: The finalized vertex processing code caught the error and incremented the nonce again (1 → 2)
  5. Supply Invariant Check: The node verified: liquid + staked + delegated + treasury + bridge == total_supply
  6. Fatal Halt: The fee was burned but the operation rejected, causing a supply mismatch → node exits with code 101

Exploit Script

An attacker could:

  1. Generate a fresh P256 keypair (no special privileges needed)
  2. Derive the SmartAccount address: blake3("smart_account_p256" || pubkey)[:20]
  3. Fund the account with any amount ≥ MIN_FEE_SATS
  4. Submit a Vote SmartOp with valid signature
  5. Node halts immediately upon processing the transaction

The attack required no council membership, no staking, and no special access — just a funded account and a valid signature.


Why This Was Critical

1. Zero-Privilege Attack

Any network participant could trigger this, not just validators or council members.

2. Immediate Impact

Single transaction → immediate node halt. No preparation or setup required.

3. Network-Wide Risk

If multiple validators received the same malicious vertex (via P2P propagation), they would all halt, causing complete network paralysis.

4. State Corruption

The supply invariant check correctly identified this as unrecoverable corruption. The node had no safe recovery path — only a manual restart with potential state inconsistency.

5. Nonce Corruption

Even if the node didn't halt, the double nonce increment would break replay protection for the affected account, making future transactions impossible without account recovery.


The Fix

Principle: Authorization Before Mutation

The fix follows a fundamental security principle: validate authorization before mutating state. This ensures transactions are atomic — they either succeed completely or leave no trace.

pub fn apply_smart_op_tx(&mut self, tx: &SmartOpTx, current_round: u64) -> Result<(), CoinError> {
    use crate::tx::smart_account::SmartOpType;

    // ✅ Ensure smart account exists (auto-registration)
    self.ensure_smart_account_at_round(&tx.from, current_round);

    // ✅ NEW: Pre-validate authorization BEFORE any state mutation
    match &tx.operation {
        SmartOpType::Vote { .. } | SmartOpType::CreateProposal { .. } => {
            if !self.is_council_member(&tx.from) {
                return Err(CoinError::ValidationError(
                    "only council members can perform governance operations".into()
                ));
            }
        }
        _ => {}
    }

    // ✅ Now safe to mutate state
    if tx.fee > 0 {
        self.debit(&tx.from, tx.fee)?;
    }
    self.increment_nonce(&tx.from);

    match &tx.operation {
        SmartOpType::Vote { proposal_id, approve } => {
            // Authorization already verified above
            self.votes.insert((*proposal_id, tx.from), *approve);
        }
        // ... other operations ...
    }
    
    Ok(())
}

Remove Double Nonce Increment

The outer error handler in apply_finalized_vertices was also fixed to avoid incrementing the nonce when apply_smart_op_tx fails:

crate::tx::Transaction::SmartOp(op_tx) => {
    if let Err(e) = self.apply_smart_op_tx(op_tx, vertex.round) {
        tracing::warn!("Skipping invalid SmartOp tx in finalized vertex: {}", e);
        // ✅ FIXED: Nonce already incremented by apply_smart_op_tx (if it got that far).
        // Do NOT increment again — that would corrupt the nonce.
        self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string());
        continue;
    }
}

Note: With the authorization-before-mutation fix, unauthorized operations now fail before the nonce increment, so the nonce remains unchanged. This is the correct behavior.


Regression Tests

We added comprehensive regression tests in crates/ultradag-coin/tests/smartop_authorization_fix.rs:

Test 1: Non-Council Vote Rejected Before Mutation

#[test]
fn test_non_council_vote_rejected_before_mutation() {
    // Setup attacker with P256 key
    let balance_before = state.balance(&attacker);
    let nonce_before = state.nonce(&attacker);
    
    // Submit unauthorized Vote SmartOp
    let result = state.apply_smart_op_tx(&op, 1);
    
    assert!(result.is_err());
    // CRITICAL: State must NOT have changed
    assert_eq!(state.balance(&attacker), balance_before);
    assert_eq!(state.nonce(&attacker), nonce_before);
}

Test 2: No Double Nonce Increment

#[test]
fn test_failed_smartop_no_double_nonce_increment() {
    // Submit unauthorized Vote through finalized vertex path
    let result = state.apply_finalized_vertices(&[vertex]);
    
    assert!(result.is_ok()); // Should not be fatal
    assert_eq!(state.nonce(&attacker), nonce_before); // Not incremented
    assert_eq!(state.balance(&attacker), balance_before); // Fee not debited
}

Test 3: Supply Invariant Preserved

#[test]
fn test_supply_invariant_preserved_after_rejected_smartop() {
    let result = state.apply_finalized_vertices(&[vertex]);
    
    assert!(result.is_ok()); // No fatal error
    assert_eq!(state.balance(&attacker), balance_before); // No state change
}

Test 4: Valid Council Operations Still Work

#[test]
fn test_council_vote_succeeds_and_mutates_state() {
    // Council member should still be able to vote
    let result = state.apply_smart_op_tx(&op, 1);
    assert!(result.is_ok());
    assert_eq!(state.nonce(&council_addr), nonce_before + 1);
}

All 6 tests pass ✅


Lessons Learned

1. Transaction Atomicity is Non-Negotiable

Every transaction must follow the pattern:

Validate → Authorize → Mutate State → Commit

If any step fails, all previous steps in that transaction must be rolled back (or never executed).

2. Supply Invariants are Your Friend

The supply invariant check that triggered the fatal halt was correct behavior. It detected state corruption and prevented further damage. Without it, the bug could have gone unnoticed and caused worse issues.

3. Error Paths Need the Same Rigor

The double nonce increment in the error handler was a secondary bug that compounded the primary issue. Error paths must be tested with the same rigor as happy paths.

4. Bug Bounties Work

This vulnerability was found by an external security researcher who carefully analyzed the code execution flow. We're grateful to Sumitshah00 for the detailed report, exploit script, and fix recommendations.

5. Test the Attack, Not Just the Fix

Our regression tests don't just verify the fix works — they reproduce the exact attack scenario and verify it no longer succeeds. This prevents future regressions.


Timeline

  • 2026-04-11 23:00 UTC: Vulnerability reported by Sumitshah00
  • 2026-04-12 09:00 UTC: Vulnerability confirmed and validated
  • 2026-04-12 10:30 UTC: Fix implemented and tested
  • 2026-04-12 11:00 UTC: Regression tests added
  • 2026-04-12 11:30 UTC: Blog post published

Acknowledgments

Huge thanks to Sumitshah00 for discovering and responsibly disclosing this vulnerability. The detailed report, complete with exploit script and fix recommendations, made our response fast and effective.

Reward: Bug bounty payout sent to tudg17lzd76ue95ht07hxzna8mzey4tkpk85jtjns2d


For Node Operators

Do I Need to Upgrade?

Yes, immediately. Any node running the previous version is vulnerable to remote fatal-attack DoS.

What If My Node Was Already Attacked?

  1. Do not restart the node with the old binary
  2. Upgrade to the patched version
  3. Restart — the node will reload state from disk (last checkpoint)
  4. Any transactions after the last checkpoint will be re-synced from peers

How Do I Know If I Were Attacked?

Check your node logs for:

ERROR FATAL: supply invariant broken — node must halt: liquid=X staked=Y ...

And exit code 101.


Technical Appendix: Why the Supply Invariant Broke

The supply invariant verifies:

liquid + staked + delegated + treasury + bridge_reserve == total_supply

When the attacker's Vote failed:

  • Liquid balance decreased by MIN_FEE_SATS (fee debited)
  • Treasury did NOT increase (fee not credited to proposer — transaction failed)
  • Total supply remained unchanged

This meant: sum < total_supply by exactly MIN_FEE_SATS.

The invariant correctly identified this as impossible — fees can only be:

  1. Debited from sender AND credited to proposer (successful tx), OR
  2. Not debited at all (failed tx before fee debit)

The bug created an impossible third state: fee burned into nowhere.


References

  • GitHub Security Advisory: GHSA-q8wx-2crx-c7pp
  • Files Changed:
    • crates/ultradag-coin/src/state/engine.rs (authorization check + error handler)
    • crates/ultradag-coin/tests/smartop_authorization_fix.rs (new regression tests)
  • Related Code:
    • verify_smart_op() — signature verification with auto-registration
    • apply_finalized_vertices() — batch vertex processing
    • StateSnapshot::verify_consistency() — supply invariant check

Questions? Reach out to security@ultradag.com or open a GitHub Discussion.

Stay secure,
The UltraDAG Team