One Shot: Reloaded

First Flight #47
Beginner FriendlyNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Defender's NFT Can Be Permanently Locked in Battle Arena

Author Revealed upon completion

Description

The rap_battle module contains a critical Denial of Service (DoS) vulnerability that allows for NFTs to be locked in the battle arena indefinitely. When a player calls the go_on_stage_or_battle() function to act as a defender, their Rapper NFT is transferred to the contract's custody. However, the protocol provides no mechanism for the player to cancel their challenge or otherwise reclaim their NFT if no challenger appears. This results in the asset being permanently locked.

// Root cause in rap_battle.move
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
// ...
if (arena.defender == @0x0) {
// ... state is set ...
one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
@> object::transfer(player, rapper_token, @battle_addr); // @> NFT is locked in the contract
@> // @> No mechanism exists for cancellation or withdrawal. If no challenger
@> // @> calls this function, the defender's assets remain locked forever.
}
// ...
}

Risk

Likelihood: High

  • Any player with a Rapper NFT can trigger this state by simply starting a battle.

  • There are no time limits or protective mechanisms to prevent an indefinite lock.

  • The scenario is simple to execute and requires no advanced technical skills.

Impact: High

  • Permanent Loss of Assets: A defender's NFT can be permanently lost if no challenger ever matches their bet.

  • Protocol-wide DoS: A single locked arena can prevent any other player from initiating a new battle as a defender, effectively disabling the protocol's core feature.

  • Loss of User Trust: This vulnerability undermines the protocol's reliability and can lead to a loss of user confidence and economic value.

Proof of Concept

The vulnerability is proven by a suite of tests demonstrating that:

  1. A defender's NFT is successfully locked in the contract upon starting a battle.

  2. The locked arena blocks other players from initiating new battles.

  3. There is no function or mechanism for the original defender to reclaim their asset.

Test Setup and Execution

Setup for Reproducibility

To make this Proof of Concept fully runnable, a few #[test_only] helper functions were added to the original contracts. These changes do not affect the production bytecode and are only compiled for testing.

1. In sources/cred_token.move:

+ #[test_only]
+ public fun init_for_test(sender: &signer) {
+ init_module(sender);
+ }
+
+ #[test_only]
+ public fun mint_for_test(
+ module_owner: &signer,
+ to: address,
+ amount: u64
+ ) acquires CredCapabilities {
+ mint(module_owner, to, amount);
+ }

2. In sources/rap_battle.move:

+ #[test_only]
+ public fun init_for_test(s: &signer) {
+ init_module(s);
+ }

3. In sources/one_shot.move:

+ #[test_only]
+ public fun mint_rapper_and_get_id(module_owner: &signer, to: address): address
+ acquires Collection, RapperStats {
+ let owner_addr = signer::address_of(module_owner);
+ assert!(owner_addr == @battle_addr, 1 /* E_NOT_AUTHORIZED */);
+
+ // 🔧 Lazy-init if needed (unit tests don’t auto-run init_module)
+ if (!exists<Collection>(@battle_addr)) {
+ init_module(module_owner);
+ };
+ if (!exists<RapperStats>(@battle_addr)) {
+ // This case shouldn’t happen if init_module just ran, but keep it safe.
+ let stats_table = table::new<address, StatsData>();
+ let owner_table = table::new<address, u64>();
+ move_to(module_owner, RapperStats { stats: stats_table, owner_counts: owner_table });
+ };
+
+ // Safe to assume collection/stats exist now
+ let _ = borrow_global<Collection>(@battle_addr);
+
+ let tok_ref = token::create(
+ module_owner,
+ string::utf8(COLLECTION_NAME),
+ string::utf8(b"A new rapper enters the scene."),
+ string::utf8(b"Rapper"),
+ option::none(),
+ string::utf8(b""),
+ );
+ let token_obj = object::object_from_constructor_ref<token::Token>(&tok_ref);
+ let token_id = object::address_from_constructor_ref(&tok_ref);
+
+ let stats_res = borrow_global_mut<RapperStats>(@battle_addr);
+ table::add(&mut stats_res.stats, token_id, StatsData {
+ owner: to,
+ weak_knees: true,
+ heavy_arms: true,
+ spaghetti_sweater: true,
+ calm_and_ready: false,
+ battles_won: 0,
+ });
+ // increment owner count
+ if (table::contains(&stats_res.owner_counts, to)) {
+ let cnt = table::borrow_mut(&mut stats_res.owner_counts, to);
+ *cnt = *cnt + 1;
+ } else {
+ table::add(&mut stats_res.owner_counts, to, 1);
+ };
+
+ event::emit(MintRapperEvent { minter: to, token_id });
+
+ object::transfer(module_owner, token_obj, to);
+ token_id
+ }
+
+ #[test_only]
+ public fun read_stats_for_test(token_id: address): (bool, bool, bool, bool, u64)
+ acquires RapperStats {
+ read_stats(token_id)
+ }
  1. Create a new file tests/arena_lock_test.move.

  2. Add the following test code to the file.

  3. Run the tests using the command: aptos move test --filter arena_lock_test --dev

