Skip to content

[BUG BOUNTY] Permanent channel DoS: anomaly_count never reset on reopen allows 1-tx re-close loop #906

Description

@jackbone222

Vulnerability Description

In the bridge contract, reopen_channel clears the emergency_closed flag and the reason string, but never resets anomaly_count. Because anomaly_count only ever increments (it is set to 0 once at channel init and then only grows in record_channel_anomaly), once a channel reaches the anomaly threshold it can never be durably reopened: after the admin reopens it, a single new anomaly immediately closes it again. An attacker can keep any channel permanently closed with one near-free transaction per reopen.

Relevant code, all in contracts/bridge/src/bridge.rs:

// bridge.rs:1185-1204 — reopen_channel
pub fn reopen_channel(
    env: Env,
    caller: Address,
    channel_id: String,
) -> Result<(), ContractError> {
    Self::require_admin(&env, &caller)?;
    let mut state = Self::load_channel_state(&env, &channel_id);
    state.emergency_closed = false;
    state.reason = Self::empty_string(&env);
    // anomaly_count is NEVER reset
    Self::save_channel_state(&env, &channel_id, &state);
// bridge.rs:466-497 — record_channel_anomaly
fn record_channel_anomaly(env: &Env, channel_id: &String, reason: String) {
    let mut channel = Self::load_channel_state(env, channel_id);
    channel.anomaly_count = channel.anomaly_count.saturating_add(1);
    if channel.anomaly_count >= cfg.anomaly_close_threshold {   // default 3
        channel.emergency_closed = true;

The only writes to anomaly_count anywhere in the contract are the init to 0 in default_channel_state (bridge.rs:338) and the increment above. No function ever sets it back to 0.

Attack scenario (any Stellar account, no whitelist)

  1. Attacker sends 3 messages with payload_version = 2 while the channel supports version 1. The version check at bridge.rs:860-867 runs before the emergency_closed gate, so it works against any channel. Each message increments anomaly_count; the third closes the channel.
  2. Channel closed: legitimate submissions are blocked (bridge.rs:874-877) and pending verified withdrawals cannot execute (execute_verified_withdrawal re-checks emergency_closed at bridge.rs:1044-1047) - real funds-flow impact.
  3. Admin calls reopen_channel. Channel is open again, but anomaly_count is still 3.
  4. Attacker sends ONE more bad-version message. Count reaches 4 >= 3, channel closes again.
  5. Repeat forever. Each reopen costs the admin a transaction; each re-close costs the attacker one near-free transaction.

Channel IDs are public (emitted in ChannelEmergencyCloseEvent / BridgeAnomalyEvent and readable via get_channel_state), so targeting a live channel is trivial. There is no rate limiting and no per-account anomaly tracking.

Impact

Any bridge channel can be permanently kept in emergency-closed state by any Stellar account. All pending and future cross-chain messages on that channel are blocked indefinitely, and verified withdrawals that are pending execution get stranded. The admin's only lever is raising the global anomaly_close_threshold (bridge.rs:760-779), which disables the anomaly auto-close defense for all channels - a whack-a-mole against a counter the attacker inflates for free. No in-contract fix exists; a code change is required.

Severity Self-Assessment

High. Unauthenticated, near-zero-cost, effectively-permanent DoS on a funds-moving bridge, with no in-contract reset path.

Steps to Reproduce

  1. Attacker calls submit_cross_chain_message three times on a live channel with payload_version = 2 (supported = 1) and any positive amount. No token transfer occurs; amount is only message data.
  2. Observe channel enters emergency_closed state after the third message.
  3. Admin calls reopen_channel; channel reopens.
  4. Attacker sends one more payload_version = 2 message.
  5. Channel closes again. Repeat steps 3-4 indefinitely.

Proof of Concept

// attacker only needs caller.require_auth() (bridge.rs:850) - no channel membership, no whitelist
for _ in 0..3 {
    bridge.submit_cross_chain_message(
        &attacker, &channel_id, /* payload_version = */ 2, amount, /* ... */);
}   // channel emergency_closed = true

// admin
bridge.reopen_channel(&admin, &channel_id);   // anomaly_count still == 3

// attacker, 1 tx
bridge.submit_cross_chain_message(
    &attacker, &channel_id, /* payload_version = */ 2, amount, /* ... */);
// channel emergency_closed = true AGAIN - loop continues forever

Proposed Remediation

Reset anomaly_count = 0 inside reopen_channel (and/or decay the counter over time). Alternatively gate anomaly recording to validators only, or cap anomaly recording while the channel is already closed.

Researcher Information

  • Name/Handle: jackbone222
  • Email/Contact: reachable via this issue
  • ETH Wallet Address: 0xabee1b0ec0fc342d7162caa8a137dbe51289045d

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions