AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

ETH Fee Drainage Griefing — Owner Can Be Blocked from Receiving Fees via Revert Bomb

[HIGH-3] ETH Fee Drainage Griefing — Owner Can Be Blocked from Receiving Fees via Revert Bomb

File: src/MerkleAirdrop.sol, claimFees() (lines 44–48)

Summary

The claimFees() function sends ETH to the owner using a low-level .call{}(""). If the owner is a smart contract with a receive() or fallback() function that reverts, the claimFees() call will always revert and accumulated fees are permanently locked. This is a well-known "push payment" vulnerability.

Vulnerability Details

function claimFees() external onlyOwner {
(bool succ,) = payable(owner()).call{ value: address(this).balance }("");
if (!succ) {
revert MerkleAirdrop__TransferFailed();
}
}

The function pushes all ETH to owner(). If:

  1. The owner is a multisig or smart contract that temporarily cannot receive ETH (e.g., during an upgrade or if the multisig logic reverts),

  2. The owner deliberately transfers ownership to a contract that always reverts on receive,

  3. A malicious actor socially engineers an ownership transfer,

...then succ == false, and MerkleAirdrop__TransferFailed is thrown. All ETH fees accumulated in the contract are permanently inaccessible.

Additionally, Ownable.transferOwnership() (inherited from OZ v5) does NOT check if the new owner can receive ETH before the transfer completes, making it trivial to lock fees accidentally.

Note: In Solidity 0.8.x, owner() can also theoretically return address(0) after renounceOwnership() is called (OZ Ownable). Sending ETH to address(0) via .call returns true (ETH is burned) — but the fee is permanently lost.

PoC

// Attacker deploys a revert-on-receive contract and becomes owner
contract BlackholeOwner {
receive() external payable { revert("no ETH"); }
}
// Scenario: original owner is compromised and transfers ownership to BlackholeOwner
// Now claimFees() always reverts — fees are stuck forever
// All FEE payments (even negligible ones) are unrecoverable
// Scenario 2: owner calls renounceOwnership() (OZ Ownable allows this!)
airdrop.renounceOwnership();
// Now owner() == address(0)
// claimFees() sends ETH to address(0) — ETH is burned, not recovered
// But the call returns true, so no revert — fees are silently burned

Impact

  • Accumulated ETH fees permanently locked or burned.

  • Protocol owner cannot recover funds.

  • Severity: High (permanent loss of ETH, no recovery path)

Tools Used

  • Manual analysis

  • OZ Ownable v5 behavior analysis

  • Push-payment vulnerability pattern

Recommendations

Use a pull-payment pattern instead:

+ mapping(address => uint256) private s_pendingWithdrawals;
+
+ function claimFees() external onlyOwner {
+ uint256 amount = address(this).balance;
+ s_pendingWithdrawals[owner()] += amount;
+ }
+
+ function withdrawFees() external {
+ uint256 amount = s_pendingWithdrawals[msg.sender];
+ require(amount > 0, "Nothing to withdraw");
+ s_pendingWithdrawals[msg.sender] = 0;
+ (bool succ,) = payable(msg.sender).call{value: amount}("");
+ require(succ, "Transfer failed");
+ }

Or at minimum, add a to parameter to avoid dependency on owner():

- function claimFees() external onlyOwner {
- (bool succ,) = payable(owner()).call{ value: address(this).balance }("");
+ function claimFees(address payable recipient) external onlyOwner {
+ require(recipient != address(0), "Invalid recipient");
+ (bool succ,) = recipient.call{ value: address(this).balance }("");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 6 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!