Skip to content

Commit dd656db

Browse files
UltraDAGcomClaude Opus 4.6 (1M context)
andcommitted
fix(bridge): enforce validator-count and quorum floors on release path
Closes GHSA-6gwf-frh8-ppw7. A sole active validator (n=1) could drain the bridge_reserve in a single transaction: ceil(2n/3) degrades to 1, and apply_bridge_release_tx had no size floor on the active set and no proof binding for deposit_nonce (a fabricated nonce was accepted). Two new constants close the exploit: - MIN_BRIDGE_VALIDATORS = 4: releases rejected if active set is smaller (matches BFT_MIN_ACTIVE_VALIDATORS, tolerates 1 Byzantine fault). - MIN_BRIDGE_QUORUM = 3: hard floor on distinct votes, independent of active-set size. Threshold is now max(ceil(2n/3), 3). Deposit-nonce ↔ source-chain proof binding (reporter's rec #2) is a larger design change (SPV / on-chain deposit registry) and is tracked as a separate hardening item. Fixes #1 + #3 here are sufficient to block the demonstrated drain. Regression tests in tests/bridge_release_quorum.rs: - single_validator_cannot_drain_bridge — mirrors reporter PoC, now fails to drain - releases_blocked_just_below_min_bridge_validators - healthy_set_requires_min_quorum_votes Ledger: BB-2026-0002, 10,000 UDAG (Critical floor) to Sumitshah00. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4ee795b commit dd656db

4 files changed

Lines changed: 261 additions & 6 deletions

File tree

crates/ultradag-coin/src/constants.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,19 @@ pub const MAX_ACTIVE_VALIDATORS: usize = 100;
257257
/// With fewer than 7 validators, the system cannot guarantee safety.
258258
pub const MIN_ACTIVE_VALIDATORS: usize = 7;
259259

260+
/// Minimum active validator set size required to process a bridge release.
261+
/// Prevents a single or small group of validators from unilaterally draining
262+
/// the bridge reserve if the active set degrades (e.g., after slashing or
263+
/// inactivity). A 4-validator floor tolerates 1 Byzantine fault (3f+1, f=1)
264+
/// and matches BFT_MIN_ACTIVE_VALIDATORS.
265+
pub const MIN_BRIDGE_VALIDATORS: usize = 4;
266+
267+
/// Absolute floor on the bridge release quorum, independent of active-set size.
268+
/// The dynamic threshold is `ceil(2n/3)`; this floor guarantees a minimum
269+
/// number of independent votes even if the dynamic threshold is lower.
270+
/// Together with MIN_BRIDGE_VALIDATORS this makes unilateral release impossible.
271+
pub const MIN_BRIDGE_QUORUM: usize = 3;
272+
260273
/// Epoch length in rounds. Validator set recalculated at epoch boundaries.
261274
/// Matches halving interval for clean alignment.
262275
pub const EPOCH_LENGTH_ROUNDS: u64 = 210_000;

crates/ultradag-coin/src/state/engine.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2262,6 +2262,19 @@ impl StateEngine {
22622262
return Err(CoinError::ValidationError("only active validators can submit bridge releases".into()));
22632263
}
22642264

2265+
// SECURITY (GHSA-6gwf-frh8-ppw7): Refuse to process bridge releases when the
2266+
// active validator set is too small. Without this, an attacker who becomes the
2267+
// sole active validator (e.g., after mass slashing / inactivity / fresh genesis)
2268+
// would hit ceil(2n/3) = 1 quorum and drain the bridge reserve in a single tx.
2269+
let active_n = self.active_validator_set.len();
2270+
if active_n < crate::constants::MIN_BRIDGE_VALIDATORS {
2271+
return Err(CoinError::ValidationError(format!(
2272+
"bridge releases require at least {} active validators, have {}",
2273+
crate::constants::MIN_BRIDGE_VALIDATORS,
2274+
active_n
2275+
)));
2276+
}
2277+
22652278
// Validate amount range
22662279
if tx.amount < crate::constants::MIN_BRIDGE_AMOUNT_SATS {
22672280
return Err(CoinError::ValidationError("below minimum bridge amount".into()));
@@ -2337,9 +2350,12 @@ impl StateEngine {
23372350
voters.insert(tx.from);
23382351
let vote_count = voters.len();
23392352

2340-
// Calculate threshold: ceil(2n/3) where n = active validator count
2353+
// Calculate threshold: max(ceil(2n/3), MIN_BRIDGE_QUORUM).
2354+
// The MIN_BRIDGE_QUORUM floor prevents the dynamic threshold from collapsing
2355+
// to 1 or 2 when the active set is small, which — combined with the
2356+
// MIN_BRIDGE_VALIDATORS gate above — closes GHSA-6gwf-frh8-ppw7.
23412357
let n = self.active_validator_set.len();
2342-
let threshold = (2 * n).div_ceil(3);
2358+
let threshold = (2 * n).div_ceil(3).max(crate::constants::MIN_BRIDGE_QUORUM);
23432359

23442360
// Always increment the submitting validator's nonce (vote recorded)
23452361
self.increment_nonce(&tx.from);
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! Regression tests for GHSA-6gwf-frh8-ppw7.
2+
//!
3+
//! Before the fix, a single validator (n=1) could drain the bridge reserve
4+
//! because the dynamic quorum `ceil(2n/3)` collapses to 1 when n=1. These
5+
//! tests lock in the two floors that close the exploit:
6+
//!
7+
//! - `MIN_BRIDGE_VALIDATORS`: refuse releases when the active set is too small.
8+
//! - `MIN_BRIDGE_QUORUM`: floor on the number of distinct validator votes
9+
//! required, independent of active-set size.
10+
11+
use ultradag_coin::{
12+
StateEngine, SecretKey,
13+
tx::{BridgeDepositTx, StakeTx, MIN_STAKE_SATS},
14+
tx::bridge::BridgeReleaseTx,
15+
address::Signature,
16+
constants::{COIN, MIN_BRIDGE_QUORUM, MIN_BRIDGE_VALIDATORS, SUPPORTED_BRIDGE_CHAIN_IDS},
17+
};
18+
19+
fn signed_stake(sk: &SecretKey, amount: u64, nonce: u64) -> StakeTx {
20+
let mut tx = StakeTx {
21+
from: sk.address(),
22+
amount,
23+
nonce,
24+
pub_key: sk.verifying_key().to_bytes(),
25+
signature: Signature([0u8; 64]),
26+
};
27+
tx.signature = sk.sign(&tx.signable_bytes());
28+
tx
29+
}
30+
31+
fn signed_bridge_deposit(sk: &SecretKey, amount: u64, nonce: u64, fee: u64) -> BridgeDepositTx {
32+
let mut tx = BridgeDepositTx {
33+
from: sk.address(),
34+
recipient: [0x11u8; 20],
35+
amount,
36+
destination_chain_id: SUPPORTED_BRIDGE_CHAIN_IDS[0],
37+
nonce,
38+
fee,
39+
pub_key: sk.verifying_key().to_bytes(),
40+
signature: Signature([0u8; 64]),
41+
};
42+
tx.signature = sk.sign(&tx.signable_bytes());
43+
tx
44+
}
45+
46+
fn signed_bridge_release(
47+
sk: &SecretKey,
48+
recipient: ultradag_coin::address::Address,
49+
amount: u64,
50+
deposit_nonce: u64,
51+
nonce: u64,
52+
) -> BridgeReleaseTx {
53+
let mut tx = BridgeReleaseTx {
54+
from: sk.address(),
55+
recipient,
56+
amount,
57+
source_chain_id: SUPPORTED_BRIDGE_CHAIN_IDS[0],
58+
deposit_nonce,
59+
nonce,
60+
pub_key: sk.verifying_key().to_bytes(),
61+
signature: Signature([0u8; 64]),
62+
};
63+
tx.signature = sk.sign(&tx.signable_bytes());
64+
tx
65+
}
66+
67+
/// Regression: GHSA-6gwf-frh8-ppw7.
68+
/// A sole active validator must NOT be able to release funds from the bridge reserve.
69+
#[test]
70+
fn single_validator_cannot_drain_bridge() {
71+
let mut state = StateEngine::new_with_genesis();
72+
73+
let attacker = SecretKey::generate();
74+
let victim = SecretKey::generate();
75+
76+
state.faucet_credit(&attacker.address(), MIN_STAKE_SATS).unwrap();
77+
state.faucet_credit(&victim.address(), 5 * COIN).unwrap();
78+
79+
// Attacker becomes sole active validator (n = 1).
80+
let stake_tx = signed_stake(&attacker, MIN_STAKE_SATS, 0);
81+
state.apply_stake_tx(&stake_tx).unwrap();
82+
state.recalculate_active_set();
83+
assert!(state.is_active_validator(&attacker.address()));
84+
assert_eq!(state.active_validators().len(), 1);
85+
86+
// Legit deposit seeds the bridge reserve.
87+
let deposit_amount = 3 * COIN;
88+
let deposit_fee = 10_000;
89+
let dep = signed_bridge_deposit(&victim, deposit_amount, 0, deposit_fee);
90+
state.apply_bridge_lock_tx(&dep, None, None).unwrap();
91+
assert_eq!(state.bridge_reserve(), deposit_amount);
92+
93+
// Attempted drain: fabricated deposit_nonce, self-recipient.
94+
let attacker_addr = attacker.address();
95+
let reserve_before = state.bridge_reserve();
96+
let attacker_before = state.balance(&attacker_addr);
97+
let rel = signed_bridge_release(&attacker, attacker_addr, deposit_amount, 999_999, 1);
98+
let result = state.apply_bridge_release_tx(&rel);
99+
100+
// Must be rejected by the MIN_BRIDGE_VALIDATORS gate.
101+
assert!(result.is_err(), "drain attempt with n=1 must be rejected, got: {:?}", result);
102+
let err_msg = format!("{:?}", result.err().unwrap());
103+
assert!(
104+
err_msg.contains("active validators"),
105+
"expected rejection to cite active-validator floor, got: {err_msg}"
106+
);
107+
108+
// No state mutation on the reserve or attacker balance.
109+
assert_eq!(state.bridge_reserve(), reserve_before);
110+
assert_eq!(state.balance(&attacker_addr), attacker_before);
111+
}
112+
113+
/// With exactly `MIN_BRIDGE_VALIDATORS - 1` active validators, releases are still blocked.
114+
#[test]
115+
fn releases_blocked_just_below_min_bridge_validators() {
116+
let mut state = StateEngine::new_with_genesis();
117+
118+
let validators: Vec<SecretKey> = (0..MIN_BRIDGE_VALIDATORS - 1)
119+
.map(|_| SecretKey::generate())
120+
.collect();
121+
122+
for sk in &validators {
123+
state.faucet_credit(&sk.address(), MIN_STAKE_SATS).unwrap();
124+
let stake_tx = signed_stake(sk, MIN_STAKE_SATS, 0);
125+
state.apply_stake_tx(&stake_tx).unwrap();
126+
}
127+
state.recalculate_active_set();
128+
assert_eq!(state.active_validators().len(), MIN_BRIDGE_VALIDATORS - 1);
129+
130+
// Seed the reserve.
131+
let donor = SecretKey::generate();
132+
state.faucet_credit(&donor.address(), 10 * COIN).unwrap();
133+
let dep = signed_bridge_deposit(&donor, 5 * COIN, 0, 10_000);
134+
state.apply_bridge_lock_tx(&dep, None, None).unwrap();
135+
136+
let v0 = &validators[0];
137+
let rel = signed_bridge_release(v0, v0.address(), 1 * COIN, 42, 1);
138+
let result = state.apply_bridge_release_tx(&rel);
139+
assert!(result.is_err(), "release must be blocked below MIN_BRIDGE_VALIDATORS");
140+
}
141+
142+
/// Normal path: with a healthy set, a single vote is not enough (MIN_BRIDGE_QUORUM floor)
143+
/// but quorum is reachable once enough independent validators attest.
144+
#[test]
145+
fn healthy_set_requires_min_quorum_votes() {
146+
let mut state = StateEngine::new_with_genesis();
147+
148+
// Use MIN_BRIDGE_VALIDATORS validators — small enough that ceil(2n/3) <= MIN_BRIDGE_QUORUM,
149+
// so the MIN_BRIDGE_QUORUM floor is the effective threshold.
150+
let n = MIN_BRIDGE_VALIDATORS;
151+
let validators: Vec<SecretKey> = (0..n).map(|_| SecretKey::generate()).collect();
152+
for sk in &validators {
153+
state.faucet_credit(&sk.address(), MIN_STAKE_SATS).unwrap();
154+
let stake_tx = signed_stake(sk, MIN_STAKE_SATS, 0);
155+
state.apply_stake_tx(&stake_tx).unwrap();
156+
}
157+
state.recalculate_active_set();
158+
assert_eq!(state.active_validators().len(), n);
159+
160+
// Seed the reserve.
161+
let donor = SecretKey::generate();
162+
state.faucet_credit(&donor.address(), 10 * COIN).unwrap();
163+
let dep = signed_bridge_deposit(&donor, 5 * COIN, 0, 10_000);
164+
state.apply_bridge_lock_tx(&dep, None, None).unwrap();
165+
166+
let recipient = SecretKey::generate().address();
167+
let release_amount = 1 * COIN;
168+
let deposit_nonce = 777;
169+
170+
// First (MIN_BRIDGE_QUORUM - 1) validators vote: release must NOT execute yet.
171+
// Each validator's account nonce is 1 after their StakeTx, so bridge vote uses nonce 1.
172+
let reserve_before = state.bridge_reserve();
173+
for sk in validators.iter().take(MIN_BRIDGE_QUORUM - 1) {
174+
let rel = signed_bridge_release(sk, recipient, release_amount, deposit_nonce, 1);
175+
state.apply_bridge_release_tx(&rel).unwrap();
176+
}
177+
assert_eq!(
178+
state.bridge_reserve(),
179+
reserve_before,
180+
"reserve must be untouched before MIN_BRIDGE_QUORUM votes"
181+
);
182+
183+
// The MIN_BRIDGE_QUORUM-th vote crosses the floor: release executes.
184+
let crossing_voter = &validators[MIN_BRIDGE_QUORUM - 1];
185+
let rel_cross = signed_bridge_release(crossing_voter, recipient, release_amount, deposit_nonce, 1);
186+
state.apply_bridge_release_tx(&rel_cross).unwrap();
187+
188+
assert_eq!(
189+
state.bridge_reserve(),
190+
reserve_before - release_amount,
191+
"reserve must decrement once MIN_BRIDGE_QUORUM is reached"
192+
);
193+
assert_eq!(state.balance(&recipient), release_amount);
194+
}

docs/security/bug-bounty/LEDGER.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Program Start:** March 8, 2026
44
**Total Allocated:** 500,000 UDAG (mainnet)
5-
**Total Awarded:** 15,000 UDAG
5+
**Total Awarded:** 25,000 UDAG
66
**Total Paid (Testnet):** 0 UDAG (pending — faucet rate-limited)
77
**UDAG Mainnet Token:** [`0x9cFD2011DF13d9E394B5Bb59f0f7e7A5C512155b`](https://arbiscan.io/token/0x9cFD2011DF13d9E394B5Bb59f0f7e7A5C512155b) (Arbitrum One, deployed 2026-04-12)
88
**Bounty Payment Source:** Genesis allocation holder `0x9aEcb515361af7980eaa16fE40c064f69738EbF9` (to be reimbursed from treasury post-emission)
@@ -48,6 +48,38 @@ Fix: 45bcf706, 2f5a3a23
4848
Status: Validated / Fixed / Testnet Paid / Pending Mainnet
4949
```
5050

51+
### BB-2026-0002
52+
```
53+
ID: BB-2026-0002
54+
Date: 2026-04-14
55+
Hunter: Sumitshah00 (tudg17lzd76ue95ht07hxzna8mzey4tkpk85jtjns2d)
56+
Severity: Critical
57+
Reward: 10,000 UDAG (mainnet promise)
58+
Testnet Paid: Pending (faucet rate-limited; will send via validator key)
59+
Source: Treasury (paid from treasury emission post-launch)
60+
Issue: Bridge release path enforced quorum as ceil(2n/3) of the active
61+
validator set with no floor on set size or vote count. When the
62+
active set degrades to n=1, the threshold collapses to 1 — a sole
63+
active validator can self-sign a BridgeReleaseTx with a fabricated
64+
deposit_nonce and drain the entire bridge_reserve in one tx. Report
65+
included a complete self-contained Rust PoC demonstrating the drain.
66+
Rated at the Critical floor because the bridge relayer is not yet
67+
live, bridge_reserve is currently 0, and mainnet P2P is closed to
68+
external staking — so no funds are at risk today. The bug would
69+
detonate the instant the bridge ships if left unpatched.
70+
Fix: Added two new constants (MIN_BRIDGE_VALIDATORS=4, MIN_BRIDGE_QUORUM=3)
71+
and wired both into apply_bridge_release_tx: releases are now rejected
72+
when active_validator_set.len() < MIN_BRIDGE_VALIDATORS, and the
73+
dynamic threshold is clamped to max(ceil(2n/3), MIN_BRIDGE_QUORUM).
74+
Regression test: crates/ultradag-coin/tests/bridge_release_quorum.rs
75+
(3 tests covering n=1 drain, below-floor set, and normal quorum path).
76+
Deposit-nonce → source-chain proof binding (reporter's recommendation
77+
#2) remains open as a separate design-level issue; tracked for a future
78+
bridge-hardening pass.
79+
Advisory: GHSA-6gwf-frh8-ppw7
80+
Status: Validated / Fixed / Pending Testnet Payout / Pending Mainnet
81+
```
82+
5183
---
5284

5385
## Pending Validation
@@ -65,9 +97,9 @@ Status: Validated / Fixed / Testnet Paid / Pending Mainnet
6597
- Unique hunters: 0
6698

6799
### April 2026
68-
- Submissions: 1 valid (GHSA-q8wx-2crx-c7pp)
69-
- Validated: 1
70-
- Rewards: 15,000 UDAG
100+
- Submissions: 2 valid (GHSA-q8wx-2crx-c7pp, GHSA-6gwf-frh8-ppw7)
101+
- Validated: 2
102+
- Rewards: 25,000 UDAG
71103
- Unique hunters: 1 (Sumitshah00)
72104

73105
### Mainnet launched: 2026-04-10

0 commit comments

Comments
 (0)