Published: April 12, 2026
Severity: Critical (CVSS 8.6)
Status: ✅ Patched in this commit
Reporter: Sumitshah00 (via GitHub Security Advisory)
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.
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(())
}When a non-council member submitted a Vote SmartOp, here's what happened:
- Fee Debit:
MIN_FEE_SATS(10,000 sats) burned from attacker's balance - Nonce Increment: Attacker's nonce advanced from 0 → 1
- Authorization Check: "Only council members can vote" → ERROR RETURNED
- Outer Error Handler: The finalized vertex processing code caught the error and incremented the nonce again (1 → 2)
- Supply Invariant Check: The node verified:
liquid + staked + delegated + treasury + bridge == total_supply - Fatal Halt: The fee was burned but the operation rejected, causing a supply mismatch → node exits with code 101
An attacker could:
- Generate a fresh P256 keypair (no special privileges needed)
- Derive the SmartAccount address:
blake3("smart_account_p256" || pubkey)[:20] - Fund the account with any amount ≥
MIN_FEE_SATS - Submit a Vote SmartOp with valid signature
- 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.
Any network participant could trigger this, not just validators or council members.
Single transaction → immediate node halt. No preparation or setup required.
If multiple validators received the same malicious vertex (via P2P propagation), they would all halt, causing complete network paralysis.
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.
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 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(())
}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.
We added comprehensive regression tests in crates/ultradag-coin/tests/smartop_authorization_fix.rs:
#[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]
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]
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]
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 ✅
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).
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.
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.
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.
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.
- 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
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
Yes, immediately. Any node running the previous version is vulnerable to remote fatal-attack DoS.
- Do not restart the node with the old binary
- Upgrade to the patched version
- Restart — the node will reload state from disk (last checkpoint)
- Any transactions after the last checkpoint will be re-synced from peers
Check your node logs for:
ERROR FATAL: supply invariant broken — node must halt: liquid=X staked=Y ...
And exit code 101.
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:
- Debited from sender AND credited to proposer (successful tx), OR
- Not debited at all (failed tx before fee debit)
The bug created an impossible third state: fee burned into nowhere.
- 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-registrationapply_finalized_vertices()— batch vertex processingStateSnapshot::verify_consistency()— supply invariant check
Questions? Reach out to security@ultradag.com or open a GitHub Discussion.
Stay secure,
The UltraDAG Team