Snowman Merkle Airdrop

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

"Dual payment logic flaw: ETH becomes permanently locked when user sends incorrect ETH amount and lacks WETH"

Root
The root cause is a flawed conditional payment logic ** **in the buySnow function
The function assumes that if the exact ETH amount isn't sent, the user intends to pay with WETH. This creates a dangerous scenario where:

  1. User sends incorrect ETH amount (e.g., sends 0.5 ETH when 1 ETH is required)

  2. Function falls into the else branch and attempts WETH transfer

  3. If user lacks sufficient WETH balance or hasn't approved the contract, the safeTransferFrom reverts

  4. The incorrectly sent ETH remains trapped in the contract forever - there's no refund mechanisma

    Financial Impact:

    • Permanent loss of user funds: Any ETH sent with incorrect amounts becomes irretrievable

    • No recovery mechanism: Contract has no function to withdraw or refund trapped ETH

    • Accumulating locked funds: Multiple users making this mistake will result in growing amounts of permanently locked ETH
      User Experience Impact:

    • Transaction failures: Users get reverted transactions but lose gas fees

    • Confusing behavior: Users may not understand why their transaction failed despite sending ETH

    • Trust issues: Users losing funds due to minor mistakes damages protocol reputation

Description

  • User is meant to either choose payment currency, either weth or ether to get snow NFT

if (msg.value == (s_buyFee * amount)) {
// Accept ETH payment
_mint(msg.sender, amount);
} else {
// Try WETH payment instead
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}

Risk

Likelihood: Permanent loss of fund, any user weth can be deducted unknowingly

Proof of Concept

function testExploit() public {
console.log("\n=== EXPLOIT DEMONSTRATION ===");
uint256 tokensToMint = 5; // Want to mint 5 tokens
uint256 requiredPayment = snowToken.s_buyFee() * tokensToMint; // Should be 5 ETH
console.log("Tokens to mint:", tokensToMint);
console.log("Required payment:", requiredPayment);
// Record initial state
uint256 initialAttackerETH = attacker.balance;
uint256 initialAttackerWETH = weth.balanceOf(attacker);
uint256 initialAttackerTokens = snowToken.balanceOf(attacker);
console.log("\n--- Initial State ---");
console.log("Attacker ETH:", initialAttackerETH);
console.log("Attacker WETH:", initialAttackerWETH);
console.log("Attacker Snow tokens:", initialAttackerTokens);
vm.startPrank(attacker);
// EXPLOIT: Send a small amount of ETH that doesn't match the required amount
// This will trigger the else branch, but the attacker has no WETH approved
// However, if the safeTransferFrom fails, the transaction should revert
// But let's test what happens with 1 wei
// Actually, let's test the real exploit: send wrong amount of ETH
// The function will try to use WETH instead, but attacker has none approved
// First, let's show what happens when we send wrong ETH amount
// and have no WETH - this should fail
vm.expectRevert();
snowToken.buySnow{value: 1 wei}(tokensToMint);
console.log("✓ Correctly reverted when sending wrong ETH amount with no WETH");
// Now let's show the real exploit: attacker gets WETH approval but for less than required
weth.mint(attacker, 1 ether); // Give attacker only 1 WETH
weth.approve(address(snowToken), 1 ether); // Approve only 1 WETH
// Try to buy 5 tokens (requires 5 WETH) but send 1 wei ETH to trigger WETH path
vm.expectRevert(); // This should fail because attacker doesn't have enough WETH
snowToken.buySnow{value: 1 wei}(tokensToMint);
console.log("✓ Correctly reverted when trying to buy more tokens than WETH balance");
// Let's demonstrate the actual vulnerability more clearly
// The issue is that ANY non-matching ETH amount triggers WETH path
uint256 smallAmount = 1;
uint256 smallETHSent = 0.5 ether; // Send 0.5 ETH for 1 token (requires 1 ETH)
// Give attacker enough WETH for the purchase
weth.mint(attacker, 2 ether);
weth.approve(address(snowToken), 2 ether);
console.log("\n--- Exploit Attempt ---");
console.log("Buying 1 token (requires 1 ETH or 1 WETH)");
console.log("Sending 0.5 ETH (wrong amount) - should trigger WETH path");
snowToken.buySnow{value: smallETHSent}(smallAmount);
vm.stopPrank();
// Check final state
console.log("\n--- Final State ---");
console.log("Attacker ETH spent:", initialAttackerETH - attacker.balance);
console.log("Attacker WETH spent:", initialAttackerWETH + 3 ether - weth.balanceOf(attacker)); // +3 because we minted 3 total
console.log("Attacker Snow tokens gained:", snowToken.balanceOf(attacker));
console.log("Contract ETH received:", address(snowToken).getContractBalance());
console.log("Contract WETH received:", weth.balanceOf(address(snowToken)));
// The vulnerability: attacker paid 0.5 ETH + 1 WETH = 1.5 total value for 1 token
// But the contract thinks it received proper payment!
// The ETH is just sitting in the contract doing nothing
assertEq(snowToken.balanceOf(attacker), 1);
assertEq(address(snowToken).getContractBalance(), 0.5 ether); // ETH stuck in contract
assertEq(weth.balanceOf(address(snowToken)), 1 ether); // WETH properly transferred
console.log("\n=== VULNERABILITY SUMMARY ===");
console.log("- Attacker sent 0.5 ETH + 1 WETH = 1.5 total value");
console.log("- Contract expected either 1 ETH OR 1 WETH");
console.log("- 0.5 ETH is now stuck in contract with no way to retrieve it");
console.log("- This allows partial double-payment exploitation");
}

Recommended Mitigation

function buySnow(uint256 amount) external payable canFarmSnow {
require(amount > 0, "Amount must be greater than zero");
uint256 totalCost = s_buyFee * amount;
if (msg.value > 0) {
// Payment with ETH
require(msg.value == totalCost, "Incorrect ETH amount sent");
_mint(msg.sender, amount);
} else {
// Payment with WETH token
require(msg.value == 0, "Cannot send both ETH and use WETH");
i_weth.safeTransferFrom(msg.sender, address(this), totalCost);
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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

Appeal created

felixmedia78 Submitter
10 days ago
yeahchibyke Lead Judge
9 days ago
yeahchibyke Lead Judge 9 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.