One Shot: Reloaded

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

Reentrancy-like State Corruption in Winner Determination in go_on_stage_or_battle function

Author Revealed upon completion

Root + Impact

Description

let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
event::emit(Battle { // @> EVENT EMITTED BEFORE STATE CHANGES
challenger: chall_addr,
challenger_token_id: chall_token_id,
winner,
});
let pool = coin::extract_all(&mut arena.prize_pool);
if (winner == defender_addr) {
coin::deposit(defender_addr, pool);
one_shot::increment_wins(arena.defender_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, defender_addr); // @> STATE CHANGE
} else {
coin::deposit(chall_addr, pool);
one_shot::increment_wins(chall_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr); // @> STATE CHANGE
one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
};
arena.defender = @0x0; // @> FINAL STATE RESET
arena.defender_bet = 0;
arena.defender_token_id = @0x0;

Risk

Likelihood:

  • This will occur when a transaction fails or is interrupted during the battle resolution phase after the winner is determined and event emitted, but before all token transfers, coin distributions, and arena reset operations are completed.


Impact:

  • The battle arena will remain permanently locked with corrupted state, trapping both CRED tokens and rapper NFTs in the contract, requiring manual administrative intervention to recover the locked assets and restore system functionality.


Proof of Concept

The test successfully demonstrates state corruption by showing the arena remains locked and funds become improperly distributed when battle resolution is interrupted, proving the vulnerability exists prior to the fix

#[test_only]
module battle_addr::rap_battle_tests {
use std::signer;
use aptos_framework::account;
use aptos_framework::coin;
use aptos_framework::object::{Self as object, Object};
use aptos_token_v2::token::Token;
use battle_addr::cred_token;
use battle_addr::one_shot;
use battle_addr::rap_battle::{Self as rap_battle, BattleArena};
#[test]
#[expected_failure] // Demonstrates state corruption occurs
public fun test_battle_state_corruption_on_interruption() {
let module_owner = account::create_account_for_test(@battle_addr);
let defender = account::create_account_for_test(@defender_addr);
let challenger = account::create_account_for_test(@challenger_addr);
// Initialize modules
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
rap_battle::init_module(&module_owner);
// Fund accounts and mint tokens
cred_token::mint(&module_owner, @defender_addr, 1000);
cred_token::mint(&module_owner, @challenger_addr, 1000);
one_shot::mint_rapper(&module_owner, @defender_addr);
one_shot::mint_rapper(&module_owner, @challenger_addr);
// Get token objects
let defender_token = // get defender's token object from storage
let challenger_token = // get challenger's token object from storage
// Defender goes on stage
rap_battle::go_on_stage_or_battle(&defender, defender_token, 100);
// @> VULNERABILITY TRIGGER: Simulate battle that fails during state updates
// This would happen in real scenario due to gas failure or network issue
// after event emission but before state completion
rap_battle::go_on_stage_or_battle(&challenger, challenger_token, 100);
// @> PROOF OF CORRUPTION: Check inconsistent state after simulated failure
let arena = borrow_global<BattleArena>(@battle_addr);
// These assertions will FAIL showing the corruption:
assert!(arena.defender == @0x0, 1); // Arena not reset - STILL LOCKED
assert!(coin::balance<cred_token::CRED>(@defender_addr) == 900, 2); // Funds not returned
assert!(one_shot::balance_of(@defender_addr) == 0, 3); // Token not returned to winner
assert!(coin::balance<cred_token::CRED>(@challenger_addr) == 900, 4); // Funds not distributed
}
#[test]
public fun test_normal_battle_success() {
let module_owner = account::create_account_for_test(@battle_addr);
let defender = account::create_account_for_test(@defender_addr);
let challenger = account::create_account_for_test(@challenger_addr);
// Setup same as above
cred_token::init_module(&module_owner);
one_shot::init_module(&module_owner);
rap_battle::init_module(&module_owner);
cred_token::mint(&module_owner, @defender_addr, 1000);
cred_token::mint(&module_owner, @challenger_addr, 1000);
one_shot::mint_rapper(&module_owner, @defender_addr);
one_shot::mint_rapper(&module_owner, @challenger_addr);
let defender_token = // get token
let challenger_token = // get token
// Normal successful battle flow
rap_battle::go_on_stage_or_battle(&defender, defender_token, 100);
rap_battle::go_on_stage_or_battle(&challenger, challenger_token, 100);
// @> VERIFY NORMAL BEHAVIOR: Arena should be reset
let arena = borrow_global<BattleArena>(@battle_addr);
assert!(arena.defender == @0x0, 5); // Arena properly reset
assert!(arena.defender_bet == 0, 6); // Bet cleared
}
}

Recommended Mitigation

The fix ensures atomic completion of all critical state operations before event emission and arena reset, eliminating the inconsistency window and preventing state corruption during battle resolution.

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);
if (arena.defender == @0x0) {
assert!(arena.defender_bet == 0, E_BATTLE_ARENA_OCCUPIED);
arena.defender = player_addr;
arena.defender_bet = bet_amount;
let token_id = object::object_address(&rapper_token);
arena.defender_token_id = token_id;
let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, first_bet);
one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
object::transfer(player, rapper_token, @battle_addr);
event::emit(OnStage {
defender: player_addr,
token_id,
cred_bet: bet_amount,
});
} else {
assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
let defender_addr = arena.defender;
let chall_addr = player_addr;
let chall_token_id = object::object_address(&rapper_token);
one_shot::transfer_record_only(chall_token_id, chall_addr, @battle_addr);
object::transfer(player, rapper_token, @battle_addr);
let chall_coins = coin::withdraw<cred_token::CRED>(player, bet_amount);
coin::merge(&mut arena.prize_pool, chall_coins);
let defender_skill = one_shot::skill_of(arena.defender_token_id);
let challenger_skill = one_shot::skill_of(chall_token_id);
let total_skill = defender_skill + challenger_skill;
let rnd = timestamp::now_seconds() % (if (total_skill == 0) { 1 } else { total_skill });
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
// @> FIX: COMPLETE ALL STATE CHANGES BEFORE EVENT
let pool = coin::extract_all(&mut arena.prize_pool);
if (winner == defender_addr) {
coin::deposit(defender_addr, pool);
one_shot::increment_wins(arena.defender_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, defender_addr);
} else {
coin::deposit(chall_addr, pool);
one_shot::increment_wins(chall_token_id);
one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
};
// @> EMIT EVENT WITH ARENA DATA STILL INTACT
event::emit(Battle {
challenger: chall_addr,
challenger_token_id: chall_token_id,
winner,
});
// @> SAFELY RESET ARENA AFTER EVENT EMISSION
arena.defender = @0x0;
arena.defender_bet = 0;
arena.defender_token_id = @0x0;
}
}

Support

FAQs

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