One Shot: Reloaded

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

Predictable, miner‑timeable RNG in winner selection

Author Revealed upon completion

Predictable, validator‑timeable RNG via timestamp::now_seconds() in winner selection lets attackers bias outcomes and drain prize pools

Description

  • Normal behavior. In rap_battle::go_on_stage_or_battle, two players place equal CRED bets; the contract calculates each Rapper’s skill and picks a winner “randomly” with probability proportional to skill, then pays the combined prize pool to the winner and increments their battles_won. The README also states “RNG: Battle randomness is derived from timestamp::now_seconds().”

  • **Issue. **The randomness is derived from the current timestamp: timestamp::now_seconds() % total_skill. This is predictable and steerable by transaction timing (spam/resubmission until a favorable second) and potentially validator‑influenced (inclusion, reordering, or delaying), enabling adversaries to bias or even fix outcomes.

// File: rap_battle.move (module battle_addr::rap_battle)
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;
// PREDICTABLE RNG: uses wall-clock seconds
@> let rnd = timestamp::now_seconds() % (if (total_skill == 0) { 1 } else { total_skill });
// WINNER SELECTION depends on predictable rnd
@> let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };

Risk

Likelihood:

  • Mempool timing is enough: A challenger can observe the defender on stage and only submit (or repeatedly resubmit) when now_seconds() % total_skill favors them. Because residues repeat every second, a favorable second arrives quickly and regularly. This occurs whenever users can choose their submission second.

  • Validator influence: Validators can bias results by choosing which block/second includes the battle, or by reordering transactions, since the RNG depends on the timestamp the transaction lands in. This is inherent to timestamp‑based RNG.

Impact:

  • Economic drain & unfair wins: Attackers can systematically win wagers, drain CRED from honest users, and distort battles_won statistics, degrading game integrity.

  • Targeted griefing: Adversaries can refuse to battle on unfavorable seconds and accept only favorable ones, selectively griefing specific defenders.

Proof of Concept

Off‑chain timing bot: After OnStage event fires, read defender_skill directly from the chain using defender address from the emitted event and know your challenger_skill. Only fire the transaction at seconds where s % (defender+challenger) >= defender_skill, i.e., where challenger wins per contract logic:

# Pseudocode
def favorable_second(now_s, defender_skill, challenger_skill):
total = defender_skill + challenger_skill or 1
return now_s % total >= defender_skill
while True:
s = now_seconds()
if favorable_second(s, defender_skill, challenger_skill):
submit_battle_tx(fee_tip="high") # rush inclusion now
break
# otherwise wait a fraction of a second and retry

Recommended Mitigation

For selecting a winner, Aptos' secure, validator-unbiasable on-chain Randomness API (0x1::randomness) should be used. The API returns uniformly random values when it is called from a private #[randomness] entry fun. To implement this, the timestamp-based modulo should be replaced with aptos_framework::randomness::u64_range(0, total_skill). Also, it's recommended to use split‑entry pattern.