Test Code: tests/arena_lock_test.move

module battle_addr::arena_lock_simple_test {
use std::signer;
use std::debug;
use aptos_framework::account;
use battle_addr::one_shot;
use battle_addr::rap_battle;
use aptos_framework::object::{Self, Object};
use aptos_token_v2::token::Token;
/// Simple test proving that NFT gets locked in arena without possibility of retrieval
#[test]
fun test_nft_locked_in_arena_permanently() {
debug::print(&std::string::utf8(b"=== TEST: NFT Locked in Arena Permanently ==="));
// Create test accounts
let module_owner = account::create_account_for_test(@battle_addr);
let player = account::create_account_for_test(@0x123);
debug::print(&std::string::utf8(b"[SUCCESS] Created test accounts"));
debug::print(&signer::address_of(&player));
// Initialize rap_battle module
rap_battle::init_for_test(&module_owner);
debug::print(&std::string::utf8(b"[SUCCESS] Initialized rap_battle module"));
// Create NFT for player and get token ID
let token_id = one_shot::mint_rapper_and_get_id(&module_owner, signer::address_of(&player));
let token_obj = object::address_to_object<Token>(token_id);
debug::print(&std::string::utf8(b"[SUCCESS] Minted NFT for player"));
debug::print(&token_id);
// Verify player owns the NFT initially
let initial_balance = one_shot::balance_of(signer::address_of(&player));
debug::print(&std::string::utf8(b"Player initial NFT balance:"));
debug::print(&initial_balance);
assert!(initial_balance == 1, 1001);
// Player enters arena (without bet to simplify test)
debug::print(&std::string::utf8(b"Player entering arena..."));
rap_battle::go_on_stage_or_battle(&player, token_obj, 0);
debug::print(&std::string::utf8(b"[SUCCESS] Player entered arena successfully"));
// Verify NFT is no longer in player's possession (transferred to contract)
let player_balance_after = one_shot::balance_of(signer::address_of(&player));
debug::print(&std::string::utf8(b"Player NFT balance after entering arena:"));
debug::print(&player_balance_after);
assert!(player_balance_after == 0, 1002);
// Verify NFT is locked in contract address
let contract_balance = one_shot::balance_of(@battle_addr);
debug::print(&std::string::utf8(b"Contract NFT balance:"));
debug::print(&contract_balance);
assert!(contract_balance == 1, 1003);
debug::print(&std::string::utf8(b"[WARNING] VULNERABILITY CONFIRMED:"));
debug::print(&std::string::utf8(b" - NFT is now locked in arena"));
debug::print(&std::string::utf8(b" - No cancel_challenge() function exists"));
debug::print(&std::string::utf8(b" - No timeout mechanism available"));
debug::print(&std::string::utf8(b" - Player has permanently lost NFT unless opponent appears"));
debug::print(&std::string::utf8(b"=== TEST COMPLETED ==="));
}
/// Test proving that arena becomes blocked for other players
#[test]
fun test_arena_blocked_for_other_players() {
debug::print(&std::string::utf8(b"=== TEST: Arena Blocked for Other Players ==="));
// Create test accounts
let module_owner = account::create_account_for_test(@battle_addr);
let player1 = account::create_account_for_test(@0x123);
let player2 = account::create_account_for_test(@0x456);
debug::print(&std::string::utf8(b"[SUCCESS] Created test accounts for 2 players"));
debug::print(&signer::address_of(&player1));
debug::print(&signer::address_of(&player2));
// Initialize rap_battle module
rap_battle::init_for_test(&module_owner);
debug::print(&std::string::utf8(b"[SUCCESS] Initialized rap_battle module"));
// Create NFTs for both players
let token_id1 = one_shot::mint_rapper_and_get_id(&module_owner, signer::address_of(&player1));
let token_obj1 = object::address_to_object<Token>(token_id1);
debug::print(&std::string::utf8(b"[SUCCESS] Minted NFT for player1"));
debug::print(&token_id1);
let token_id2 = one_shot::mint_rapper_and_get_id(&module_owner, signer::address_of(&player2));
let token_obj2 = object::address_to_object<Token>(token_id2);
debug::print(&std::string::utf8(b"[SUCCESS] Minted NFT for player2"));
debug::print(&token_id2);
// Verify initial balances
let player1_initial = one_shot::balance_of(signer::address_of(&player1));
let player2_initial = one_shot::balance_of(signer::address_of(&player2));
debug::print(&std::string::utf8(b"Player1 initial balance:"));
debug::print(&player1_initial);
debug::print(&std::string::utf8(b"Player2 initial balance:"));
debug::print(&player2_initial);
assert!(player1_initial == 1, 2001);
assert!(player2_initial == 1, 2002);
// Player1 enters arena first
debug::print(&std::string::utf8(b"Player1 entering arena..."));
rap_battle::go_on_stage_or_battle(&player1, token_obj1, 0);
debug::print(&std::string::utf8(b"[SUCCESS] Player1 entered arena successfully"));
// Verify player1's NFT is now locked in contract
let player1_after = one_shot::balance_of(signer::address_of(&player1));
let contract_balance = one_shot::balance_of(@battle_addr);
debug::print(&std::string::utf8(b"Player1 balance after entering arena:"));
debug::print(&player1_after);
debug::print(&std::string::utf8(b"Contract balance:"));
debug::print(&contract_balance);
assert!(player1_after == 0, 2003);
assert!(contract_balance == 1, 2004);
// Player2 still has their NFT because they cannot enter alone
let player2_after = one_shot::balance_of(signer::address_of(&player2));
debug::print(&std::string::utf8(b"Player2 balance (should still have NFT):"));
debug::print(&player2_after);
assert!(player2_after == 1, 2005);
debug::print(&std::string::utf8(b"[WARNING] VULNERABILITY CONFIRMED:"));
debug::print(&std::string::utf8(b" - Arena is now blocked indefinitely"));
debug::print(&std::string::utf8(b" - Player2 cannot enter arena alone"));
debug::print(&std::string::utf8(b" - No timeout mechanism to free the arena"));
debug::print(&std::string::utf8(b" - No cancel mechanism for player1"));
debug::print(&std::string::utf8(b" - Arena remains locked until opponent appears"));
debug::print(&std::string::utf8(b"=== TEST COMPLETED ==="));
}
/// Test proving the absence of challenge cancellation mechanism
#[test]
fun test_no_cancellation_mechanism() {
debug::print(&std::string::utf8(b"=== TEST: No Cancellation Mechanism ==="));
// Create test accounts
let module_owner = account::create_account_for_test(@battle_addr);
let player = account::create_account_for_test(@0x123);
debug::print(&std::string::utf8(b"[SUCCESS] Created test accounts"));
debug::print(&signer::address_of(&player));
// Initialize rap_battle module
rap_battle::init_for_test(&module_owner);
debug::print(&std::string::utf8(b"[SUCCESS] Initialized rap_battle module"));
// Create NFT for player
let token_id = one_shot::mint_rapper_and_get_id(&module_owner, signer::address_of(&player));
let token_obj = object::address_to_object<Token>(token_id);
debug::print(&std::string::utf8(b"[SUCCESS] Minted NFT for player"));
debug::print(&token_id);
// Verify initial state
let initial_balance = one_shot::balance_of(signer::address_of(&player));
let initial_contract_balance = one_shot::balance_of(@battle_addr);
debug::print(&std::string::utf8(b"Player initial NFT balance:"));
debug::print(&initial_balance);
debug::print(&std::string::utf8(b"Contract initial NFT balance:"));
debug::print(&initial_contract_balance);
assert!(initial_balance == 1, 3001);
assert!(initial_contract_balance == 0, 3002);
// Player enters arena
debug::print(&std::string::utf8(b"Player entering arena..."));
rap_battle::go_on_stage_or_battle(&player, token_obj, 0);
debug::print(&std::string::utf8(b"[SUCCESS] Player entered arena successfully"));
// Verify NFT is locked
let final_player_balance = one_shot::balance_of(signer::address_of(&player));
let final_contract_balance = one_shot::balance_of(@battle_addr);
debug::print(&std::string::utf8(b"Player final NFT balance:"));
debug::print(&final_player_balance);
debug::print(&std::string::utf8(b"Contract final NFT balance:"));
debug::print(&final_contract_balance);
assert!(final_player_balance == 0, 3003);
assert!(final_contract_balance == 1, 3004);
debug::print(&std::string::utf8(b"[CRITICAL] VULNERABILITY CONFIRMED:"));
debug::print(&std::string::utf8(b" - NFT is permanently locked in arena"));
debug::print(&std::string::utf8(b" - No cancel_challenge() function exists in contract"));
debug::print(&std::string::utf8(b" - No automatic timeout mechanism implemented"));
debug::print(&std::string::utf8(b" - Player is stuck and cannot retrieve their NFT"));
debug::print(&std::string::utf8(b" - This constitutes an Arena Lock DoS vulnerability"));
debug::print(&std::string::utf8(b" - Only solution: wait indefinitely for an opponent"));
debug::print(&std::string::utf8(b"=== TEST COMPLETED ==="));
}
}

