Caviar contest - ak1'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: 32/103

Findings: 2

Award: $180.83

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

6.9881 USDC - $6.99

Labels

bug
3 (High Risk)
satisfactory
duplicate-442

External Links

Lines of code

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

Vulnerability details

Impact

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

Proof of Concept

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

Tools Used

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

Findings Information

🌟 Selected for report: Jeiwan

Also found by: BPZ, Janio, UNCHAIN, ak1, dic0de, hansfriese, ladboy233

Labels

bug
2 (Med Risk)
downgraded by judge
partial-50
duplicate-383

Awards

173.8376 USDC - $173.84

External Links

Lines of code

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

Vulnerability details

Impact

Price manipulation in following functions wherever the baseTokenReserves(); is called.

buyQuote, sellQuote, addQuote, removeQuote

Proof of Concept

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.

Tools Used

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

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