One Shot: Reloaded

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

Winner Steals Loser's NFT Due to Flawed Ownership Transfer Logic in Battle

Author Revealed upon completion

Description

The rap_battle::go_on_stage_or_battle function is responsible for resolving a battle between two players. The intended behavior is for the winner to receive the prize pool of CRED tokens, while both players retain ownership of their respective Rapper NFTs.

However, the current implementation contains a critical flaw in the battle resolution logic. After determining the winner, the code incorrectly transfers ownership of both the winner's and the loser's NFTs to the winner's address. This results in the loser forfeiting not only their CRED bet but also their Rapper NFT, which is a permanent loss of a digital asset.

// Root cause in rap_battle.move
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);
};

Risk

Likelihood: High

  • This flawed logic is executed in every battle that takes place on the protocol, making the vulnerability deterministic.

Impact: Critical

  • Permanent Loss of Assets: Users who lose a battle will have their Rapper NFT stolen by the winner. This goes far beyond the expected risk of losing a fungible token bet and constitutes a critical failure of the protocol's core game mechanics.

  • Protocol Integrity Failure: This bug undermines the trust and fairness of the entire RapBattle game, rendering it unusable as players will not risk their valuable NFTs.

Proof of Concept

Setup for Reproducibility

To make this Proof of Concept fully runnable, a few #[test_only] helper functions were added to the original contracts. These changes do not affect the production bytecode and are only compiled for testing.

1. In sources/cred_token.move:

+ #[test_only]
+ public fun init_for_test(sender: &signer) {
+ init_module(sender);
+ }
+
+ #[test_only]
+ public fun mint_for_test(
+ module_owner: &signer,
+ to: address,
+ amount: u64
+ ) acquires CredCapabilities {
+ mint(module_owner, to, amount);
+ }

2. In sources/rap_battle.move:

+ #[test_only]
+ public fun init_for_test(s: &signer) {
+ init_module(s);
+ }

3. In sources/one_shot.move:

+ #[test_only]
+ public fun mint_rapper_and_get_id(module_owner: &signer, to: address): address
+ acquires Collection, RapperStats {
+ let owner_addr = signer::address_of(module_owner);
+ assert!(owner_addr == @battle_addr, 1 /* E_NOT_AUTHORIZED */);
+
+ // 🔧 Lazy-init if needed (unit tests don’t auto-run init_module)
+ if (!exists<Collection>(@battle_addr)) {
+ init_module(module_owner);
+ };
+ if (!exists<RapperStats>(@battle_addr)) {
+ // This case shouldn’t happen if init_module just ran, but keep it safe.
+ 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 });
+ };
+
+ // Safe to assume collection/stats exist now
+ let _ = borrow_global<Collection>(@battle_addr);
+
+ 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);
+
+ let stats_res = borrow_global_mut<RapperStats>(@battle_addr);
+ table::add(&mut stats_res.stats, token_id, StatsData {
+ owner: to,
+ weak_knees: true,
+ heavy_arms: true,
+ spaghetti_sweater: true,
+ calm_and_ready: false,
+ battles_won: 0,
+ });
+ // increment owner count
+ if (table::contains(&stats_res.owner_counts, to)) {
+ let cnt = table::borrow_mut(&mut stats_res.owner_counts, to);
+ *cnt = *cnt + 1;
+ } else {
+ table::add(&mut stats_res.owner_counts, to, 1);
+ };
+
+ event::emit(MintRapperEvent { minter: to, token_id });
+
+ object::transfer(module_owner, token_obj, to);
+ token_id
+ }
+
+ #[test_only]
+ public fun read_stats_for_test(token_id: address): (bool, bool, bool, bool, u64)
+ acquires RapperStats {
+ read_stats(token_id)
+ }

Test File and Execution

  1. Create a new file tests/exploit_theft_test.move.

  2. Add the following test code to the file.

  3. Run the test using the command: aptos move test --filter test_winner_steals_losers_nft --dev

Test Code: tests/exploit_theft_test.move

