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: 22/103
Findings: 5
Award: $317.19
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
Finder
Tomo
In add()
, users can get the amount of lpToken. This amount is the lesser of baseToken and fractionalToken share.
There is a check to prevent the amount of lpToken is greater than the minOutputAmount
but there is no check for the amount of tokens lost by the one with the larger share.
lpTokenTotalSupply = 100e18, baseTokenReserves() = 10e18, fractionalTokenReserves() = 50e18
add()
with 1 USDC and 1e18 fractionalTokenaddQuote()
, the baseTokenShare and fractionalTokenShare would be like this.// calculate amount of lp tokens as a fraction of existing reserves // baseTokenShare = 1e6 * 100e18 / 10e18 = 1e7 uint256 baseTokenShare = (baseTokenAmount * lpTokenSupply) / baseTokenReserves(); // fractionalTokenShare = 1e18 * 100e18 / 50e18 = 2e18 uint256 fractionalTokenShare = (fractionalTokenAmount * lpTokenSupply) / fractionalTokenReserves();
addQuote()
would return the baseTokenShare
as a return valuefunction add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) public payable returns (uint256 lpTokenAmount) { // *** Checks *** // /* ... */ 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); /* ... */ lpToken.mint(msg.sender, lpTokenAmount); /* ... */ if (baseToken != address(0)) { // transfer base tokens in ERC20(baseToken).safeTransferFrom(msg.sender, address(this), baseTokenAmount); } emit Add(baseTokenAmount, fractionalTokenAmount, lpTokenAmount); }
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); } }
Manual
For a token amount with a large share, only the amount of the share that is the same as that of the smaller share should be charged in the transfer.
#0 - c4-judge
2022-12-29T11:52:50Z
berndartmueller marked the issue as duplicate of #376
#1 - c4-judge
2023-01-10T09:02:35Z
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#L58-L99
Finder
Yawn, Tomo, keit
The Add
function allows the user to add any amount to the Pair contract.Both the add
and remove
functions use the balance of the contract’s baseToken in their calculations for shares.
This allows attacker to tie the share to very high amounts, and steal fractionalToken and baseToken by pricing out smaller depositors.
nftAdd
function with arguments so that lpTokenAmount
is close to the minimum amount 1.1000
USDC directly to the Pair contract. The baseToken’s balanceOf()
is now 1000e6 + 1
baseTokenShare = (baseTokenAmount * lpTokenSupply) / baseTokenReserves() = (1000e6 * 1) / (1000e6 + 1) = 0
buy
function and provides liquidity with the add
function, the share will be zero because the smaller share is calculated as the amount of the lpToken.return Math.min(baseTokenShare, fractionalTokenShare);
remove
as lpTokenAmount = 1
to receive the entire Pair balance both of baseToken and fractional tokens. So attacker can steal all of the NFTs and baseToken balance.function add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) publicpayablereturns (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); }
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); } }
Manual Review
It is recommended to define a minimum deposit amount as UniswapV2 does, and mint the initial shares like 1000
to the zero address.
lpToken
could be manipulated by sending baseToken
directly.Since the actual baseToken
and fractionalToken
balance in the Pair
contract is used to calculate the lpToken
value and fractionalToken
price, they can be manipulated by transferring directly to the Pair
contract.Under some conditions, users’ share may be estimated lower than expected and loses value of assets.
Pair
by add
function.baseToken
directly to the Pair
contract.fractionalToken
is calculated by xy*k
, so it sets higher.baseToken
, the share will be smaller than expected.fractionalToken
with the add
function, the share will be smaller than expected when the amount of baseToken
to provide has a small impact on the liquidity of the Pair
.return Math.min(baseTokenShare, fractionalTokenShare);
remove
to receive more baseTokens
and fractionalTokens
than expected.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)); }
function fractionalTokenReserves() public view returns (uint256) { return balanceOf[address(this)]; }
Manual Review
It is recommended that the baseToken
and fractionalToken
balances in the Pair
contract be saved as storage variables like reserve
each time they are updated, as is done in UniswapV2.
#0 - c4-judge
2022-12-28T14:25:31Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:16:19Z
berndartmueller marked the issue as satisfactory
🌟 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#L147
Finder
Yosuke
An attacker can steal a ton of fractional tokens in the Pair contract.
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L147 ・Suppose there are 1 NFTs of BAYC, 80000 USDC, and 1e18 fractional tokens in a Pair contract and anyone hasn't called remove() function, buy() function, nftRemove() function, and nftBuy() function yet. ・Attacker puts a small number like 10000 in the first and second arguments and calls the buy() function.
buy(10000, 10000);
・The formula (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997) is used when calculating the amount of base tokens in the buyQuote() function.
inputAmount = buyQuote(outputAmount); function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
・fractionalTokenReserves() is 10 to the 18th power, which is a very big number.
fractionalTokenReserves() = 1*1e18
・Applying the above formula, we get
return (10000 * 1000 * 80000) / ((1 * 1e18 - 10000) * 997)
・((1 * 1e18 - 10000) * 997) is greater than (10000 * 1000 * 80000) and the denominator is greater than the numerator, so the quotient is 0 by the solidity language specification.
(10000 * 1000 * 80000) < ((1 * 1e18 - 10000) * 997) so, return 0
・When the buyQuote() function returns 0, the attacker can get 10000 fractional tokens without paying any base tokens(ERC20).
・And to make matters worse, as long as the numerator of the above formula is not greater than the denominator, the attacker can steal the fractional token any number of times.
・If the attacker has a ton of fractional tokens, he can exchange the fractional tokens for base tokens or buy NFTs from the Pair contract.
Add a check for the return value from buyQuote() and revert if the return value is 0. https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L155
before
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); }
after
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); if( inputAmount == 0 )revert("inputAmount is 0")// add // 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); }
#0 - c4-judge
2022-12-29T13:48:32Z
berndartmueller marked the issue as duplicate of #391
#1 - c4-judge
2023-01-13T10:31:07Z
berndartmueller marked the issue as not a duplicate
#2 - c4-judge
2023-01-13T10:31:22Z
berndartmueller marked the issue as duplicate of #243
#3 - c4-judge
2023-01-13T10:32:10Z
berndartmueller changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-01-13T10:32:30Z
berndartmueller marked the issue as satisfactory
173.8376 USDC - $173.84
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L379-L381 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L383-L385
Finder
yawn
Since the actual baseToken
and fractionalToken
balance in the Pair
contract is used to calculate the lpToken
value and fractionalToken
price, they can be manipulated by transferring directly to the Pair
contract.Under some conditions, users’ share may be estimated lower than expected and loses value of assets.
Pair
by add
function.baseToken
directly to the Pair
contract.fractionalToken
is calculated by xy*k
, so it sets higher.baseToken
, the share will be smaller than expected.fractionalToken
with the add
function, the share will be smaller than expected when the amount of baseToken
to provide has a small impact on the liquidity of the Pair
.return Math.min(baseTokenShare, fractionalTokenShare);
remove
to receive more baseTokens
and fractionalTokens
than expected.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)); }
function fractionalTokenReserves() public view returns (uint256) { return balanceOf[address(this)]; }
Manual Review
It is recommended that the baseToken
and fractionalToken
balances in the Pair
contract be saved as storage variables like reserve
each time they are updated, as is done in UniswapV2.
#0 - c4-judge
2022-12-29T11:02:54Z
berndartmueller marked the issue as duplicate of #353
#1 - c4-judge
2023-01-13T11:13:42Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - berndartmueller
2023-01-13T11:16:34Z
Applying a partial credit as the PoC is hard to follow (it seems the focus is on griefing liquidy providers who call add
, even though there is slippage control in place).
#3 - c4-judge
2023-01-13T11:16:40Z
berndartmueller marked the issue as partial-50
#4 - C4-Staff
2023-01-25T12:18:52Z
CloudEllie marked the issue as duplicate of #383
🌟 Selected for report: 0xSmartContract
Also found by: 0xGusMcCrae, 8olidity, Bnke0x0, IllIllI, JC, RaymondFam, Rolezn, SleepingBugs, UNCHAIN, ahayashi, aviggiano, caventa, cozzetti, h0wl, helios, immeas, ladboy233, minhquanym, obront, rjs, rvierdiiev, shung, unforgiven, yixxas
50.16 USDC - $50.16
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L447-L459
Finder
Tomo
The _transferFrom()
returns variables as a return value. This value shows whether executing this transaction is a success or not.
However, some functions use _transferFrom()
has no check for this return value.
This will leads to the user does not know why the transaction failed or lead to an unexpected error.
function add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) public payable returns (uint256 lpTokenAmount) { // *** Checks *** // // *** Effects *** // // transfer fractional tokens in _transferFrom(msg.sender, address(this), fractionalTokenAmount); }
function _transferFrom(address from, address to, uint256 amount) internal returns (bool) { balanceOf[from] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(from, to, amount); return true; }
Manual Review
Consider adding a check for the success of executing _transferFrom
require(_transferFrom(/* ... */),"Failed transferFrom");
#0 - c4-judge
2022-12-28T14:37:10Z
berndartmueller changed the severity to QA (Quality Assurance)
#1 - c4-judge
2023-01-16T11:38:01Z
berndartmueller marked the issue as grade-b