Snowman Merkle Airdrop

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

Token Theft in `buySnow`

buySnow does not return msg.value when the msg.value == (s_buyFee * amount) check fails.

Description

buySnow does not return msg.value when the msg.value == (s_buyFee * amount) check fails.

if the msg.value is not exactly eqaul to (s_buyFee * amount), the function pays for the mint using weth, but never returns the ETH sent to msg.value. This is critical fund theft, with no way to withdraw that ETH.

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:

  • Very likely, even a slight inaccuracy will result in complete fund loss

Impact:

  • Fund loss

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {DeploySnow} from "../script/DeploySnow.s.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
/**
* PoC: buySnow does not refund msg.value when msg.value != s_buyFee * amount
*
* If the user sends ETH that doesn't exactly match the fee, the function:
* 1. Pulls WETH from the user via safeTransferFrom (the else branch)
* 2. Never returns the ETH sent as msg.value
* 3. The ETH is permanently stuck in the contract (only the collector can sweep it)
*/
contract PoC_BuySnowEthLoss is Test {
Snow snow;
DeploySnow deployer;
MockWETH weth;
address collector;
uint256 FEE; // s_buyFee after construction
address victim;
function setUp() public {
deployer = new DeploySnow();
snow = deployer.run();
weth = deployer.weth();
collector = deployer.collector();
FEE = deployer.FEE(); // s_buyFee = 5 * 10**18
victim = makeAddr("victim");
// Give the victim enough WETH and ETH
weth.mint(victim, FEE);
deal(victim, FEE);
}
function test_PoC_EthNotRefundedWhenMsgValueMismatch() public {
uint256 userEthBefore = victim.balance;
uint256 userWethBefore = weth.balanceOf(victim);
uint256 contractEthBefore = address(snow).balance;
uint256 contractWethBefore = weth.balanceOf(address(snow));
// Victim sends msg.value = FEE - 1 (1 wei less than required)
// The exact-check fails, so the else branch pulls FEE WETH instead
// The 1 wei of ETH is never returned to the user
vm.startPrank(victim);
weth.approve(address(snow), FEE);
snow.buySnow{value: FEE - 1}(1);
vm.stopPrank();
// The ETH sent is stuck in the contract
assertEq(address(snow).balance, contractEthBefore + (FEE - 1), "ETH should be stuck in contract");
assertEq(victim.balance, userEthBefore - (FEE - 1), "Victim lost their ETH");
// The WETH was also pulled from the victim
assertEq(weth.balanceOf(victim), userWethBefore - FEE, "Victim also paid WETH");
assertEq(weth.balanceOf(address(snow)), contractWethBefore + FEE, "Contract has the WETH too");
// The victim got their Snow tokens, but overpaid by (FEE - 1) ETH
assertEq(snow.balanceOf(victim), 1, "Victim got snow tokens");
// The victim cannot recover the stuck ETH -- there is no withdraw function
// Only the collector can sweep it via collectFee()
// So the victim's ETH is lost forever (unless they are the collector)
}
function test_PoC_ExactCheckSucceeds_NoLoss() public {
// Baseline: when msg.value is exact, everything works fine
uint256 userEthBefore = victim.balance;
vm.prank(victim);
snow.buySnow{value: FEE}(1);
assertEq(victim.balance, userEthBefore - FEE, "ETH spent correctly");
assertEq(address(snow).balance, FEE, "Contract holds the ETH");
assertEq(snow.balanceOf(victim), 1, "Victim got snow");
}
}

Recommended Mitigation

Return the eth

(bool sent,) = payable(msg.sender).call{value: msg.value}("");
require(sent, "Snow::buySnow: ETH transfer failed");
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!