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: 1/103
Findings: 5
Award: $3,609.31
🌟 Selected for report: 0
🚀 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#L182
When the baseToken
of a Pair
contract has pre-transfer hooks on the sender, such as any ERC777 token, then it's possible to execute a reentrancy attack to repeateadly manipulate quotes and obtain better rates. I'll explain using the buy
function as an example.
When the attacker calls the buy
function to buy fractional tokens and pay base tokens in return, this would be the order of operations:
transferFrom
function of the ERC777 base token is called, which callbacks the attacker's tokensToSend
hook, before updating balances. This means base token reserves remain unchanged.buy
again. This time, because the base token reserves haven't yet been increased (remain equal to step 1), the new calculated buy quote will be lower than expected, thus allowing the attacker to pay less base tokens.A similar attack can be carried out in other functions that would interact with the ERC777 base token and therefore call hooks, such as sell
.
Also, this attack vector is not uncommon in simple AMMs. It was described for Uniswap v1 here.
Manual review
Implement reentrancy guards in all functions that can make unsafe external calls to attacker-controlled accounts.
#0 - c4-judge
2022-12-29T11:33:57Z
berndartmueller marked the issue as duplicate of #343
#1 - c4-judge
2023-01-13T11:51:33Z
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
The AMM implemented in the Pair
contract is subject to a liquidity deflation attack upon deployment. In this attack:
add
function, providing a small amount of base and fractional tokens. The attacker receives the corresponding amount of LP tokens.Pair
contract, a large amount of base and fractional tokens.This gives the first liquidity provider the possibility to make it very costly for all other LPs to enter the pool, and potentially control the vast majority of the liquidity in a pair, leaving out smaller players.
Manual review
This is a long known issue in this kind of AMMs, and there's already a solution available, pioneered by Uniswap V2.
It's based on implementing a minimum liquidity threshold in the pair. Essentially, during the first liquidity provision, part of the minted LP tokens are burned (instead of being transferred to the provider). I suggest reading the discussion in the Uniswap v2 audit for more details.
#0 - c4-judge
2022-12-20T14:35:03Z
berndartmueller marked the issue as duplicate of #442
#1 - c4-judge
2023-01-10T09:14:08Z
berndartmueller marked the issue as satisfactory
2616.8492 USDC - $2,616.85
https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L63 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L107 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L147 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L182 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L275 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L294 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L310 https://github.com/code-423n4/2022-12-caviar/blob/0212f9dc3b6a418803dbfacda0e340e059b8aae2/src/Pair.sol#L323
Neither liquidity provision nor trade orders executed in the Pair
contract are time-bounded.
As a consequence, users can have their operations executed at unexpected times, when the market conditions are unfavorable. To avoid users Caviar be at the mercy of block builders, it's best to add an expiration timestamp to their orders, so that operations that are executed too late are automatically reverted.
The idea of expiring orders is not new in AMMs, and Uniswap and others already implement this feature to protect users.
Manual review
I suggest either modifying the relevant functions of the Pair
contract to allow users specify an expiration time for their orders. Alternatively, you could create a wrapper over the Pair
contract that implements this safety measure. Similar to the periphery-core architecture implemented by Uniswap.
#0 - c4-judge
2022-12-29T12:06:30Z
berndartmueller marked the issue as primary issue
#1 - c4-sponsor
2023-01-05T16:57:22Z
outdoteth marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-01-06T10:48:17Z
outdoteth marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-01-06T10:49:26Z
outdoteth marked the issue as sponsor confirmed
#4 - outdoteth
2023-01-06T17:18:21Z
Fixed in: https://github.com/outdoteth/caviar/pull/6
Adds a timestamp deadline that add(), remove(), buy() and sell() must be executed by.
#5 - c4-judge
2023-01-10T16:50:53Z
berndartmueller marked the issue as duplicate of #28
#6 - c4-judge
2023-01-10T16:51:02Z
berndartmueller marked the issue as satisfactory
184.3311 USDC - $184.33
According to its docstrings, the price
function of the Pair
contract intends to return "The current price of one fractional token in base tokens with 18 decimals of precision.". The calculation is performed as follows:
return (_baseTokenReserves() * ONE) / fractionalTokenReserves();
This calculation has a fundamental flawed assumption: that all base tokens have 18 decimals like the fractional tokens. However, there's nothing in the contract enforcing base tokens to always have 18 decimals. As a consequence, when the base token has a different number of decimals than the fractional token, the price reported by the Pair
contract will be miscalculated.
For example, let's say USDC is used as a base token. USDC has 6 decimals. If there's 100 USDC and 100 fractional tokens in reserve, one would expect the price reported by the price
function to be 1e18
. Because of the issue described, following the formula above:
100e6 * 1e18 / 100e18
the resulting price reported by price
will actually be 1e6
.
This can cause severe problems in any off-chain services or other contracts integrating with the Caviar protocol, as they will be receiving inaccurate price information.
Manual review
Price calculation should be done in the same units for both tokens. The units may need to be scaled accordingly to match, and only then calculate the price. I also recommend including unit tests to have stronger assurances that the changes are correct and this issue is not re-introduced in the future.
#0 - c4-judge
2022-12-28T15:43:06Z
berndartmueller marked the issue as duplicate of #53
#1 - c4-judge
2023-01-10T09:32:10Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-10T09:32:14Z
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: 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
_transferFrom
functionThe internal _transferFrom
function of the Pair
contract returns true
on success. However, this value is never used when _transferFrom
is called in lines 85, 125, 162 and 194.
These unused values may cause confusion in readers and contributors alike, so I recommend removing it to improve the code's quality. Alternatively, if the _transferFrom
function is to be left unmodified, the code could include inline comments in the calls to _transferFrom
to explicitly state why the return value is not used.
The _validateTokenIds
function of the Pair
contract returns early when the merkleRoot
state variable is zero. The if
clause that implements this logic contains an erroneous casting operation when comparing merkleRoot
. In particular, the comparison is made against a bytes23
instead of bytes32
. While this does not cause a problem, to favor correctness it's best to change the casting operation to a bytes32
type.
In the _validateTokenIds
function of the Pair
contract, there's a for
loop that accesses the proofs
and tokenIds
parameters with the same index. The function implicitly assumes the arrays have the same length, yet it doesn't explicitly verify it. If the proofs
array is shorter than the tokenIds
array, execution will unexpectedly revert due to an out-of-bounds access.
It's best to always make function requirements explicit, so as to fail early and loudly. Include a require
statement to ensure the two arrays have the same length.
In an emergency exit, the owner will trigger the close
function on the Pair
contract. In this scenario, it is expected for all users to withdraw their assets from the pair and unwrap their NFTs. However, there's nothing preventing users to (erroneously) continue providing liquidity via the add
function.
Given this scenario is not tested, it is unclear whether this is an expected behavior or not. If it is expected, document and test it. If it's not, add a require
statement in the add
function to ensure users cannot supply more assets to a pair that is undergoing an emergency exit.
#0 - c4-judge
2023-01-16T11:40:41Z
berndartmueller marked the issue as grade-b