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: 7/103
Findings: 3
Award: $1,031.36
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: carlitox477
Also found by: 9svR6w, KingNFT, Lambda, cccz, cozzetti, gzeon, koxuan, minhquanym, rvierdiiev
976.2721 USDC - $976.27
https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L95 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L137 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L172 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L203
Current implementation of functions add
, remove
, buy
and sell
first transfer fractional tokens, and then base tokens.
If this base token is ERC777 (extension of ERC20), we can call this function without updating the base token balance, but updating the fractional token balance.
Allows to drain funds of a pairs which implements an ERC-777 token.
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 + @audit Use current balances inputAmount = buyQuote(outputAmount); // check that the required amount of base tokens is less than the max amount require(inputAmount <= maxInputAmount, "Slippage: amount in"); // *** Effects *** // + @audit Modifies just fractional balance // 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 + @audit If an ERC-777 token is used, we can re call buy function with the same balance of base token, but with different fractional balance ERC20(baseToken).safeTransferFrom(msg.sender, address(this), inputAmount); } emit Buy(inputAmount, outputAmount); }
function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
The buy quote is used to calculate the amount of fractional token that the user will receive, and it should be less/equal to maxInputAmount sent by parameter in order to achieve a successful execution of function buy.
Current buy quote can be mathematically expressed as: $\frac{outputAmount \times 1000 \times baseTokenReserves}{fractionalTokenReserves - outPutAmount} \times 997$.
Then, about sales
function sell(uint256 inputAmount, uint256 minOutputAmount) public returns (uint256 outputAmount) { // *** Checks *** // // calculate output amount using xyk invariant outputAmount = sellQuote(inputAmount); // check that the outputted amount of fractional tokens is greater than the min amount require(outputAmount >= minOutputAmount, "Slippage: amount out"); // *** Effects *** // // transfer fractional tokens from sender + //@audit fractional balance is updated _transferFrom(msg.sender, address(this), inputAmount); // *** Interactions *** // if (baseToken == address(0)) { // transfer ether out msg.sender.safeTransferETH(outputAmount); } else { // transfer base tokens out + @audit If an ERC-777 token is used, we can re call sell function with the same balance of base token, but with different fractional balance. ERC20(baseToken).safeTransfer(msg.sender, outputAmount); } emit Sell(inputAmount, outputAmount); }
uint256 inputAmountWithFee = inputAmount * 997; return (inputAmountWithFee * baseTokenReserves()) / ((fractionalTokenReserves() * 1000) + inputAmountWithFee); }
Current sellQuote function can be expressed mathematically as:
$inputAmount = \frac{inputAmount \times 997 \times baseTokenReserves}{fractionalTokenReserves \times 1000 + inputAmountWithFee}$
Then we can think next scenario to drain a pair which use an ERC-777 token as base token:
A simulation of this attack can be visualized in next table
Operation | outputAmount (FRT) | maxInputAmount (BT777) | BT777 reserve | FRT reserve | inputAmount (BT777 to pay) | inputAmount < maxInputAmount |
---|---|---|---|---|---|---|
Attaker buy 1 | 50 | 80 | 1000 | 1000 | 52 | TRUE |
Callback buy 2 | 50 | 80 | 1000 | 950 | 55 | TRUE |
Callback buy 3 | 50 | 80 | 1000 | 900 | 59 | TRUE |
Callback buy 4 | 50 | 80 | 1000 | 850 | 62 | TRUE |
Callback buy 5 | 50 | 80 | 1000 | 800 | 66 | TRUE |
Callback buy 6 | 50 | 80 | 1000 | 750 | 71 | TRUE |
Callback buy 7 | 50 | 80 | 1000 | 700 | 77 | TRUE |
The result of this operation is that the attaker/malicious contract has 350 FRT, while BT777 reserve still has 1000 and FRT reserve has 650 tokens. The success execution needs that the attacker pays 442 BT777 eventually.
To do this, the last operation of the malicious contract is calling sell function
Operation | inputAmount(BT777) | minOutputAmount | BT777 reserve | FRT reserve | outputAmount (BT777 to receive) | outputAmount > minOutputAmount |
---|---|---|---|---|---|---|
calback Sell | 350 | 442 | 1000 | 650 | 536 | TRUE |
The result is that the attacker now controls 536 BT777, the attacker use this balance to pay the debt of 442 BT77, with a profit of 94 BT77 tokens.
Add openzeppelin nonReentrant modifier to mentioned functions, or state clear in the documentation that this protocol should not be used with ERC777 tokens.
#0 - c4-judge
2022-12-29T11:31:31Z
berndartmueller marked the issue as primary issue
#1 - outdoteth
2023-01-05T13:50:00Z
Technically valid though we don't intend to support erc777 tokens.
#2 - c4-sponsor
2023-01-05T13:50:04Z
outdoteth marked the issue as sponsor acknowledged
#3 - c4-judge
2023-01-13T11:50:52Z
berndartmueller marked the issue as selected for report
🌟 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/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L77-L96 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L417-L428 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L285
Current function assure as that we will at least receive minLpTokenAmount amount of LP. However, this function assumes that the input parameter baseTokenAmount
and fractionalTokenAmount
will adjust to the LP amount calculated to deposit.
A user whose input parameters baseTokenAmount
or fractionalTokenAmount
does not adjust to the corresponding lpTokenAmount
calculated inside the function can make the user lose funds, or even open frontrunning opportunities for MEV bots.
Add this file to test folder, then run forge test --match-test testUserLoseOfFunds -vvv
to check the next output.
Logs: Current user 1 USD balance: 1000 Current user 1 Fractional Token balance: 1000 User 1 deposits 100 USD and 150 fractional tokens User 1 USD balance after LP minting through add function: 900 User 1 Fractional Token balance after LP minting through add function:: 850 User 1 LP balance: 100 User 1 tries to remove their LP to get their 100 USD and 150 fractional token User 1 USD balance after calling remove function with all their LPs: 1000 User 1 Fractional Token balance calling remove function with all their LPs: 975 User 1 LP balance: 0
This shows that a user calling this function can accidentally give tokens to current LP holders.
A derivate consequence is that it opens a windows to MEV bots, they can include in the same block a transaction to:
This last scenario means that user funds are in risk.
Calculates amount of tokens/ETH to transfer to the Pair contract in fact and use these values to execute the transfer function and/or to return ETH. I would suggest to add this calculations inside the addQuote
function. Then the return values can be even used in the front end to suggest user amount to send in order to the slippage they set.
- function addQuote(uint256 baseTokenAmount, uint256 fractionalTokenAmount) public view returns (uint256) { + function addQuote(uint256 baseTokenAmount, uint256 fractionalTokenAmount) public view returns (uint256, uint256, uint256) { uint256 lpTokenSupply = lpToken.totalSupply(); if (lpTokenSupply > 0) { //@audit-ok 3 simple rules and return min is ok // calculate amount of lp tokens as a fraction of existing reserves // EXPECTED_SHARE = AMOUNT SENT * CURRENT SHARES / CURRENT BALANCE + uint256 _baseTokenReserve = baseTokenReserves(); + uint256 _fractionalTokenReserves = fractionalTokenReserves(); uint256 baseTokenShare = (baseTokenAmount * lpTokenSupply) / - baseTokenReserves(); + _baseTokenReserve; uint256 fractionalTokenShare = (fractionalTokenAmount * lpTokenSupply) / - fractionalTokenReserves(); + _fractionalTokenReserves; + if(baseTokenShare < fractionalTokenShare){ + uint256 baseTokenToSend = _baseTokenReserve * baseTokenShare / lpTokenSupply; + uint256 fractionalTokenToSend = _fractionalTokenReserves * baseTokenShare / lpTokenSupply; + return baseTokenShare, baseTokenToSend, fractionalTokenToSend; + }else{ + uint256 baseTokenToSend = _baseTokenReserve * fractionalTokenShare / lpTokenSupply; + uint256 fractionalTokenToSend = _fractionalTokenReserves * fractionalTokenShare / lpTokenSupply; + return fractionalTokenShare, baseTokenToSend, fractionalTokenToSend; } - return Math.min(baseTokenShare, fractionalTokenShare); } else { // if there is no liquidity then init - return Math.sqrt(baseTokenAmount * fractionalTokenAmount); + return Math.sqrt(baseTokenAmount * fractionalTokenAmount), baseTokenAmount, fractionalTokenAmount; } }
function add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) public payable + nonReentrant // consider adding this lock giving that we are sending back ETH to msg.sender, evaluate possible re-entrancy attacks 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); + lpTokenAmount,baseTokenToSend, fractionalTokenToSend = 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 *** // - _transferFrom(msg.sender, address(this), fractionalTokenAmount); + _transferFrom(msg.sender, address(this), fractionalTokenToSend); // *** 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); + ERC20(baseToken).safeTransferFrom(msg.sender, address(this), baseTokenToSend); - } + }else{ + // return extra ETH + payable(msg.sender).call{value: msg.value - baseTokenToSend}() + } - emit Add(baseTokenAmount, fractionalTokenAmount, lpTokenAmount); + emit Add(baseTokenToSend, fractionalTokenToSend , lpTokenAmount); }
#0 - c4-judge
2022-12-28T12:39:40Z
berndartmueller marked the issue as duplicate of #376
#1 - c4-judge
2023-01-10T09:01:44Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: Rolezn
Also found by: 0x1f8b, 0xAgro, 0xSmartContract, 0xab00, 0xhacksmithh, Aymen0909, Bnke0x0, Breeje, Diana, HardlyCodeMan, IllIllI, JC, JrNet, Madalad, NoamYakov, RaymondFam, ReyAdmirado, SleepingBugs, UdarTeam, c3phas, carlitox477, cryptonue, gz627, lukris02, millersplanet, oyc_109, pavankv, ret2basic, saneryee, tnevler
14.833 USDC - $14.83
Instead of using the &&
operator in a single require statement to check multiple conditions,using multiple require statements with 1 condition per require statement will save 3 GAS per &&
// In Pair.add - require(baseTokenAmount > 0 && fractionalTokenAmount > 0, "Input token amount is zero"); + require(baseTokenAmount > 0, ERROR_INPUT_TOKEN_AMOUNT_IS_ZERO); + require(fractionalTokenAmount > 0, ERROR_INPUT_TOKEN_AMOUNT_IS_ZERO);
Internal functions spend less gass
//Pair.sol 399: return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); 408: return (inputAmountWithFee * baseTokenReserves()) / ((fractionalTokenReserves() * 1000) + inputAmountWithFee); 417: uint256 baseTokenShare = (baseTokenAmount * lpTokenSupply) / baseTokenReserves(); 437: uint256 baseTokenOutputAmount = (baseTokenReserves() * lpTokenAmount) / lpTokenSupply;
//Pair.sol 391: return (_baseTokenReserves() * ONE) / fractionalTokenReserves(); 399: return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); 408: return (inputAmountWithFee * baseTokenReserves()) / ((fractionalTokenReserves() * 1000) + inputAmountWithFee); 422: uint256 fractionalTokenShare = (fractionalTokenAmount * lpTokenSupply) / fractionalTokenReserves(); 438: uint256 fractionalTokenOutputAmount = (fractionalTokenReserves() * lpTokenAmount) / lpTokenSupply;
closeTimestamp
is accessed twice, this can be avoided
#0 - c4-judge
2023-01-14T17:16:39Z
berndartmueller marked the issue as grade-b