Snowman Merkle Airdrop

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

`buySnow()` payment check unclear — accepts exact ETH or else assumes WETH transfer; no validation if user sends incorrect msg.value.

Author Revealed upon completion

Root + Impact

Description

Normal behavior:

The buySnow() function allows users to purchase Snow tokens by paying either in native ETH or by transferring WETH. The expected cost is calculated as s_buyFee * amount.

The problem:

The function checks for an exact ETH payment match using msg.value == (s_buyFee * amount). If this check fails, it assumes the user wants to use WETH and proceeds to transfer WETH using safeTransferFrom. However, it does not ensure that msg.value is zero in that path, nor does it validate that the WETH transfer will succeed ahead of time. This causes silent ETH loss when the user sends non-zero msg.value that does not exactly match the cost.

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));
_mint(msg.sender, amount);
}
}

Risk

Likelihood: High

  • This issue occurs when a user sends an incorrect msg.value amount — such as slightly too much or too little ETH — when calling buySnow(). The function does not validate that the ETH sent is exactly equal to the expected fee, and proceeds assuming the user wants to pay with WETH instead.

  • The lack of validation allows ETH to be accidentally sent to the contract without being refunded or used, which is highly likely in frontend miscalculations, manual transactions, or poor UX prompting


Impact: High

  • Users can irreversibly lose ETH if they mistakenly overpay or underpay the exact required msg.value for minting Snow tokens. This represents a direct financial loss.

  • The protocol silently mints tokens under the assumption that WETH is being used without ensuring WETH was actually transferred. This makes the contract behavior ambiguous, misleading, and potentially harmful to end users interacting via ETH.



Proof of Concept

  • This contract sends slightly more ETH than expected for buySnow.

  • Since the contract only checks for an exact match (msg.value == s_buyFee * amount), any mismatch defaults to WETH payment logic, even though WETH was not transferred.

  • This means:

    • ETH is silently accepted and trapped in the contract.

    • The user may or may not get Snow tokens depending on how the fallback logic interprets state, leading to undefined and harmful behavior.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
interface ISnow {
function buySnow(uint256 amount) external payable;
function getCollector() external view returns (address);
}
contract ExploitPoC {
ISnow public immutable snow;
constructor(address _snow) {
snow = ISnow(_snow);
}
function exploit() external payable {
require(msg.value > 0, "Send ETH");
// Intentionally send slightly more ETH than required (1 wei extra)
uint256 buyAmount = 1;
uint256 fee = 0.01 ether; // Suppose s_buyFee = 0.01 ether
uint256 overpayment = fee * buyAmount + 1;
// This will trigger the fallback WETH path even though user intended to pay in ETH
// But WETH is never transferred, so user unintentionally receives free tokens
// Or nothing is minted and ETH is silently stuck
snow.buySnow{value: overpayment}(buyAmount);
}
}

Recommended Mitigation

Ensure that ETH payments in buySnow() strictly match the required fee (s_buyFee * amount). If msg.value is greater or less than expected, revert the transaction with a clear error. This prevents users from unintentionally losing ETH due to overpayment and enforces safe and predictable payment logic.

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));
- _mint(msg.sender, amount);
- }
+ uint256 totalCost = s_buyFee * amount;
+
+ if (msg.value > 0) {
+ // Enforce exact ETH match; reject anything else
+ if (msg.value != totalCost) {
+ revert S__IncorrectETHAmount(); // <-- Add new custom error
+ }
+ _mint(msg.sender, amount);
+ } else {
+ // Fallback to WETH payment
+ i_weth.safeTransferFrom(msg.sender, address(this), totalCost);
+ _mint(msg.sender, amount);
+ }
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Support

FAQs

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