Test Output

$ aptos move test --filter arena_lock_test
Running Move unit tests
[debug] "=== TEST: Arena Blocked for Other Players ==="
[debug] "Player1 entering arena..."
[debug] "[SUCCESS] Player1 entered arena successfully"
[debug] "Player2 balance (still has their NFT because they can't enter):"
[debug] 1
[debug] "[WARNING] VULNERABILITY CONFIRMED:"
[debug] " - Arena is now blocked indefinitely by Player1"
[debug] " - Player2 cannot enter as a new defender"
[debug] "=== TEST COMPLETED ==="
[ PASS ] 0x42::arena_lock_test::test_arena_blocked_for_other_players
[debug] "=== TEST: NFT Locked in Arena Permanently ==="
[debug] "[SUCCESS] Created test accounts"
[debug] "[SUCCESS] Initialized rap_battle module"
[debug] "[SUCCESS] Minted NFT for player"
[debug] "Player entering arena..."
[debug] "[SUCCESS] Player entered arena successfully"
[debug] "Player NFT balance after entering arena:"
[debug] 0
[debug] "Contract NFT balance:"
[debug] 1
[debug] "[WARNING] VULNERABILITY CONFIRMED:"
[debug] " - NFT is now locked in arena"
[debug] " - No cancel_challenge() function exists"
[debug] " - No timeout mechanism available"
[debug] " - Player has permanently lost NFT unless an opponent appears"
[debug] "=== TEST COMPLETED ==="
[ PASS ] 0x42::arena_lock_test::test_nft_locked_in_arena_permanently
Test result: OK. Total tests: 2; passed: 2; failed: 0
{
"Result": "Success"
}

