One Shot: Reloaded

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

Funds can be locked indefinitely during a battle

Author Revealed upon completion

[Griefing] Defender’s NFT can be locked indefinitely if no challenger joins

Description

  • Normal behavior:

    • When a player (Defender) goes on stage, they risk their Rapper and their stake.

    • A Challenger should arrive to complete the battle, after which NFTs and rewards are distributed.

  • Actual behavior:

    • If no Challenger arrives, the Defender’s Rapper remains in custody of @battle_addr.

    • There is no cancel/withdraw function, so the Defender cannot recover their NFT or stake.

    • Assets remain locked until the protocol owner redeploys or manually intervenes.

// rap_battle.move
if (arena.defender == @0x0) {
arena.defender = player_addr;
arena.defender_token_id = token_id;
...
one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
object::transfer(player, rapper_token, @battle_addr); // @> custody at battle_addr
}
// No exit path for defender if no challenger arrives

Risk

Likelihood:

  • Always occurs if no challenger joins after defender goes on stage.

  • Any user can trigger this by simply ignoring the defender’s challenge.

Impact:

  • Defender’s NFT and staked tokens are indefinitely locked.

  • Requires manual fix or redeployment by protocol owner.

  • No direct permanent loss, but players may perceive it as loss of assets.

Proof of Concept

one_shot.move

#[test_only]
public fun test_mint_to_player_and_return_object(
module_owner: &signer,
player: &signer
): (object::Object<aptos_token_v2::token::Token>, address) acquires RapperStats {
use std::string;
use std::option;
use aptos_framework::object::{Self as object};
use aptos_token_v2::token::{Self as token};
use aptos_token_v2::collection;
use aptos_std::table;
// Lazy init
if (!exists<Collection>(@battle_addr)) {
init_module(module_owner);
};
if (!exists<RapperStats>(@battle_addr)) {
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 });
};
// Mint token
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);
// Register stats and owner
let player_addr = signer::address_of(player);
let stats_res = borrow_global_mut<RapperStats>(@battle_addr);
table::add(&mut stats_res.stats, token_id, StatsData {
owner: player_addr,
weak_knees: true,
heavy_arms: true,
spaghetti_sweater: true,
calm_and_ready: false,
battles_won: 0,
});
if (table::contains(&stats_res.owner_counts, player_addr)) {
let cnt = table::borrow_mut(&mut stats_res.owner_counts, player_addr);
*cnt = *cnt + 1;
} else {
table::add(&mut stats_res.owner_counts, player_addr, 1);
};
// Transfer obj to the player
object::transfer(module_owner, token_obj, player_addr);
// Reconstruct obj for the token
let player_obj: object::Object<token::Token> = object::address_to_object<token::Token>(token_id);
(player_obj, token_id)
}

tests/griefing_poc.move

#[test(battle_addr = @0x42, alice = @0xa11ce)]
public fun test_defender_locked_without_challenger(battle_addr: &signer, alice: &signer) {
rap_battle::test_init(battle_addr);
let (alice_obj, alice_token_id) =
one_shot::test_mint_to_player_and_return_object(battle_addr, alice);
// Alice goes on stage with bet=0
rap_battle::go_on_stage_or_battle(alice, alice_obj, 0);
// After this, NFT is in custody at @battle_addr.
// No function exists for Alice to cancel or withdraw.
}

Test confirms: Defender’s NFT is locked if no challenger arrives.

Recommended Mitigation

Introduce a cancel/timeout function for defenders, e.g.:

+ public entry fun cancel_defense(
+ player: &signer,
+ module_owner: &signer
+ ) acquires BattleArena {
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ let player_addr = signer::address_of(player);
+
+ // Only current defender can cancel
+ assert!(arena.defender == player_addr, E_NOT_OWNER);
+
+ // Return defender's Rapper: first fix logical registry, then physical object
+ let def_tid = arena.defender_token_id;
+ let def_obj: Object<Token> = object::address_to_object<Token>(def_tid);
+ one_shot::transfer_record_only(def_tid, @battle_addr, player_addr);
+ object::transfer(module_owner, def_obj, player_addr);
+
+ // Refund only the defender's stake (challenger hasn't joined yet)
+ if (arena.defender_bet > 0) {
+ let refund = coin::extract_all(&mut arena.prize_pool);
+ coin::deposit(player_addr, refund);
+ } else {
+ // keep state clean even if zero; extract_all would be zero
+ let _ = coin::extract_all(&mut arena.prize_pool);
+ };
+
+ // Reset arena state
+ arena.defender = @0x0;
+ arena.defender_bet = 0;
+ arena.defender_token_id = @0x0;
+ }
  • Custody signer required: The Rapper object is physically held by @battle_addr, so returning it requires the module owner’s signer (module_owner: &signer) for object::transfer.

  • Registry first, then object: Call one_shot::transfer_record_only before the physical object::transfer to keep the internal ownership table consistent with the real object owner.

  • Refund only defender’s stake: Before any challenger joins, the entire prize_pool consists solely of the defender’s bet. Refund that amount and reset the pool.

  • Zero-bet safety: Even when the bet is zero, extract/clear the pool to avoid state drift and keep the arena clean.

  • Strict authorization: Ensure only the current defender (arena.defender) can execute cancel_defense; abort otherwise.

  • State reset: After canceling, clear defender, defender_bet, and defender_token_id so the arena becomes available again.

  • (Optional) cooldown/timeout: You may enforce a minimum waiting period (e.g., timestamp::now_seconds() - placed_at >= N) before allowing cancelation to reduce spam/griefing.

Support

FAQs

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