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: 9/103
Findings: 4
Award: $850.48
π Selected for report: 1
π 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/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L172 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L95
It is important to be aware that I have reported two reentrancy bugs. Each of these have different ways of being activated and can be found in separate functions.
All calculations done in Caviar Pair are using token balance directly. For example, when users add liquidity, it will use baseToken.balanceOf(address(this))
to calculate the amount of LP token should be minted.
function add(uint256 baseTokenAmount, uint256 fractionalTokenAmount, uint256 minLpTokenAmount) public payable returns (uint256 lpTokenAmount) { ... // calculate the lp token shares to mint uint lpTokenAmount = addQuote(baseTokenAmount, fractionalTokenAmount); ... // 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); // @audit ERC777 token reentrancy. } ... }
However, if baseToken
is ERC777 that implemented tokensToSend()
, it could be exploited because the tokensToSend()
hook is called before balance update.
Assuming baseToken
is XXX
- an ERC777 that implemnted tokensToSend()
. This hook is called before balance update (Implementation Requirement from EIP-777)
- The token contract MUST call the tokensToSend hook before updating the state.
Consider the scenario:
1000 XXX - 1000 FracToken
and total supply of LP token is 1000
.add()
to add 100 XXX
and 100 FracToken
. The calculate and mint lpTokenAmount = 100
to Alice.
At this moment, liquidity pool has 1000 XXX
(because baseToken is not transferred in yet) and 1100 FracToken
and total supply of LP token is 1100
.safeTransferFrom()
calls, Alice received a tokensToSend()
hook. She used this to call add()
again with 100 XXX
and 110 FracToken
. Now lpTokenAmount
is calculated asbaseTokenShare = 100 * 1100 / 1000 = 110 fractionalTokenShare = 110 * 1100 / 1100 = 110 lpAmount = min(baseTokenShare, fractionalTokenShare) = 110
So totally, Alice added 200 XXX
and 210 FracToken
and she received 210
LP token while she is expected to only receive 200
LP token normally.
Manual Review
Consider adding nonReentrant
modifier to these functions to protect them from reentrancy issue.
#0 - c4-judge
2022-12-29T11:34:18Z
berndartmueller marked the issue as duplicate of #343
#1 - c4-judge
2023-01-13T11:51:47Z
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
Function Pair.add()
receives base token and fractional token from liquidity providers and mint equivalent amount of LP token for them.
The amount of LP token be minted is calculate in function addQuote()
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); } }
As we can see, it takes the min value between baseTokenShare
and fractionalTokenShare
as the return value. However, the surplus token is not returned to sender but lock in the liquidity pool and become assets of all LPs, which means sender is losing funds.
Consider the scenario
100 USDC - 100 FracToken
. Total supply of LP token is 100
.50 USDC - 50 FracToken
. She should get 33.33%
of total supply of LP token, which is 50 LP token
.90 USDC - 111.11 FracToken
.45
instead of 50
LP token.baseTokenShare = 50 * 100 / 90 = 55.55 fractionalTokenShare = 50 * 100 / 111.11 = 45 lpAmount = min(baseTokenShare, fractionalTokenShare) = 45
There is 55.55 - 45 = 10.55
surplus base token share. It should be return to Alice.
Manual Review
#0 - Shungy
2022-12-21T07:14:00Z
Dup #90
#1 - c4-judge
2022-12-28T11:23:44Z
berndartmueller marked the issue as duplicate of #376
#2 - c4-judge
2023-01-10T09:01: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
9.0846 USDC - $9.08
The attack vector and impact is the same as TOB-YEARN-003, where users may not receive shares in exchange for their deposits if the total asset amount has been manipulated through a large βdonationβ.
In Pair.add()
, the amount of LP token minted is calculated as
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); } }
An attacker can exploit using these steps
1 wei baseToken - 1 wei quoteToken
to the pair. At this moment, attacker is minted 1 wei LP token
because sqrt(1 * 1) = 1
baseToken
and quoteToken
directly to the pair, such as 1e9 baseToken - 1e9 quoteToken
. Since no new LP token is minted, 1 wei LP token
worths 1e9 baseToken - 1e9 quoteToken
.0
LP token if they add less than 1e9
token because of rounding division.baseTokenShare = (X * 1) / 1e9; fractionalTokenShare = (Y * 1) / 1e9;
Manual Review
lpTokenSupply == 0
, send the first min liquidity LP tokens to the zero address to enable share dilution.add()
, ensure the number of LP tokens to be minted is non-zero:require(lpTokenAmount != 0, "No LP minted");
#0 - c4-judge
2022-12-20T14:33:47Z
berndartmueller marked the issue as primary issue
#1 - c4-sponsor
2023-01-05T14:13:50Z
outdoteth marked the issue as sponsor confirmed
#2 - outdoteth
2023-01-06T17:06:41Z
#3 - c4-judge
2023-01-10T09:03:15Z
berndartmueller marked the issue as selected for report
π 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
Id | Title |
---|---|
1 | High price NFT will be unwrap immediately, replaced by a lower value NFT |
2 | Unwrap did not check whether tokenId is in merkle proof |
It is evident that with wrap()/unwrap()
, an individual can exchange a lower-valued Non-Fungible Token (NFT) for a higher-valued one within the same collection. This is because each NFT is unique, and thus, usually has its own distinct value/price.
The result of this design will be that only one (or a few) NFTs with the lowest value will remain in the Pair. This is because if someone wraps an NFT which has a price slightly higher than the floor price, other traders will immediately trade it for one at the floor price.
If an NFT is mistakenly directly sent to the Pair contract not included in merkle proof, the FracToken can be used to withdraw it with unwrap()
function.
Consider the scenario where this NFT has higher value than all NFTs in merkle proof, it should be withdrawn by admin to possibly return to true owner.
#0 - c4-judge
2023-01-16T11:30:29Z
berndartmueller marked the issue as grade-b