Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

Centralization Risks

The contract owner has excessive control over faucet operations without adequate restrictions, creating centralization risks.

Description

  • Normal behavior:
    A faucet contract should allow the operator to maintain and refill the faucet while keeping destructive or high-risk actions (minting, burning, pausing, changing limits, withdrawing) guarded by appropriate governance (multisig, timelock or separated roles). Operator keys used for automation should not simultaneously hold all high-power privileges.

  • Issue:

    A single owner (deployer) account holds multiple high-risk privileges (mintFaucetTokens, burnFaucetTokens, adjustDailyClaimLimit, toggleEthDripPause, refillSepEth, etc.). In addition, burnFaucetTokens contains a problematic implementation that transfers the contract token balance to owner before burning — giving the owner direct ability to extract token balance. This centralization means owner compromise or misuse can immediately disrupt service or extract funds.

// Owner-only burn function: transfers full contract balance to owner, then burns only part
function burnFaucetTokens(uint256 amountToBurn) public onlyOwner {
require(amountToBurn <= balanceOf(address(this)), "Faucet Token Balance: Insufficient");
// transfer faucet balance to owner first before burning
// ensures owner has a balance before _burn (owner only function) can be called successfully
@> _transfer(address(this), msg.sender, balanceOf(address(this))); // transfer the whole balance, not `amountToBurn` — OWNER GETS FULL CONTRACT BALANCE
@> _burn(msg.sender, amountToBurn); // burn `amountToBurn`, but `(transfer amount) - amountToBurn` still on the msg.sender`s balance
}
// Other powerful owner-only functions (examples):
@> function mintFaucetTokens(address to, uint256 amount) public onlyOwner { ... }
@> function adjustDailyClaimLimit(uint256 by, bool increaseClaimLimit) public onlyOwner { ... }
@> function toggleEthDripPause(bool _paused) external onlyOwner { ... }
@> function refillSepEth(uint256 amountToRefill) external payable onlyOwner { ... }

Risk

Likelihood:

  • Owner / deployer keys are commonly used for deployment and automation; when a single EOA is used as the owner, compromise or misconfiguration is plausible (high chance during development or insufficient operational controls).

  • Owner is required for routine operations (refill, parameter change, pause/unpause); every owner action is a high-risk surface that can be triggered by mistake or by a compromised key.

Impact:

  • Full service disruption — owner can pause ETH drips, set claim limits to 0, or otherwise block legitimate users.

  • Direct financial loss — due to the burnFaucetTokens logic owner can receive contract token balance and retain the remainder after partial burn; owner-controlled minting can inflate supply.

  • Trust / governance risk — single-key control undermines decentralization and makes the system fragile to compromise, insider abuse, or operational mistakes.

Proof of Concept

PoC (high-level steps you can reproduce):

  1. Deploy contract (owner = deployer). Fund contract with tokens and ETH.

  2. As owner, call burnFaucetTokens(amountToBurn) with amountToBurn < balanceOf(address(this)).

    • Expected result: owner balance increases by balanceOf(address(this)) (full transfer), then _burn reduces owner balance by amountToBurn, leaving owner with (balanceBefore - amountToBurn) tokens extracted from contract.

  3. As owner, call toggleEthDripPause(true) to disable ETH drips; then optionally adjustDailyClaimLimit(0, false) to set low/zero limits; verify claims fail.

  4. As owner, call mintFaucetTokens(address(this), amount) to mint tokens to contract (or to other address depending on to-parameter), or call refillSepEth{value: ...}() to increase contract ETH balance.

  5. Demonstrate that a single owner account can both disable service and extract funds/tokens.

// Owner drains all tokens while appearing to burn only small amount
function burnFaucetTokens(uint256 amountToBurn) public onlyOwner {
// Takes ALL tokens from contract
_transfer(address(this), msg.sender, balanceOf(address(this)));
// Only burns small portion, keeps the rest
_burn(msg.sender, amountToBurn);
}

Recommended Mitigation

1) Fix burn logic.

Replace current transfer-then-burn with direct burn from contract (or transfer the exact amount if owner should receive tokens).

- // transfer faucet balance to owner first before burning
- // ensures owner has a balance before _burn (owner only function) can be called successfully
- _transfer(address(this), msg.sender, balanceOf(address(this)));
-
- _burn(msg.sender, amountToBurn);
+ // Burn tokens directly from the contract balance (do not transfer the entire contract balance to owner)
+ // This prevents owner from extracting the remainder of the contract balance.
+ _burn(address(this), amountToBurn);

Rationale: burning from address(this) reduces contract token supply without transferring tokens to owner. If the intended behaviour is to let owner withdraw tokens, implement an explicit withdrawTokens(amount) function protected by governance/multisig (see below).

2) Reduce owner power — adopt role separation and governance.

Replace single EOA onlyOwner operational model with one or more of the following (recommendations in order):

  • Immediate / pragmatic: Require operator/automation keys to be multisig (e.g., Gnosis Safe) instead of single EOA. Make multisig the owner.

  • Short-term code change: introduce OPERATOR_ROLE (for low-risk automated ops) and keep critical actions under MANAGER_ROLE or DEFAULT_ADMIN_ROLE protected by a multisig account or timelock. Example pattern (pseudocode):

+ bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
+ // use AccessControl or similar; assign OPERATOR_ROLE to automation key, MANAGER_ROLE to multisig
- function lockToken(uint256 tokenId) external onlyOwner { ... }
+ function lockToken(uint256 tokenId) external onlyRole(OPERATOR_ROLE) { ... }
// Owner-only critical functions -> require multisig or DEFAULT_ADMIN_ROLE
- function burnFaucetTokens(uint256 amount) public onlyOwner { ... }
+ function burnFaucetTokens(uint256 amount) public onlyRole(DEFAULT_ADMIN_ROLE) { ... }
  • Best practice: route all admin privilege changes (granting roles, mint/burn beyond small emergency amounts, changing caps) through a timelock or multisig + timelock so community/operators have time to react.

3) Operational controls & docs

  • Revoke deployer’s privileged roles after deployment (or make deployer a multisig address).

  • Add event logging and off-chain alerts for critical changes (pause/unpause, mint, burn, limit changes).

  • Add explicit tests asserting that only expected roles can perform specific actions (regression test).

Updates

Lead Judging Commences

inallhonesty Lead Judge 16 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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