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: 32/103
Findings: 2
Award: $180.83
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L275-L286 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L63-L100 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L417-L428
A well known attack vector for almost all shares based liquidity pool contracts, where an early user can manipulate the price per share and profit from late users' deposits because of the precision loss caused by the rather large value of price per share..
Attacker add 1 nft and can receive 1e18 value of fractionalTokenAmount
at line 282 uint256 fractionalTokenAmount = wrap(tokenIds, proofs);
function nftAdd( uint256 baseTokenAmount, uint256[] calldata tokenIds, uint256 minLpTokenAmount, bytes32[][] calldata proofs ) public payable returns (uint256 lpTokenAmount) { // wrap the incoming NFTs into fractional tokens uint256 fractionalTokenAmount = wrap(tokenIds, proofs); // add liquidity using the fractional tokens and base tokens lpTokenAmount = add(baseTokenAmount, fractionalTokenAmount, minLpTokenAmount); }
Now, at line 285, add function is called with baseTokenAmount = 1e18, fractionalTokenAmount = 1e18, and minLpTokenAmount is some x amount.
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); }
In add function, at line 77, addQuote
is called. lpTokenAmount = addQuote(baseTokenAmount, fractionalTokenAmount);
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); } }
since it is first deosit, the lpTokenSupply will be zero, so else part is executed and the return Math.sqrt(baseTokenAmount * fractionalTokenAmount);
The total amount of lptoken is 1e18.
Then the attacker can send 10000e18 - 1 of baseToken tokens and inflate the price per share from 1.0000 to an extreme value of 1.0000e22 ( from (1 + 10000e18 - 1) / 1) .
As a result, the future user who deposits 19999e18 will only receive 1 wei (from 19999e18 * 1 / 10000e18) of shares token.
They will immediately lose 9999e18 or half of their deposits if they sell and liquidate right after the deposit().
Manual review
Consider requiring a minimal amount of share tokens to be minted for the first minter, and send a port of the initial mints as a reserve to the DAO so that the pricePerShare can be more resistant to manipulation.
#0 - c4-judge
2022-12-20T14:34:24Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:10:05Z
berndartmueller marked the issue as satisfactory
173.8376 USDC - $173.84
https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L398-L441 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L477-L481
Price manipulation in following functions wherever the baseTokenReserves();
is called.
buyQuote, sellQuote, addQuote, removeQuote
function _baseTokenReserves() internal view returns (uint256) { return baseToken == address(0) ? address(this).balance - msg.value // subtract the msg.value if the base token is ETH : ERC20(baseToken).balanceOf(address(this)); }
If the baseToken is eth, then the _baseTokenReserves() function returns the total amount of eth stored in the contract. While returning, it deduct any of the msg.value if sent during the transaction.
function price() public view returns (uint256) { return (_baseTokenReserves() * ONE) / fractionalTokenReserves(); } /// @notice The amount of base tokens required to buy a given amount of fractional tokens. /// @dev Calculated using the xyk invariant and a 30bps fee. /// @param outputAmount The amount of fractional tokens to buy. /// @return inputAmount The amount of base tokens required. function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); } /// @notice The amount of base tokens received for selling a given amount of fractional tokens. /// @dev Calculated using the xyk invariant and a 30bps fee. /// @param inputAmount The amount of fractional tokens to sell. /// @return outputAmount The amount of base tokens received. function sellQuote(uint256 inputAmount) public view returns (uint256) { uint256 inputAmountWithFee = inputAmount * 997; return (inputAmountWithFee * baseTokenReserves()) / ((fractionalTokenReserves() * 1000) + inputAmountWithFee); } /// @notice The amount of lp tokens received for adding a given amount of base tokens and fractional tokens. /// @dev Calculated as a share of existing deposits. If there are no existing deposits, then initializes to /// sqrt(baseTokenAmount * fractionalTokenAmount). /// @param baseTokenAmount The amount of base tokens to add. /// @param fractionalTokenAmount The amount of fractional tokens to add. /// @return lpTokenAmount The amount of lp tokens received. 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); } } /// @notice The amount of base tokens and fractional tokens received for burning a given amount of lp tokens. /// @dev Calculated as a share of existing deposits. /// @param lpTokenAmount The amount of lp tokens to burn. /// @return baseTokenAmount The amount of base tokens received. /// @return fractionalTokenAmount The amount of fractional tokens received. function removeQuote(uint256 lpTokenAmount) public view returns (uint256, uint256) { uint256 lpTokenSupply = lpToken.totalSupply(); uint256 baseTokenOutputAmount = (baseTokenReserves() * lpTokenAmount) / lpTokenSupply; uint256 fractionalTokenOutputAmount = (fractionalTokenReserves() * lpTokenAmount) / lpTokenSupply; return (baseTokenOutputAmount, fractionalTokenOutputAmount);
It is possible to send the ETH into the contract directly by using the self-destruct method.
In this way, the balance of the eth can be increased.
Manual review
One recommendation would be to track the amount of the eth deposited using separate variable and use / update wherever it is used.
#0 - Shungy
2022-12-20T06:15:07Z
Seems invalid.
That just gifts ETH to existing liquidity providers. It doesn't break anything.
Duplicate: https://github.com/code-423n4/2022-12-caviar-findings/issues/506
#1 - aktech297
2022-12-21T01:42:45Z
@Shungy the what is need to subtract the msg.value from balance of this contract ?
#2 - Shungy
2022-12-21T05:38:46Z
@aktech297 When base token is ETH, you will have to send value when calling buy
or add
(or other functions that call these). If msg.value isn't subtracted, then reserve amounts used in addQuote
and buyQuote
would be incorrect, because address(this).balance
would include the extra msg.value.
#3 - aktech297
2022-12-21T05:44:05Z
I am looking at the same logic here whether it's included or not it's not going to give correct answer.
#4 - Shungy
2022-12-21T05:45:43Z
The difference is that when you call buy
with value, you still own (in contract's accounting) the msg.value until quote amount is calculated and swap is finalized, so not excluding it in the reserves can be profitably used to manipulate the reserves.
#5 - aktech297
2022-12-21T06:00:21Z
I am still concerned if the contract received eth outside by means of self destruction mode. Since the protocol using the eth for accounting , whether it gain or loss , imo, the accounting is not correct. The correct accounting would be tracking of each base tokens asset and use the correct value for any calculation. It should be any value that the contract holds. One may argue why somebody wants to waste their eth, the fact here is an hacker who steal eth push it. The input could from tornodo cash. Something it could be converting the black money into white..
#6 - Shungy
2022-12-21T06:14:32Z
When someone directly sends Ether, underlying Ether amount for the same amount of liquidity share increases, hence benefiting the liquidity providers. This is the same thing as UniswapV2 transfer->sync.
The correct accounting would be tracking of each base tokens asset and use the correct value for any calculation. It should be any value that the contract holds.
That is one way of doing it, as it was done in UniswapV2. However, that does not mean anything is wrong with how Caviar did. You have not shown that.
One may argue why somebody wants to waste their eth, the fact here is an hacker who steal eth push it. The input could from tornodo cash.
"Your contract can receive Eth from tornado cash" is not a vulnerability. I think that would not even hold as informational.
Back to your actual submitted issue, the PoC only says Eth balance can be increased. Yes, that's the case, but that doesn't mean accounting is broken. You just assumed that there is a problem. Since you haven't demonstrated how this can be used to exploit the contract, this finding is clearly unsatisfactory if not invalid. You can DM if you want to go through the logic of the accounting.
#7 - aktech297
2022-12-21T06:20:22Z
The issue is straightforward.. it's upto you to get the point. I let the judges to decide on that. Everyone has their own views. Overall issue here price manipulation in one way other.DM me if you want any clarification .. it better not put out dislike without fully understand the nature and impact of code.thanks. Please refer the codes that are shown in POC and try to understand the impact of eth in those situations..
#8 - c4-judge
2022-12-29T10:51:47Z
berndartmueller marked the issue as duplicate of #353
#9 - c4-judge
2023-01-13T11:07:46Z
berndartmueller changed the severity to 2 (Med Risk)
#10 - berndartmueller
2023-01-13T11:12:05Z
Applying a partial credit as the submission only focuses on ETH-dominated pairs, even though the issues also exist for baseToken
(contrary to the report https://github.com/code-423n4/2022-12-caviar-findings/issues/383)
#11 - c4-judge
2023-01-13T11:12:14Z
berndartmueller marked the issue as partial-50
#12 - C4-Staff
2023-01-25T12:18:52Z
CloudEllie marked the issue as duplicate of #383