@@
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::randomness;
use aptos_token_v2::token::Token;
use battle_addr::cred_token;
use battle_addr::one_shot;
/// The battle arena is already occupied by a defender.
const E_BATTLE_ARENA_OCCUPIED: u64 = 1;
/// The bet amount provided by the challenger does not match the defender's bet.
const E_BETS_DO_NOT_MATCH: u64 = 2;
+ /// A challenger is already set; cannot accept a second challenger.
+ const E_ALREADY_CHALLENGED: u64 = 3;
+ /// Only the recorded challenger can settle the battle.
+ const E_NOT_CHALLENGER: u64 = 4;
const BASE_SKILL: u64 = 65;
const VICE_DECREMENT: u64 = 5;
const VIRTUE_INCREMENT: u64 = 10;
struct BattleArena has key {
defender: address,
defender_bet: u64,
defender_token_id: address,
+ challenger: address,
+ challenger_token_id: address,
prize_pool: Coin<cred_token::CRED>,
}
@@
// MUST be private
fun init_module(sender: &signer) {
move_to(sender, BattleArena {
defender: @0x0,
defender_bet: 0,
defender_token_id: @0x0,
+ challenger: @0x0,
+ challenger_token_id: @0x0,
prize_pool: coin::zero<cred_token::CRED>(),
});
}
- public entry fun go_on_stage_or_battle(
- player: &signer,
- rapper_token: Object<Token>,
- bet_amount: u64
- ) acquires BattleArena {
- let player_addr = signer::address_of(player);
- let arena = borrow_global_mut<BattleArena>(@battle_addr);
- if (arena.defender == @0x0) {
- assert!(arena.defender_bet == 0, E_BATTLE_ARENA_OCCUPIED);
- arena.defender = player_addr;
- arena.defender_bet = bet_amount;
- let token_id = object::object_address(&rapper_token);
- arena.defender_token_id = token_id;
- let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
- coin::merge(&mut arena.prize_pool, first_bet);
- one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
- object::transfer(player, rapper_token, @battle_addr);
- event::emit(OnStage {
- defender: player_addr,
- token_id,
- cred_bet: bet_amount,
- });
- } else {
- assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
- let defender_addr = arena.defender;
- let chall_addr = player_addr;
- let chall_token_id = object::object_address(&rapper_token);
- one_shot::transfer_record_only(chall_token_id, chall_addr, @battle_addr);
- object::transfer(player, rapper_token, @battle_addr);
- let chall_coins = coin::withdraw<cred_token::CRED>(player, bet_amount);
- coin::merge(&mut arena.prize_pool, chall_coins);
- 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 };
- event::emit(Battle {
- challenger: chall_addr,
- challenger_token_id: chall_token_id,
- winner,
- });
- let pool = coin::extract_all(&mut arena.prize_pool);
- if (winner == defender_addr) {
- coin::deposit(defender_addr, pool);
- one_shot::increment_wins(arena.defender_token_id);
- one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, defender_addr);
- one_shot::transfer_record_only(chall_token_id, @battle_addr, defender_addr);
- } else {
- coin::deposit(chall_addr, pool);
- one_shot::increment_wins(chall_token_id);
- one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr);
- one_shot::transfer_record_only(chall_token_id, @battle_addr, chall_addr);
- };
- arena.defender = @0x0;
- arena.defender_bet = 0;
- arena.defender_token_id = @0x0;
- }
- }
+
+ // --- Step 1: Defender takes the stage (no randomness here) ---
+ public entry fun go_on_stage(
+ player: &signer,
+ rapper_token: Object<Token>,
+ bet_amount: u64
+ ) acquires BattleArena {
+ let player_addr = signer::address_of(player);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.defender == @0x0 && arena.defender_bet == 0, E_BATTLE_ARENA_OCCUPIED);
+ assert!(arena.challenger == @0x0, E_BATTLE_ARENA_OCCUPIED);
+ let token_id = object::object_address(&rapper_token);
+ arena.defender = player_addr;
+ arena.defender_bet = bet_amount;
+ arena.defender_token_id = token_id;
+ let first_bet = coin::withdraw<cred_token::CRED>(player, bet_amount);
+ coin::merge(&mut arena.prize_pool, first_bet);
+ one_shot::transfer_record_only(token_id, player_addr, @battle_addr);
+ object::transfer(player, rapper_token, @battle_addr);
+ event::emit(OnStage { defender: player_addr, token_id, cred_bet: bet_amount });
+ }
+
+ // --- Step 2: Challenger matches the bet and locks in state (still no randomness) ---
+ public entry fun challenge(
+ player: &signer,
+ rapper_token: Object<Token>,
+ bet_amount: u64
+ ) acquires BattleArena {
+ let chall_addr = signer::address_of(player);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.defender != @0x0, E_BATTLE_ARENA_OCCUPIED);
+ assert!(arena.challenger == @0x0, E_ALREADY_CHALLENGED);
+ assert!(arena.defender_bet == bet_amount, E_BETS_DO_NOT_MATCH);
+
+ let chall_token_id = object::object_address(&rapper_token);
+ one_shot::transfer_record_only(chall_token_id, chall_addr, @battle_addr);
+ object::transfer(player, rapper_token, @battle_addr);
+
+ let chall_coins = coin::withdraw<cred_token::CRED>(player, bet_amount);
+ coin::merge(&mut arena.prize_pool, chall_coins);
+
+ arena.challenger = chall_addr;
+ arena.challenger_token_id = chall_token_id;
+ settle_battle(player);
+ }
+
+ // --- Step 3: Challenger settles with secure randomness ---
+ #[randomness]
+ entry fun settle_battle(
+ challenger: &signer
+ ) acquires BattleArena {
+ let chall_addr = signer::address_of(challenger);
+ let arena = borrow_global_mut<BattleArena>(@battle_addr);
+ assert!(arena.challenger == chall_addr, E_NOT_CHALLENGER);
+
+ let defender_skill = one_shot::skill_of(arena.defender_token_id);
+ let challenger_skill = one_shot::skill_of(arena.challenger_token_id);
+ let total_skill = defender_skill + challenger_skill;
+ let modulo = if (total_skill == 0) { 1 } else { total_skill };
+ let rnd = randomness::u64_range(0, modulo);
+ let winner = if (rnd < defender_skill) { arena.defender } else { chall_addr };
+
+ event::emit(Battle {
+ challenger: chall_addr,
+ challenger_token_id: arena.challenger_token_id,
+ winner,
+ });
+
+ let pool = coin::extract_all(&mut arena.prize_pool);
+ if (winner == arena.defender) {
+ coin::deposit(arena.defender, pool);
+ one_shot::increment_wins(arena.defender_token_id);
+ one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, arena.defender);
+ one_shot::transfer_record_only(arena.challenger_token_id, @battle_addr, arena.defender);
+ } else {
+ coin::deposit(chall_addr, pool);
+ one_shot::increment_wins(arena.challenger_token_id);
+ one_shot::transfer_record_only(arena.defender_token_id, @battle_addr, chall_addr);
+ one_shot::transfer_record_only(arena.challenger_token_id, @battle_addr, chall_addr);
+ };
+
+ // Reset arena
+ arena.defender = @0x0;
+ arena.defender_bet = 0;
+ arena.defender_token_id = @0x0;
+ arena.challenger = @0x0;
+ arena.challenger_token_id = @0x0;
+ }
}

Support

FAQs

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