Raisebox Faucet

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

Race Condition Due to Shared State Variable `faucetClaimer`

Root + Impact

The faucetClaimer public state variable is set at the beginning of every claimFaucetTokens call and used throughout the function, creating dangerous race conditions where concurrent transactions can overwrite each other's claimer address, causing tokens and ETH to be sent to wrong recipients.

Description

  • The normal and safe pattern is to use local variables or function parameters for transaction-specific data to avoid shared mutable state that can be affected by concurrent transactions.

  • The critical bug is on line 162 where faucetClaimer = msg.sender immediately sets a public state variable that is then used throughout the entire function execution. In Ethereum's concurrent transaction environment, if multiple users call claimFaucetTokens in the same block or with overlapping execution windows, one user's transaction can overwrite the faucetClaimer value that another user's transaction is using, causing tokens and ETH transfers to go to the wrong address. This creates a severe race condition where User A could receive nothing while User B receives double the tokens.

function claimFaucetTokens() public {
// Checks
@> faucetClaimer = msg.sender; // BUG: Sets shared global state immediately!
if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
// ... function continues using faucetClaimer ...
@> if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
// ... ETH drip logic ...
@> (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
}
@> lastClaimTime[faucetClaimer] = block.timestamp;
@> _transfer(address(this), faucetClaimer, faucetDrip);
}

Risk

Likelihood:

  • Occurs whenever multiple users call claimFaucetTokens within the same block or with overlapping execution

  • More likely on high-throughput networks like Polygon or during periods of network congestion

  • Can be exploited by MEV bots through transaction ordering manipulation

  • Natural occurrence increases as the protocol gains more users

  • Does not require malicious intent - can happen accidentally during normal usage

Impact:

  • Tokens or ETH intended for User A are sent to User B instead

  • User A's transaction succeeds but they receive nothing

  • State corruption in lastClaimTime mappings where wrong users get cooldown timestamps

  • Users might permanently lose their ability to claim if cooldown is set for wrong address

  • Complete loss of funds for legitimate users

  • Breaks trust in the faucet system and protocol integrity

Proof of Concept

This test simulates concurrent transactions showing how the race condition causes tokens to go to the wrong address.

it("Should demonstrate race condition with faucetClaimer", async function () {
// Initial balances
const aliceBalanceBefore = await faucet.balanceOf(alice.address);
const bobBalanceBefore = await faucet.balanceOf(bob.address);
// Simulate concurrent claims by disabling auto-mining
await ethers.provider.send("evm_setAutomine", [false]);
// Both Alice and Bob submit claims (concurrent)
console.log("\n⚡ Submitting concurrent transactions...");
const aliceTx = faucet.connect(alice).claimFaucetTokens();
const bobTx = faucet.connect(bob).claimFaucetTokens();
// Wait for transactions to be sent
await Promise.all([aliceTx, bobTx]);
// Mine the block containing both transactions
await ethers.provider.send("evm_mine");
await ethers.provider.send("evm_setAutomine", [true]);
// Check results
const aliceBalanceAfter = await faucet.balanceOf(alice.address);
const bobBalanceAfter = await faucet.balanceOf(bob.address);
const currentClaimer = await faucet.getClaimer();
const aliceReceived = aliceBalanceAfter - aliceBalanceBefore;
const bobReceived = bobBalanceAfter - bobBalanceBefore;
const expectedAmount = ethers.parseEther("1000");
// The race condition may cause unpredictable results
// One user might get double, the other might get nothing
const totalReceived = aliceReceived + bobReceived;
console.log(`Total distributed: ${ethers.formatEther(totalReceived)} tokens`);
// Check cooldown corruption
const aliceCooldown = await faucet.getUserLastClaimTime(alice.address);
const bobCooldown = await faucet.getUserLastClaimTime(bob.address);
// Demonstrate the issue: faucetClaimer will be whoever called last
console.log("\n⚠️ The faucetClaimer variable was overwritten:");
console.log(`Final faucetClaimer: ${currentClaimer}`);
console.log(`This proves the shared state vulnerability!");
});
});

Recommended Mitigation

Replace the shared state variable with a local variable that is function-scoped. This ensures each transaction has its own isolated claimer address that cannot be affected by other concurrent transactions.

function claimFaucetTokens() public {
// Checks
- faucetClaimer = msg.sender;
+ address claimer = msg.sender;
- if (block.timestamp < (lastClaimTime[faucetClaimer] + CLAIM_COOLDOWN)) {
+ if (block.timestamp < (lastClaimTime[claimer] + CLAIM_COOLDOWN)) {
revert RaiseBoxFaucet_ClaimCooldownOn();
}
- if (faucetClaimer == address(0) || faucetClaimer == address(this) || faucetClaimer == Ownable.owner()) {
+ if (claimer == address(0) || claimer == address(this) || claimer == Ownable.owner()) {
revert RaiseBoxFaucet_OwnerOrZeroOrContractAddressCannotCallClaim();
}
if (balanceOf(address(this)) <= faucetDrip) {
revert RaiseBoxFaucet_InsufficientContractBalance();
}
if (dailyClaimCount >= dailyClaimLimit) {
revert RaiseBoxFaucet_DailyClaimLimitReached();
}
- if (!hasClaimedEth[faucetClaimer] && !sepEthDripsPaused) {
+ if (!hasClaimedEth[claimer] && !sepEthDripsPaused) {
uint256 currentDay = block.timestamp / 24 hours;
if (currentDay > lastDripDay) {
lastDripDay = currentDay;
dailyDrips = 0;
}
if (dailyDrips + sepEthAmountToDrip <= dailySepEthCap && address(this).balance >= sepEthAmountToDrip) {
- hasClaimedEth[faucetClaimer] = true;
+ hasClaimedEth[claimer] = true;
dailyDrips += sepEthAmountToDrip;
- (bool success,) = faucetClaimer.call{value: sepEthAmountToDrip}("");
+ (bool success,) = claimer.call{value: sepEthAmountToDrip}("");
if (success) {
- emit SepEthDripped(faucetClaimer, sepEthAmountToDrip);
+ emit SepEthDripped(claimer, sepEthAmountToDrip);
} else {
revert RaiseBoxFaucet_EthTransferFailed();
}
}
}
if (block.timestamp > lastFaucetDripDay + 1 days) {
lastFaucetDripDay = block.timestamp;
dailyClaimCount = 0;
}
// Effects
- lastClaimTime[faucetClaimer] = block.timestamp;
+ lastClaimTime[claimer] = block.timestamp;
dailyClaimCount++;
+ faucetClaimer = claimer; // Update state variable only at end for tracking
// Interactions
- _transfer(address(this), faucetClaimer, faucetDrip);
+ _transfer(address(this), claimer, faucetDrip);
emit Claimed(msg.sender, faucetDrip);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 11 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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