Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

ETH Permanently Locked When Wrong msg.value Sent to buySnow

Root + Impact

Description

buySnow() accepts ETH as payment for Snow tokens when msg.value equals exactly s_buyFee * amount. When the caller
sends a non-zero msg.value that does not match this exact figure, the function silently falls through to the
WETH branch: it pulls WETH from the caller and mints Snow — but keeps the ETH. The ETH is then trapped in the
contract with no withdrawal path accessible to the sender, only recoverable via collectFee() by the collector
address.

A user who sends msg.value = s_buyFee * amount - 1 (off by 1 wei), or who sends ETH while also having WETH
approval, pays twice: once in ETH (lost) and once in WETH (pulled). There is no receive() / fallback() guard, and
no refund mechanism.

// Snow.sol:79-90
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // exact match only
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount); // ETH from msg.value silently kept
}
// no refund of msg.value in the else branch
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • When a user sends a slightly wrong ETH amount due to fee miscalculation, gas estimation rounding, or a frontend
    bug

  • When a user interacts directly via Etherscan or a script and misjudges the fee

  • When a frontend constructs the transaction with a stale s_buyFee value

Impact:

  • Sender's ETH is permanently inaccessible to them — only the collector can retrieve it via collectFee()

  • User who has WETH approval loses both ETH and WETH for a single Snow mint

  • No on-chain event or revert indicates that anything went wrong — the transaction succeeds and emits SnowBought

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../../src/Snow.sol";
import {SnowmanAirdrop} from "../../src/SnowmanAirdrop.sol";
import {Snowman} from "../../src/Snowman.sol";
import {MockWETH} from "../../src/mock/MockWETH.sol";
import {Helper} from "../../script/Helper.s.sol";
contract PoC_SA05 is Test {
Snow snow;
MockWETH weth;
address alice = makeAddr("alice");
function setUp() public {
Helper deployer = new Helper();
SnowmanAirdrop airdrop;
Snowman nft;
(airdrop, snow, nft, weth) = deployer.run();
}
function test_exploit_SA05_ETHLockedOnWrongValue() public {
uint256 fee = snow.s_buyFee(); // e.g. 1e18
uint256 wrongValue = fee - 1; // 1 wei short of exact
// Fund alice with ETH and WETH
vm.deal(alice, wrongValue);
deal(address(weth), alice, fee);
vm.startPrank(alice);
weth.approve(address(snow), fee);
uint256 contractEthBefore = address(snow).balance;
// Alice sends wrong ETH amount -- falls into WETH branch
snow.buySnow{value: wrongValue}(1);
uint256 contractEthAfter = address(snow).balance;
// Snow minted via WETH, ETH also sitting in contract
assertEq(snow.balanceOf(alice), 1, "alice got Snow via WETH");
assertEq(contractEthAfter - contractEthBefore, wrongValue, "ETH trapped in contract");
assertEq(alice.balance, 0, "alice lost her ETH");
console2.log("ETH locked in Snow contract:", contractEthAfter - contractEthBefore);
console2.log("Alice WETH spent:", fee - weth.balanceOf(alice));
console2.log("[CONFIRMED] SA-05: wrong msg.value is locked, WETH also pulled");
vm.stopPrank();
}
}

Recommended Mitigation

Revert when msg.value is non-zero but does not match the required ETH amount. This makes the ETH/WETH path
selection explicit and prevents any ETH from being silently retained.

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
+ if (msg.value != 0) revert S__ZeroValue(); // reject partial/wrong ETH
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours 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!