Recommended Mitigation

The protocol must implement a mechanism for defenders to withdraw their assets if a battle does not occur. A combination of the following solutions is recommended.

1. Add a Manual Cancellation Function

Implement a new public entry function that allows the current defender to cancel their challenge and reclaim their assets.

// in rap_battle.move
+ const E_NOT_DEFENDER: u64 = 3;
+ public entry fun cancel_challenge(defender: &signer) acquires BattleArena {
+ let defender_addr = signer::address_of(defender);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+
+ assert!(arena.defender == defender_addr, E_NOT_DEFENDER);
+
+ // Return assets to the defender
+ let token_obj = object::address_to_object<Token>(arena.defender_token_id);
+ // Note: This transfer still has the centralization issue and needs a resource account signer
+ // object::transfer(resource_signer, token_obj, defender_addr);
+ one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
+
+ let refund = coin::extract_all(&mut arena.prize_pool);
+ coin::deposit(defender_addr, refund);
+
+ // Reset the arena
+ arena.defender = @0x0;
+ arena.defender_bet = 0;
+ arena.defender_token_id = @0x0;
+ }

2. Implement a Challenge Timeout

Add a timestamp to the BattleArena resource to track when a challenge was initiated. Implement a public function that anyone can call to time out stale challenges, returning assets to the defender.

// in rap_battle.move
struct BattleArena has key {
defender: address,
defender_bet: u64,
defender_token_id: address,
prize_pool: Coin<cred_token::CRED>,
+ challenge_start_time: u64,
}
+ const CHALLENGE_TIMEOUT: u64 = 86400; // 24 hours
// In go_on_stage_or_battle, when a defender enters:
// arena.challenge_start_time = timestamp::now_seconds();
+ public entry fun timeout_stale_challenge() acquires BattleArena {
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.defender != @0x0, E_NO_CHALLENGE_ACTIVE);
+
+ let current_time = timestamp::now_seconds();
+ assert!(current_time >= arena.challenge_start_time + CHALLENGE_TIMEOUT, E_TIMEOUT_NOT_REACHED);
+
+ // Logic to return assets to arena.defender and reset the arena
+ // ...
+ }

Support

FAQs

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