One Shot: Reloaded

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

Funds can be locked indefinitely during a battle

[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.

Updates

Lead Judging Commences

bube Lead Judge 19 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.