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: 23/103
Findings: 4
Award: $287.42
🌟 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
This is a common vulnerability. The token supply can be manipulated to prevent users from gaining a "normal" number of shares. See Section 3.4 Uniswap does this by burning the first 1000 lpTokens
to significantly increase the cost of this attack.
First depositor to liquidity pool can deposit a small amount, i.e. 1 wei
to mint 1 LP token. They can then donate a large number of baseToken
to the protocol and make it difficult for small liquidity providers from minting any LP tokens.
baseTokenShare
is calculated as baseTokenAmount * lpTokenSupply / baseTokenReserves()
. Because if lpTokenSupply
is 1, and if a malicious user donates 100000e18 baseToken
, the next depositors need to provide at least 100000e18 baseToken
to mint 1 lpToken
. If user deposits say 150000e18
, they will still only mint 1 lpToken
due to the large rounding error.
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
We can do what Uniswap does, by sending the first 1000 lptoken
to the 0 address when liquidity is first initiated.
#0 - c4-judge
2022-12-28T15:42:40Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:18:54Z
berndartmueller changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-10T09:19:06Z
berndartmueller marked the issue as satisfactory
184.3311 USDC - $184.33
https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L147-L176 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L398-L400
decimals of baseToken
is not accounted for. It is always assumed to be the same as in fractionalToken
. Should a coin like USDC be used as the baseToken
, NFTs can be easily purchased for free.
Note that the same mistake is made in sellQuote()
, hence affected functions are buy()
, sell()
, add()
, remove()
as they all assume that decimals of baseToken
and fractionalToken
are the same.
inputAmount
is calculated as such. This value is used to determine how much a user has to pay in buy()
.
function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
If baseToken
is a coin like USDC which is 6 decimals, our computation here will almost always return 0, or a much lesser amount than it should be due to the massive difference in decimals i.e. 6 vs 18.
On the other hand, if basetoken
decimal is more than 18 decimals, buyers would be overpaying the difference.
Manual Review
For example in buyQuote()
, our calculation should scale to e18, which is the same as fractionalTokenReserve()
.
function buyQuote(uint256 outputAmount) public view returns (uint256) { - return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); + return (outputAmount * 1000 * baseTokenReserves() * 1e18 / baseToken.decimals() ) / ((fractionalTokenReserves() - outputAmount) * 997); }
#0 - c4-judge
2022-12-28T15:39:38Z
berndartmueller marked the issue as duplicate of #53
#1 - c4-judge
2023-01-10T09:31:42Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-10T09:31:50Z
berndartmueller marked the issue as satisfactory
#3 - C4-Staff
2023-01-25T12:23:07Z
CloudEllie marked the issue as duplicate of #141
🌟 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/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L147-L176 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L398-L400
It is possibly for buyQuote()
to quote an input amount of 0 despite setting an output amount > 0. This means that an attacker can constantly steal from the protocol by calling buy()
with a small input amount each time.
inputAmount
is calculated as follows.
function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997); }
If baseTokenReserves() > fractionalTokenReserves()
, this attack can happen.
For eg, if
fractionalTokenReserves() = 100
,
baseTokenReserves() = 1000
,
outputAmount = 9
inputAmount = buyQuote(9) = 9 * 1000 * 1000 / ( (100 - 9) * 997 ) = 0
inputAmount
is used in buy()
to determine how much user has to pay. In this case, attacker can drain 9 baseTokens
from the protocol without having to pay any fractionalToken
.
Manual Review
We should add the check in buy()
, require( inputAmount != 0 )
to prevent this from happening.
#0 - c4-judge
2022-12-23T13:50:57Z
berndartmueller marked the issue as duplicate of #243
#1 - c4-judge
2023-01-10T09:43:31Z
berndartmueller marked the issue as satisfactory
🌟 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
wrap()
takes in tokenIds[]
and proofs[][]
but their lengths are not checked to be the same. It is recommended that we check length to be equal to avoid unintended actions by the users.
In the event that NFT contract gets temporarily destroyed, users can mint fractionalToken
for free since the safeTransferFrom
call would always succeed even if users to do not own the required NFTs.
Instead of setting baseToken to address(0) to represent baseToken is ETH, we can consider using the vanity address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE to represent this. This makes it much clearer that we are using ETH as the baseToken.
#0 - c4-judge
2023-01-16T11:44:58Z
berndartmueller marked the issue as grade-b