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.
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
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
Create a new file tests/exploit_theft_test.move
.
Add the following test code to the file.
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;
use battle_addr::one_shot;
use battle_addr::cred_token;
use battle_addr::rap_battle;
// --- High-Level Helper Functions ---
fun setup_world(module_owner: &signer, aptos_framework: &signer) {
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);
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);
cred_token::register(player_account);
cred_token::mint_for_test(module_owner, player_addr, cred_amount);
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);
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);
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"));
}
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 ==="));
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);
};