Trick or Treat

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

The resolveTrick function is vulnerable to cross-function reentrancy attack.

Summary

The function resolveTrick has nonReentrantmodifier but reetrancy attack is still possible because this function dose not follow Checks-effects-interactions (CEI) pattern. The SpookySwapcontract is aERC721 standard. Under the hood are functions transferFromand safeTransferFromwhich can be use for this type of attack.

Vulnerability Details

The main problem for this function is that it dose not follow the CEI pattern, deletepart is after _transferfunction.

_transfer(address(this), msg.sender, tokenId);
// @audit wrong order of function - delete is after _transfer
delete pendingNFTs\[tokenId];
delete pendingNFTsAmountPaid\[tokenId];
delete tokenIdToTreatName\[tokenId];

For hacking contract can be use bellow POC:

The first a potential attacker send more amount than it is necesarry. At the end of this function is ifstatement where totalPaidis checked.

if (totalPaid > requiredCost)

{````uint256 refund = totalPaid - requiredCost;````(bool refundSuccess,) = msg.sender.call{value: refund}("");````require(refundSuccess, "Refund failed");````}

If value is greater than requiredCostthen refund msg.sender.call{value: refund}("");.

Below POC can be use for hack.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./SpookySwap.sol"; // Adjust the import path to your SpookySwap contract
contract ReentrancyAttack {
SpookySwap public spookySwap;
uint256 public targetTokenId;
constructor(SpookySwap _spookySwap, uint256 _tokenId) {
spookySwap = _spookySwap;
targetTokenId = _tokenId;
}
function attack() public payable {
// Initiate the resolveTrick function
spookySwap.resolveTrick{value: msg.value}(targetTokenId);
}
receive() external payable {
// Reenter the resolveTrick function to exploit the contract
if (address(spookySwap).balance > 0) {
spookySwap.transferFrom(address(spookySwap), attacker, TOKEN_ID_FOR_STOLEN);
//@audit or
//spookySwap.safeTransferFrom(address(spookySwap), attacker, TOKEN_ID_FOR_STOLEN);
}
}
}

Impact

It is possible to drain all NFT tokens owned by the smart contract.

Tools Used

manual review

Recommendations

function resolveTrick(uint256 tokenId) public payable nonReentrant {
require(pendingNFTs[tokenId] == msg.sender, "Not authorized to complete purchase");
string memory treatName = tokenIdToTreatName[tokenId];
Treat memory treat = treatList[treatName];
uint256 requiredCost = treat.cost * 2; // Double price
uint256 amountPaid = pendingNFTsAmountPaid[tokenId];
uint256 totalPaid = amountPaid + msg.value;
require(totalPaid >= requiredCost, "Insufficient ETH sent to complete purchase");
// Transfer the NFT to the buyer
_transfer(address(this), msg.sender, tokenId);
// audit wrong order of functions - delete is after _transfer
delete pendingNFTs[tokenId];
delete pendingNFTsAmountPaid[tokenId];
delete tokenIdToTreatName[tokenId];
// Refund excess ETH if any
if (totalPaid > requiredCost) {
uint256 refund = totalPaid - requiredCost;
(bool refundSuccess,) = msg.sender.call{value: refund}("");
require(refundSuccess, "Refund failed");
}
}

To resolve this problem please fallow the CEI pattern.

function resolveTrick(uint256 tokenId) public payable nonReentrant {
require(pendingNFTs[tokenId] == msg.sender, "Not authorized to complete purchase");
string memory treatName = tokenIdToTreatName[tokenId];
Treat memory treat = treatList[treatName];
uint256 requiredCost = treat.cost * 2; // Double price
uint256 amountPaid = pendingNFTsAmountPaid[tokenId];
uint256 totalPaid = amountPaid + msg.value;
require(totalPaid >= requiredCost, "Insufficient ETH sent to complete purchase");
delete pendingNFTs[tokenId];
delete pendingNFTsAmountPaid[tokenId];
delete tokenIdToTreatName[tokenId];
// Transfer the NFT to the buyer
_transfer(address(this), msg.sender, tokenId);
// audit wrong order of functions - delete is after _transfer
// Refund excess ETH if any
if (totalPaid > requiredCost) {
uint256 refund = totalPaid - requiredCost;
(bool refundSuccess,) = msg.sender.call{value: refund}("");
require(refundSuccess, "Refund failed");
}
}
Updates

Appeal created

bube Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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