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: 8/103
Findings: 5
Award: $1,028.50
🌟 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
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L154 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L398-L400
This is a well known attack, openzepellin talks about it here https://blog.openzeppelin.com/exploiting-uniswap-from-reentrancy-to-actual-profit/.
When a erc777 token is used, user can reenter buy
before the transfer of base token has taken place, allowing user to buy fractional token at a cheaper price because baseTokenBalance will stay the same.
User buys some fractional token. buyQuote
is used to calculate the amount of baseToken needed for the amount of fractional token user want to buy.
function buy(uint256 outputAmount, uint256 maxInputAmount) public payable returns (uint256 inputAmount) { // *** Checks *** // // check that correct eth input was sent - if the baseToken equals address(0) then native ETH is used require(baseToken == address(0) ? msg.value == maxInputAmount : msg.value == 0, "Invalid ether input"); // calculate required input amount using xyk invariant inputAmount = buyQuote(outputAmount); // check that the required amount of base tokens is less than the max amount require(inputAmount <= maxInputAmount, "Slippage: amount in"); // *** Effects *** // // transfer fractional tokens to sender _transferFrom(address(this), msg.sender, outputAmount); // *** Interactions *** // if (baseToken == address(0)) { // refund surplus eth uint256 refundAmount = maxInputAmount - inputAmount; if (refundAmount > 0) msg.sender.safeTransferETH(refundAmount); } else { // transfer base tokens in ERC20(baseToken).safeTransferFrom(msg.sender, address(this), inputAmount); } emit Buy(inputAmount, outputAmount); }
In buyQuote
, baseTokenReserves is divided by fractionalTokenReserves to get the price of fractional token.
function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
However, user have the opportunity to reenter buy
at safeTransferFrom if baseToken is a erc777 token. According to erc777 docs, the hook is called before the transfer of tokens. Hence, baseTokenReserves above will remain the same. This allows the user to buy fractional token at a cheaper price than usual by reentering the buy
function repeatedly.
} else { // transfer base tokens in ERC20(baseToken).safeTransferFrom(msg.sender, address(this), inputAmount); }
Manual Review
Recommend using ReentrancyGuard from OpenZepellin.
#0 - c4-judge
2022-12-29T11:34:12Z
berndartmueller marked the issue as duplicate of #343
#1 - c4-judge
2023-01-13T11:51:40Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: Jeiwan
Also found by: 0xxm, 9svR6w, BAHOZ, Bobface, CRYP70, Chom, HE1M, Junnon, RaymondFam, UNCHAIN, __141345__, bytehat, carlitox477, caventa, cccz, chaduke, hansfriese, hihen, koxuan, mauricio1802, minhquanym, minhtrng, nicobevi, obront, shung, unforgiven, wait
40.2564 USDC - $40.26
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L63-L99 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L417-L428
baseTokenAmount and fractionalTokenAmount in add
will calculate the shares that both amount will generate and take the minimum. However, after calculating the minimum, baseTokenAmount and fractionalTokenAmount is not recalculated based on the minimum, causing user to overpay on baseTokenAmount or fractionalTokenAmount if there is a difference in the share amount of both.
lpTokenAmount = addQuote(baseTokenAmount, fractionalTokenAmount);
is used to calculate in add
how much liquidity token based on the baseTokenAmount and fractionalTokenAmount.
function add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) public payable returns (uint256 lpTokenAmount) { // *** Checks *** // // check the token amount inputs are not zero require(baseTokenAmount > 0 && fractionalTokenAmount > 0, "Input token amount is zero"); // check that correct eth input was sent - if the baseToken equals address(0) then native ETH is used require(baseToken == address(0) ? msg.value == baseTokenAmount : msg.value == 0, "Invalid ether input"); // calculate the lp token shares to mint lpTokenAmount = addQuote(baseTokenAmount, fractionalTokenAmount); // check that the amount of lp tokens outputted is greater than the min amount require(lpTokenAmount >= minLpTokenAmount, "Slippage: lp token amount out"); // *** Effects *** // // transfer fractional tokens in _transferFrom(msg.sender, address(this), fractionalTokenAmount); // *** Interactions *** // // mint lp tokens to sender lpToken.mint(msg.sender, lpTokenAmount); // transfer base tokens in if the base token is not ETH if (baseToken != address(0)) { // transfer base tokens in ERC20(baseToken).safeTransferFrom(msg.sender, address(this), baseTokenAmount); } emit Add(baseTokenAmount, fractionalTokenAmount, lpTokenAmount); }
However, as we see in addQuote
, the minimum of either baseTokenShare or fractionalTokenShare is returned. That means that if there is a difference between baseTokenShare and fractionalTokenShare, the user will lose out on the tokens that contribute to the higher share as the amount of baseToken and fractionalToken transferred is based on the user input and not recalculated after getting the minimum amount of shares.
function addQuote(uint256 baseTokenAmount, uint256 fractionalTokenAmount) public view returns (uint256) { uint256 lpTokenSupply = lpToken.totalSupply(); if (lpTokenSupply > 0) { // calculate amount of lp tokens as a fraction of existing reserves uint256 baseTokenShare = (baseTokenAmount * lpTokenSupply) / baseTokenReserves(); uint256 fractionalTokenShare = (fractionalTokenAmount * lpTokenSupply) / fractionalTokenReserves(); return Math.min(baseTokenShare, fractionalTokenShare); } else { // if there is no liquidity then init return Math.sqrt(baseTokenAmount * fractionalTokenAmount); } }
place poc in Add.t.sol
, and run forge test --match testUserNormal -vv
Both tests are the same except for the fact that babe pays more baseToken in the second test. However, both test get the same amount of liquidity tokens.
function testUserNormal() public { // arrange uint256 minLpTokenAmount = Math.sqrt(baseTokenAmount * fractionalTokenAmount); p.add(baseTokenAmount, fractionalTokenAmount, minLpTokenAmount); // initial add uint256 lpTokenSupplyBefore = lpToken.totalSupply(); uint256 expectedLpTokenAmount = Math.sqrt(baseTokenAmount * fractionalTokenAmount) * 17; minLpTokenAmount = expectedLpTokenAmount; baseTokenAmount = baseTokenAmount * 17; fractionalTokenAmount = fractionalTokenAmount * 17; deal(address(usd), babe, baseTokenAmount, true); deal(address(p), babe, fractionalTokenAmount, true); // act vm.startPrank(babe); usd.approve(address(p), type(uint256).max); uint256 lpTokenAmount = p.add(baseTokenAmount, fractionalTokenAmount, minLpTokenAmount); vm.stopPrank(); // assert assertEq(lpTokenAmount, expectedLpTokenAmount, "Should have returned correct lp token amount"); assertEq(lpToken.balanceOf(babe), expectedLpTokenAmount, "Should have minted lp tokens"); assertEq(lpToken.totalSupply() - lpTokenSupplyBefore, expectedLpTokenAmount, "Should have increased lp supply"); console.log(usd.balanceOf(address(babe))); console.log(lpToken.balanceOf(address(babe))); } function testUserNormalPaysMoreBaseTokensButGetsTheSame() public { // arrange uint256 minLpTokenAmount = Math.sqrt(baseTokenAmount * fractionalTokenAmount); p.add(baseTokenAmount, fractionalTokenAmount, minLpTokenAmount); // initial add uint256 lpTokenSupplyBefore = lpToken.totalSupply(); uint256 expectedLpTokenAmount = Math.sqrt(baseTokenAmount * fractionalTokenAmount) * 17; minLpTokenAmount = expectedLpTokenAmount; baseTokenAmount = baseTokenAmount * 17; fractionalTokenAmount = fractionalTokenAmount * 17; deal(address(usd), babe, baseTokenAmount+100000, true); deal(address(p), babe, fractionalTokenAmount, true); // act vm.startPrank(babe); usd.approve(address(p), type(uint256).max); uint256 lpTokenAmount = p.add(baseTokenAmount+100000, fractionalTokenAmount, minLpTokenAmount); vm.stopPrank(); // assert assertEq(lpTokenAmount, expectedLpTokenAmount, "Should have returned correct lp token amount"); assertEq(lpToken.balanceOf(babe), expectedLpTokenAmount, "Should have minted lp tokens"); assertEq(lpToken.totalSupply() - lpTokenSupplyBefore, expectedLpTokenAmount, "Should have increased lp supply"); console.log(usd.balanceOf(address(babe))); console.log(lpToken.balanceOf(address(babe))); }
Foundry
Consider recalculating fractionalTokenAmount and baseTokenAmount based on lpTokenAmount returned from addQuote
.
// check that the amount of lp tokens outputted is greater than the min amount require(lpTokenAmount >= minLpTokenAmount, "Slippage: lp token amount out"); + fractionalTokenAmount = lpTokenAmount * fractionalTokenReserves() / lpTokenSupply; + baseTokenAmount = lpTokenAmount * baseTokenReserves() / lpTokenSupply; // *** Effects *** // // transfer fractional tokens in
#0 - c4-judge
2022-12-28T15:32:02Z
berndartmueller marked the issue as duplicate of #376
#1 - c4-judge
2023-01-10T09:02:22Z
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
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L63-L99 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L417-L428
The first liquidity provider can manipulate price of share by having 1 share only and sending in baseTokens and fractionalTokens to manipulate the price of the share. Future liquidity providers will then lose out on precision loss which will be gained by the first liquidity provider.
Place this in NftAdd.sol
, babe that started out with 1e18 baseTokens is able to gain 2e17 baseTokens from victim after the attack.
function testUserCanManipulatePrice() public { uint256[] memory _tokenIds = new uint[](1); console.log(lpToken.totalSupply()); // begin with 0 supply vm.startPrank(babe); bayc.setApprovalForAll(address(p), true); usd.approve(address(p), type(uint256).max); bayc.mint(babe, 5); _tokenIds[0] = 5; deal(address(usd), babe, 1e18, true); uint256 lpTokenAmount = p.nftAdd(1e18, _tokenIds, 1, proofs); console.log(lpToken.totalSupply()); // begin with 1e18 supply p.remove(1e18-1, 1e18-1, 1e18-1); // make lptoken to 1 share = 1 usd and 1 fractional token console.log(lpToken.totalSupply()); // left 1 share p.transfer(address(p), 1e18-1); usd.transfer(address(p), 6e17); // 1 share = 6e17 usdc and 1e18 fractional token vm.stopPrank(); deal(address(usd), address(this), 1e18, true); // victim bayc.mint(address(this), 6); _tokenIds[0] = 6; lpTokenAmount = p.nftAdd(1e18, _tokenIds, 1, proofs); console.log(lpToken.totalSupply()); // total number of shares = 2 vm.startPrank(babe); p.remove(1, 1, 1); console.log(usd.balanceOf(babe)); // babe now ends with more usdc than he has }
Foundry
Set a minimum amount of share that must be left in the pool when withdrawing and also burn away some shares from first liquidity provider to ensure that the price is more robust against such attacks.
#0 - c4-judge
2022-12-20T14:34:56Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:13:33Z
berndartmueller marked the issue as satisfactory
184.3311 USDC - $184.33
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L77 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L426 https://github.com/code-423n4/2022-12-caviar/blob/main/src/LpToken.sol#L13
Based on LpToken.sol
constructor, liquidity pool token is in 18 decimals. However, when liquidity token amount is initialized, it assumes that base token decimal is 18 because it multiples it with fractional tokens amount which is in 18 decimal before square rooting and taking the result as the initial liquidity pool token amount. In the event that non 18 decimal ERC20 token is used as base token (USDC), the liquidity pool token amount will be in the wrong precision.
In LpToken.sol
, the constructor initializes the pair with 18 decimals for the liquidity pool token.
constructor(string memory pairSymbol) Owned(msg.sender) ERC20(string.concat(pairSymbol, " LP token"), string.concat("LP-", pairSymbol), 18) {}
However, when initalizing initial liquidity pool token amount, baseTokenAmount is assumed as 18 decimals. In the event that a non 18 decimal token is used such as USDC with 6 decimal, the resulting amount after square rooting will be in 12 decimals instead of 18 decimals.
} else { // if there is no liquidity then init return Math.sqrt(baseTokenAmount * fractionalTokenAmount); }
Manual Review
Suggest making baseTokenAmount to 18 decimals before multiplying it with fractionalTokenAmount
return Math.sqrt(baseTokenAmount * (10 ** (18 - ERC20(baseToken).decimals())) * fractionalTokenAmount));
#0 - c4-judge
2022-12-29T11:09:53Z
berndartmueller marked the issue as duplicate of #53
#1 - c4-judge
2023-01-10T09:35:17Z
berndartmueller marked the issue as satisfactory
#2 - C4-Staff
2023-01-25T12:23:07Z
CloudEllie marked the issue as duplicate of #141
🌟 Selected for report: Zarf
Also found by: 0xDave, Apocalypto, CRYP70, Franfran, Jeiwan, UNCHAIN, adriro, bytehat, chaduke, hansfriese, hihen, kiki_dev, koxuan, minhtrng, rajatbeladiya, unforgiven, wait, yixxas
45.9386 USDC - $45.94
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L154 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L186 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L398-L400 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L406-L409
When buying or selling fractional Tokens from pair, buyQuote and sellQuote is used to calculate how much base Token is needed in exchange of the amount of fractional Tokens user is buying or selling. In the event that a low decimal ERC20 token is used (USDC with 6 decimals), buyQuote and sellQuote will underflow and revert unless fractional tokens amount is big enough.
In buy
, buyQuote is used to calculate the amount of input base Tokens for the amount of fractional tokens user wants to buy.
inputAmount = buyQuote(outputAmount);
However, we can see that in buyQuote
, fractionalTokenReserves (18 decimals) will be significantly bigger than baseTokenReserves if base Token has a small decimal count. i.e (USDC 6 decimals). buyQuote and sellQuote will underflow and revert due to baseTokenReserves divide by fractionalTokenReserves.
function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
Manual Review
There should be a minimum for outputAmount depending on the number of decimal the ERC20 used for base Token has.
require(outputAmount > 10 ** 18- ERC20(baseToken).decimals, "outputAmount needs to be more )
Or if possible, ONE can be based on base token decimals.
#0 - c4-judge
2022-12-23T13:51:16Z
berndartmueller marked the issue as duplicate of #243
#1 - c4-judge
2023-01-10T09:44:10Z
berndartmueller marked the issue as satisfactory