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)
- 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.
- 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.
- Admin calls
reopen_channel. Channel is open again, but anomaly_count is still 3.
- Attacker sends ONE more bad-version message. Count reaches 4 >= 3, channel closes again.
- 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
- 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.
- Observe channel enters
emergency_closed state after the third message.
- Admin calls
reopen_channel; channel reopens.
- Attacker sends one more
payload_version = 2 message.
- 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
Vulnerability Description
In the bridge contract,
reopen_channelclears theemergency_closedflag and thereasonstring, but never resetsanomaly_count. Becauseanomaly_countonly ever increments (it is set to 0 once at channel init and then only grows inrecord_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:The only writes to
anomaly_countanywhere in the contract are the init to 0 indefault_channel_state(bridge.rs:338) and the increment above. No function ever sets it back to 0.Attack scenario (any Stellar account, no whitelist)
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 incrementsanomaly_count; the third closes the channel.reopen_channel. Channel is open again, butanomaly_countis still 3.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
submit_cross_chain_messagethree times on a live channel withpayload_version = 2(supported = 1) and any positive amount. No token transfer occurs;amountis only message data.emergency_closedstate after the third message.reopen_channel; channel reopens.payload_version = 2message.Proof of Concept
Proposed Remediation
Reset
anomaly_count = 0insidereopen_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