#[test_only]
module battle_addr::exploit_theft_test {
use std::signer;
use aptos_framework::account;
use aptos_framework::object::{Self as object, Object};
use aptos_token_v2::token::Token;
use aptos_framework::aptos_coin;
use aptos_framework::coin;
use aptos_framework::timestamp;
use std::debug;
use std::string;
// Import all project modules
use battle_addr::one_shot;
use battle_addr::cred_token;
use battle_addr::rap_battle;
// --- High-Level Helper Functions ---
// Initializes the entire world for the test
fun setup_world(module_owner: &signer, aptos_framework: &signer) {
// Initialize framework modules
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(aptos_framework);
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
timestamp::set_time_has_started_for_testing(aptos_framework);
// Use the test-only wrappers to initialize our modules
cred_token::init_for_test(module_owner);
rap_battle::init_for_test(module_owner);
// one_shot is lazy-initialized by mint_rapper
}
// A powerful function to set up a new player completely
fun new_player(module_owner: &signer, player_account: &signer, cred_amount: u64): (address, Object<Token>) {
let player_addr = signer::address_of(player_account);
// Register and mint CRED
cred_token::register(player_account);
cred_token::mint_for_test(module_owner, player_addr, cred_amount);
// Mint a Rapper and get its object
let token_id = one_shot::mint_rapper_and_get_id(module_owner, player_addr);
let token_obj: Object<Token> = object::address_to_object(token_id);
(player_addr, token_obj)
}
#[test]
fun test_winner_steals_losers_nft() {
debug::print(&string::utf8(b"=== Starting NFT Theft Vulnerability Test ==="));
// === 1. SETUP ===
debug::print(&string::utf8(b"1. Setting up environment and accounts..."));
let module_owner = account::create_account_for_test(@battle_addr);
let defender_account = account::create_account_for_test(@0xd);
let challenger_account = account::create_account_for_test(@0xc);
let aptos_framework = account::create_account_for_test(@aptos_framework);
// Initialize everything
debug::print(&string::utf8(b"2. Initializing world and modules..."));
setup_world(&module_owner, &aptos_framework);
// Set up both players
debug::print(&string::utf8(b"3. Creating players and distributing resources..."));
let (defender_addr, defender_token) = new_player(&module_owner, &defender_account, 1000);
let (challenger_addr, challenger_token) = new_player(&module_owner, &challenger_account, 1000);
debug::print(&string::utf8(b"Defender address:"));
debug::print(&defender_addr);
debug::print(&string::utf8(b"Challenger address:"));
debug::print(&challenger_addr);
// Verify initial state
let initial_defender_balance = one_shot::balance_of(defender_addr);
let initial_challenger_balance = one_shot::balance_of(challenger_addr);
debug::print(&string::utf8(b"=== INITIAL STATE ==="));
debug::print(&string::utf8(b"Defender NFT balance:"));
debug::print(&initial_defender_balance);
debug::print(&string::utf8(b"Challenger NFT balance:"));
debug::print(&initial_challenger_balance);
debug::print(&string::utf8(b"Defender CRED balance: 1000"));
debug::print(&string::utf8(b"Challenger CRED balance: 1000"));
assert!(initial_defender_balance == 1, 0);
assert!(initial_challenger_balance == 1, 0);
// === 2. ACT ===
debug::print(&string::utf8(b"=== STARTING BATTLE ==="));
let bet_amount = 100;
debug::print(&string::utf8(b"Bet amount:"));
debug::print(&bet_amount);
debug::print(&string::utf8(b"Defender enters the stage..."));
rap_battle::go_on_stage_or_battle(&defender_account, defender_token, bet_amount);
debug::print(&string::utf8(b"Challenger enters the battle..."));
rap_battle::go_on_stage_or_battle(&challenger_account, challenger_token, bet_amount);
// === 3. ASSERT ===
debug::print(&string::utf8(b"=== ANALYZING RESULTS ==="));
let final_defender_balance = one_shot::balance_of(defender_addr);
let final_challenger_balance = one_shot::balance_of(challenger_addr);
debug::print(&string::utf8(b"=== FINAL STATE ==="));
debug::print(&string::utf8(b"Defender NFT balance after battle:"));
debug::print(&final_defender_balance);
debug::print(&string::utf8(b"Challenger NFT balance after battle:"));
debug::print(&final_challenger_balance);
// Change analysis
debug::print(&string::utf8(b"=== CHANGE ANALYSIS ==="));
if (final_defender_balance > initial_defender_balance) {
debug::print(&string::utf8(b"Defender gained additional NFT"));
} else if (final_defender_balance < initial_defender_balance) {
debug::print(&string::utf8(b"Defender lost NFT"));
};
if (final_challenger_balance > initial_challenger_balance) {
debug::print(&string::utf8(b"Challenger gained additional NFT"));
} else if (final_challenger_balance < initial_challenger_balance) {
debug::print(&string::utf8(b"Challenger lost NFT"));
};
// Determine winner and loser
if (final_defender_balance == 2 && final_challenger_balance == 0) {
debug::print(&string::utf8(b"WINNER: Defender won and stole Challenger's NFT!"));
} else if (final_defender_balance == 0 && final_challenger_balance == 2) {
debug::print(&string::utf8(b"WINNER: Challenger won and stole Defender's NFT!"));
} else {
debug::print(&string::utf8(b"ERROR: Unexpected result!"));
};
debug::print(&string::utf8(b"=== VULNERABILITY PROOF: Winner steals loser's NFT ==="));
// The assertion remains the same, proving the theft.
assert!((final_defender_balance == 2 && final_challenger_balance == 0) || (final_defender_balance == 0 && final_challenger_balance == 2), 100);
debug::print(&string::utf8(b"SUCCESS: Vulnerability proven - Winner gets both NFTs!"));
}
}

Test Output

$ aptos move test --filter test_winner_steals_losers_nft --dev
Running Move unit tests
[debug] "=== Starting NFT Theft Vulnerability Test ==="
[debug] "1. Setting up environment and accounts..."
[debug] "2. Initializing world and modules..."
[debug] "3. Creating players and distributing resources..."
[debug] "Defender address:"
[debug] @0xd
[debug] "Challenger address:"
[debug] @0xc
[debug] "=== INITIAL STATE ==="
[debug] "Defender NFT balance:"
[debug] 1
[debug] "Challenger NFT balance:"
[debug] 1
[debug] "=== STARTING BATTLE ==="
[debug] "Defender enters the stage..."
[debug] "Challenger enters the battle..."
[debug] "=== ANALYZING RESULTS ==="
[debug] "=== FINAL STATE ==="
[debug] "Defender NFT balance after battle:"
[debug] 2
[debug] "Challenger NFT balance after battle:"
[debug] 0
[debug] "WINNER: Defender won and stole Challenger's NFT!"
[debug] "=== VULNERABILITY PROOF: Winner steals loser's NFT ==="
[debug] "SUCCESS: Vulnerability proven - Winner gets both NFTs!"
[ PASS ] 0x42::exploit_theft_test::test_winner_steals_losers_nft
Test result: OK. Total tests: 1; passed: 1; failed: 0
{
"Result": "Success"
}

Recommended Mitigation

The ownership transfer logic should be corrected to ensure that each player's NFT is returned to them after the battle, regardless of the outcome. The winner should only receive the CRED prize pool.

// In rap_battle.move
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);
+ // Return the loser's NFT to the loser
+ one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_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);
+ // Return the loser's NFT to the loser
+ one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
};

Support

FAQs

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