Beginner FriendlyGameFi
100 EXP
View results
Submission Details
Severity: high
Valid

Unrestricted registration via public entry get_random_slice enables Sybil drain

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 { // // externally callable; no owner/ACL → self-registration
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); // writes “registered” key; since this is public entry, anyone can add for any address (bypasses owner-only registration)
}
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); // eligibility = presence in the table that attackers can populate via the public entry above
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

// 1) Attacker self-registers by calling get_random_slice(attacker_addr).
// 2) Once the pool is funded >= assigned amount, attacker calls claim_pizza_slice and receives coins.
#[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);
// Attacker self-registers (no owner):
let a = signer::address_of(attacker);
get_random_slice(a); // <-- unrestricted 'entry'
assert!(is_registered(a), 1);
let amt = get_claimed_amount(a);
// Owner funds at least 'amt' and attacker claims:
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); // <-- succeeds
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 });
}
Updates

Appeal created

bube Lead Judge 10 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Anyone can call `get_random_slice` function

Support

FAQs

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