Description
The battle outcome relies on timestamp based “randomness” at the one second level, which makes results highly predictable. Because Aptos blocks are produced in under a second but share second level timestamps, multiple blocks often land with the same timestamp.
This creates a clear exploit path: challengers can easily calculate which second guarantees a win and time their transaction accordingly. As a result, they can consistently force favorable outcomes for the attacker
Root Cause
The battle winner determination in rap_battle::go_on_stage_or_battle()
uses timestamp::now_seconds()
for randomness, which is entirely predictable:
let total_skill = defender_skill + challenger_skill;
let rnd = timestamp::now_seconds() % total_skill;
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
This is exploitable because:
Challenger controls timing: They choose exactly when to submit the battle transaction
Outcome is deterministic: Given any timestamp, the winner can be calculated in advance
Risk
Likelihood: High - Any challenger with basic math skills can exploit this
Impact: High - Guarantees battle victories and CRED token theft from defenders
Impact
High severity because:
-
Guaranteed wins: Challengers can ensure 100% win rate by timing their transactions
-
Economic exploitation: Steal CRED tokens from legitimate defenders
-
Protocol integrity failure: Completely breaks the competitive battle system
-
Unfair advantage: Defenders have no defense against this exploit
Proof of Concept
Exploitation test showing attacker can wait for guaranteed win:
#[test]
public fun test_farm_winning() {
let now = 1_758_100_000_000;
let aptos_framework = account::create_account_for_test(@0x1);
timestamp::set_time_has_started_for_testing(&aptos_framework);
timestamp::update_global_time_for_test(now);
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 = object::address_to_object<Token>(token_id);
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_token2 = object::address_to_object<Token>(token_id2);
rap_battle::go_on_stage_or_battle(&minter, rapper_token, 0);
let second = 1_000_000;
timestamp::update_global_time_for_test(now + 50*second);
rap_battle::go_on_stage_or_battle(&attacker, rapper_token2, 0);
let battle_events = event::emitted_events<Battle>();
print(&battle_events);
}
Recommended Mitigation
Use Aptos's secure randomness API instead of timestamps:
+ use aptos_framework::randomness;
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
// ... existing code ...
let total_skill = defender_skill + challenger_skill;
- let rnd = timestamp::now_seconds() % total_skill;
+ // Use secure on-chain randomness
+ let rnd = randomness::u64_range(0, total_skill);
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
// ... rest of function
}
This preserves the intended skill-based probability system while eliminating timestamp manipulation