One Shot: Reloaded

First Flight #47
Beginner FriendlyNFT
100 EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

M01. Permanent Arena Lock: No Challenger or Cancellation Mechanism

Root + Impact

Description

The intended behavior is that a player can take the stage as a defender by staking a CRED token bet and locking their NFT. Another player can then challenge with a matching bet and their NFT, after which the contract determines a winner and pays out the pooled bets.

The problem is that once a defender goes on stage, there is no mechanism for the defender (or anyone else) to cancel or withdraw if no challenger arrives. This allows a defender to lock the arena indefinitely or set a bet size that no one can match, blocking the arena for all other players. In the process, the defender’s bet and NFT are also locked forever.

public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
let player_addr = signer::address_of(player);
let arena = borrow_global_mut<BattleArena>(@battle_addr);
if (arena.defender == @0x0) {
@> arena.defender = player_addr;
@> arena.defender_bet = bet_amount;
@> arena.defender_token_id = token_id;
...
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
...
}
}

Risk

Likelihood:

  • A defender always sets arena.defender and arena.defender_bet when going on stage, with no timeout or cancel function.

  • Attackers can trivially call this once with a very high bet or simply never intend to battle, leaving the arena locked.

Impact:

  • Denial of Service: No other users can use the arena once locked.

  • Permanent loss of funds/NFT: Defender’s stake and NFT remain trapped unless a challenger with the exact bet appears — which may never happen.


Proof of Concept

// Attacker sets unrealistic bet and occupies arena forever.
rap_battle::go_on_stage_or_battle(&attacker_signer, attacker_nft, 1_000_000_000);
// Arena state:
// arena.defender == attacker_addr
// arena.defender_bet == 1_000_000_000
// arena.defender_token_id == attacker_nft.address
// There is no public function to cancel or withdraw.
// No other player can use the arena unless they match this exact bet.
// Defender’s NFT and stake remain permanently locked.

Recommended Mitigation

Add a cancellation or timeout mechanism that allows a defender to reclaim their NFT and bet if no challenger arrives:

+ public entry fun cancel_stage(player: &signer) acquires BattleArena {
+ let addr = signer::address_of(player);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.defender == addr, E_NOT_DEFENDER);
+
+ // Return NFT and bet
+ let pool = coin::extract_all(&mut arena.prize_pool);
+ coin::deposit(addr, pool);
+ // TODO: object::transfer stored rapper_token back to addr
+
+ // Reset arena
+ arena.defender = @0x0;
+ arena.defender_bet = 0;
+ arena.defender_token_id = @0x0;
+ }

Alternatively, enforce a time-based expiration (e.g., using timestamp::now_seconds()) so that if no challenger joins within X seconds, the defender can safely withdraw.

Updates

Lead Judging Commences

bube Lead Judge 18 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

The defender can't cancel the battle if there is no challenger

There is no security impact on the protocol from this issue. The defender should wait until the challenger joins, this is intended behavior.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.