One Shot: Reloaded

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

Inconsistent Ownership via transfer_record_only (RapperStats Desync)

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior:
    When a token is created or transferred, the ownership should be updated on the actual ledger/object layer. Any statistics stored in the RapperStats resource (such as stats and owner_counts) should always reflect the real ownership of tokens on-chain.

  • Issue:
    The transfer_record_only(token_id, from, to) function modifies only the internal ownership records in RapperStats (i.e. updates s.owner and owner_counts) without verifying or enforcing that the actual token transfer has occurred at the Aptos object/ledger level. This can lead to inconsistent states where the RapperStats module reports different ownership or balances from the real on-chain state.

// Root cause in the codebase — @> marks the vulnerable part
public(friend) fun transfer_record_only(token_id: address, from: address, to: address)
acquires RapperStats {
let stats_res = borrow_global_mut<RapperStats>(@battle_addr);
let s = table::borrow_mut(&mut stats_res.stats, token_id);
@> s.owner = to; // @> Updates internal owner without verifying token transfer
let c_from = table::borrow_mut(&mut stats_res.owner_counts, from);
*c_from = *c_from - 1; // @> Decrements counter without guaranteed sync with ledger
if (*c_from == 0) { table::remove(&mut stats_res.owner_counts, from); };
if (table::contains(&stats_res.owner_counts, to)) {
let c_to = table::borrow_mut(&mut stats_res.owner_counts, to);
*c_to = *c_to + 1;
} else {
table::add(&mut stats_res.owner_counts, to, 1);
};
}

Risk

Likelihood:

  • This issue will occur whenever a friend module calls transfer_record_only without actually transferring the token at the ledger level — the function itself does not enforce the transfer.

  • Because owner_counts duplicates data from stats, any missed update or unsynchronized call will directly cause inconsistent state.

Impact:

  • Inconsistent or incorrect balances (balance_of) may be reported and used for rewards, rankings, or permissions based on ownership, resulting in unauthorized benefits.

  • An attacker with access to a friend module could deliberately alter RapperStats to fake token ownership or inflate their balances without performing real transfers on-chain.

Proof of Concept

// Hypothetical PoC showing inconsistency:
// 1. Attacker already owns no tokens but has access to a friend module:
RapperStats::transfer_record_only(fake_token_id, attacker_address, attacker_address);
// 2. RapperStats now shows the attacker owns the token, even though no transfer occurred
assert!(RapperStats::balance_of(attacker_address) > 0);
// Meanwhile, the actual token ledger still shows zero tokens.

Recommended Mitigation

- public(friend) fun transfer_record_only(token_id: address, from: address, to: address)
+ public(friend) fun transfer_record_only(token_id: address, from: address, to: address)
+ acquires RapperStats {
+ // Add a verification that the token is actually owned/has been transferred before updating:
+ assert!(Token::owner(token_id) == to, E_NOT_ACTUALLY_TRANSFERRED);

Support

FAQs

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