Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $88,500 USDC
Total HM: 31
Participants: 105
Period: 11 days
Judge: ronnyx2017
Total Solo HM: 7
Id: 342
League: ETH
Rank: 51/105
Findings: 4
Award: $140.43
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Limbooo
Also found by: 0xDemon, 0xspryon, 14si2o_Flint, Aymen0909, Silvermist, alix40, btk, crypticdefense, erosjohn, falconhoof, jnforja, shaka, wangxx2026, y0ng0p3
18.5042 USDC - $18.50
https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/V3Vault.sol#L352-L355 https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/V3Vault.sol#L378-L381
The contest page explicitly mentioned that the V3Vault
must conform with the ERC4626.
V3Vault: Should comply with ERC/EIP4626
Thus, issues related to EIP compliance should be considered valid in the context of this audit.
Let's consider the value returned by V3Vault::previewRedeem
function be $asset_{preview}$ and the actual number of assets obtained from calling the V3Vault::redeem
function be $asset_{actual}$.
The following specification of previewRedeem function is taken from ERC4626 specification:
Allows an on-chain or off-chain user to simulate the effects of their redeemption at the current block, given current on-chain conditions.
MUST return as close to and no more than the exact amount of assets that would be withdrawn in a redeem call in the same transaction. I.e. redeem should return the same or more assets as previewRedeem if called in the same transaction.
It mentioned that the redeem should return the same or more assets as previewRedeem if called in the same transaction, which means that it must always be $asset_{preview} \le asset_{actual}$.
This is the implementation of V3Vault::previewRedeem
and V3Vault::redeem
functions.
File: src/V3Vault.sol function previewRedeem(uint256 shares) public view override returns (uint256) { (, uint256 lendExchangeRateX96) = _calculateGlobalInterest(); return _convertToAssets(shares, lendExchangeRateX96, Math.Rounding.Down); } function redeem(uint256 shares, address receiver, address owner) external override returns (uint256) { (uint256 assets,) = _withdraw(receiver, owner, shares, true); return assets; }
V3Vault::redeem()
subsequently calls the V3Vault::_updateGlobalInterest
function, which call the V3Vault::_calculateGlobalInterest
function only once per block.
function _updateGlobalInterest() internal returns (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) { @@->// only needs to be updated once per block (when needed) if (block.timestamp > lastExchangeRateUpdate) { (newDebtExchangeRateX96, newLendExchangeRateX96) = _calculateGlobalInterest(); lastDebtExchangeRateX96 = newDebtExchangeRateX96; lastLendExchangeRateX96 = newLendExchangeRateX96; lastExchangeRateUpdate = block.timestamp; emit ExchangeRateUpdate(newDebtExchangeRateX96, newLendExchangeRateX96); } else { newDebtExchangeRateX96 = lastDebtExchangeRateX96; newLendExchangeRateX96 = lastLendExchangeRateX96; } }
Note that the V3Vault::redeem
function does not call the V3Vault::previewRedeem
function.
This basically allows the two functions be called in different blocks, which means that global interest rates would be recalculated.
Those values can be different between the call to V3Vault::previewRedeem()
and the call to V3Vault::redeem()
.
It is possible that the V3Vault::redeem
function might return fewer assets than the number of assets previewed by the V3Vault::previewRedeem
($asset_{preview} > asset_{actual}$), thus it does not conform to the specification.
It was understood from the protocol team that they anticipate external parties (ERC4626 compliant smart contracts) to integrate directly with the V3Vault (e.g., vault shares as collateral). Thus, the V3Vault must be ERC4626 compliant. Otherwise, the caller of the V3Vault::previewRedeem
function might receive incorrect information, leading to the wrong action being executed.
Manual review
Ensure that the actual value redeemed in V3Vault::redeem()
is at least equal to the value that V3Vault::previewRedeem()
would return in the same transaction ($asset_{preview} \le asset_{actual}$).
Alternatively, document that the previewRedeem and redeem functions deviate from the ERC4626 specification in the comments and/or documentation.
ERC4626
#0 - c4-pre-sort
2024-03-21T13:49:59Z
0xEVom marked the issue as duplicate of #249
#1 - c4-pre-sort
2024-03-21T13:50:03Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T04:14:26Z
jhsagd76 marked the issue as satisfactory
π Selected for report: b0g0
Also found by: 0x175, 0xAlix2, 0xblackskull, 0xspryon, 14si2o_Flint, Fitro, Giorgio, MSaptarshi, MohammedRizwan, Silvermist, boredpukar, crypticdefense, grearlake, kfx, maxim371, y0ng0p3
6.6125 USDC - $6.61
https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/V3Oracle.sol#L363 https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/automators/Automator.sol#L148
The V3Oracle::_getReferencePoolPriceX96
function uses uniswap slot0 price. slot0
price can be manipulated with flash loans.
The contract uses the instantaneous price from slot0
to get the pool price. The slot0 price is calculated from the ratios of the underlying assets. However, these ratios can be manipulated by buying/selling assets in the pool.
function _getReferencePoolPriceX96(IUniswapV3Pool pool, uint32 twapSeconds) internal view returns (uint256) { uint160 sqrtPriceX96; // if twap seconds set to 0 just use pool price if (twapSeconds == 0) { (sqrtPriceX96,,,,,,) = pool.slot0(); } else { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 0; // from (before) secondsAgos[1] = twapSeconds; // from (before) (int56[] memory tickCumulatives,) = pool.observe(secondsAgos); // pool observe may fail when there is not enough history available (only use pool with enough history!) int24 tick = int24((tickCumulatives[0] - tickCumulatives[1]) / int56(uint56(twapSeconds))); sqrtPriceX96 = TickMath.getSqrtRatioAtTick(tick); } return FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, Q96); }
Thus any user can take a flashloan, use those funds to manipulate the price of the target asset in the pool, and then trigger V3Vault::borrow()
to take a loan at good price.
All functionalities that use the slot0 price is vulnerable, this includes but not limited to borrowing, liquidate, transforming in V3Vault
contract as well as validating swap in Automator
contract since the same issue lies there too.
This vulnerability is closed when feedConfig.twapSeconds
is set to a value different from 0, but since V3Oracle::_checkPoolPrice()
always calls V3Oracle::_getReferencePoolPriceX96()
with 0 as twapSeconds, and V3Oracle::_checkPoolPrice()
is used in many places in the codebase, this vulnerability is very widespread and causes serious losses to the protocol.
The value of an LP token depends on the current market reserves which can be manipulated by an attacker via flashloans. Therefore, an attacker trading large amounts in the market can either increase or decrease the value of an LP token.
If the value decreases, they can try to liquidate users borrowing against their LP tokens. If the value increases, they can borrow against it and potentially receive an under-collateralized borrow this way, making a profit.
The exact profitability of such an attack depends on the AMM as the initial reserve manipulation and restoring the reserves later incurs fees and slippage. In constant-product AMMs like Uniswap it's profitable and several projects have already been exploited by this, like warp.finance.
Manual review
Only use TWAP to get the value of sqrtPriceX96.
Use a minimal twap seconds when twapSeconds
argument is set to 0.
function _getReferencePoolPriceX96(IUniswapV3Pool pool, uint32 twapSeconds) internal view returns (uint256) { uint160 sqrtPriceX96; // if twap seconds set to 0 just use pool price if (twapSeconds == 0) { -- (sqrtPriceX96,,,,,,) = pool.slot0(); ++ twapSeconds = MIN_TWAP_SECONDS; -- } else { } uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 0; // from (before) secondsAgos[1] = twapSeconds; // from (before) (int56[] memory tickCumulatives,) = pool.observe(secondsAgos); // pool observe may fail when there is not enough history available (only use pool with enough history!) int24 tick = int24((tickCumulatives[0] - tickCumulatives[1]) / int56(uint56(twapSeconds))); sqrtPriceX96 = TickMath.getSqrtRatioAtTick(tick); -- } return FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, Q96); }
Other
#0 - c4-pre-sort
2024-03-19T09:53:35Z
0xEVom marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-03-19T09:53:40Z
0xEVom marked the issue as primary issue
#2 - 0xEVom
2024-03-25T09:26:34Z
The value of an LP token depends on the current market reserves which can be manipulated by an attacker via flashloans. Therefore, an attacker trading large amounts in the market can either increase or decrease the value of an LP token.
Fails to recognize that there are protections in place to prevent against this and does not provide a clear attack path.
Grouping all invalid/insufficient quality submissions for "usage of slot0
" under this one.
#3 - jhsagd76
2024-03-31T02:18:46Z
This issue and the dups refer to the lookout comments https://github.com/code-423n4/2024-03-revert-lend-findings/issues/175#issuecomment-2017600605
#4 - jhsagd76
2024-03-31T14:27:45Z
Link this series of duplicates to https://github.com/code-423n4/2024-03-revert-lend-findings/issues/175, but halve the reward.
#5 - c4-judge
2024-03-31T14:28:20Z
jhsagd76 marked the issue as duplicate of #175
#6 - c4-judge
2024-03-31T14:43:14Z
jhsagd76 marked the issue as partial-50
#7 - c4-judge
2024-04-01T15:43:40Z
jhsagd76 changed the severity to 2 (Med Risk)
π Selected for report: y0ng0p3
Also found by: 0xk3y, 0xspryon, Mike_Bello90, Myd, falconhoof, lightoasis, th3l1ghtd3m0n
72.5395 USDC - $72.54
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/transformers/AutoCompound.sol#L159-L172 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L1032-L1074
The protocol is using block.timestamp
as the deadline
argument while interacting with the Uniswap NFT Position Manager, which completely defeats the purpose of using a deadline.
Actions in the Uniswap NonfungiblePositionManager contract are protected by a deadline parameter to limit the execution of pending transactions. Functions that modify the liquidity of the pool check this parameter against the current block timestamp in order to discard expired actions.
These interactions with the Uniswap position are present throughout the code base, in particular and not only in the functions V3Utils::_swapAndMint
, Automator::_decreaseFullLiquidityAndCollect
, LeverageTransformer::leverageUp
.. Those functions call their corresponding functions in the Uniswap Position Manager, providing the deadline
argument with their own dealine
argument.
On the other hand, AutoCompound::execute
and V3Vault::_sendPositionValue
functions provide block.timestamp as the argument for the deadline
parameter in their call to the corresponding underlying Uniswap NonfungiblePositionManager contract.
File: src/transformers/AutoCompound.sol // deposit liquidity into tokenId if (state.maxAddAmount0 > 0 || state.maxAddAmount1 > 0) { _checkApprovals(state.token0, state.token1); (, state.compounded0, state.compounded1) = nonfungiblePositionManager.increaseLiquidity( INonfungiblePositionManager.IncreaseLiquidityParams( @@-> params.tokenId, state.maxAddAmount0, state.maxAddAmount1, 0, 0, block.timestamp ) ); // fees are always calculated based on added amount (to incentivize optimal swap) state.amount0Fees = state.compounded0 * rewardX64 / Q64; state.amount1Fees = state.compounded1 * rewardX64 / Q64; }
File: src/V3Vault.sol if (liquidity > 0) { nonfungiblePositionManager.decreaseLiquidity( INonfungiblePositionManager.DecreaseLiquidityParams(tokenId, liquidity, 0, 0, block.timestamp) ); }
Using block.timestamp
as the deadline is effectively a no-operation that has no effect nor protection. Since block.timestamp
will take the timestamp value when the transaction gets mined, the check will end up comparing block.timestamp
against the same value (see https://github.com/Uniswap/v3-periphery/blob/697c2474757ea89fec12a4e6db16a574fe259610/contracts/base/PeripheryValidation.sol#L7).
Failure to provide a proper deadline value enables pending transactions to be maliciously executed at a later point. Transactions that provide an insufficient amount of gas such that they are not mined within a reasonable amount of time, can be picked by malicious actors or MEV bots and executed later in detriment of the submitter.
See this issue for an excellent reference on the topic (the author runs a MEV bot).
Manual review
As done in the LeverageTransformer::leverageUp
and V3Utils::_swapAndIncrease
functions, add a deadline parameter to the AutoCompound::execute
and V3Vault::_sendPositionValue
functions and forward this parameter to the corresponding underlying call to the Uniswap NonfungiblePositionManager contract.
Invalid Validation
#0 - c4-pre-sort
2024-03-18T13:53:52Z
0xEVom marked the issue as duplicate of #147
#1 - c4-pre-sort
2024-03-18T14:38:55Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T16:01:18Z
jhsagd76 marked the issue as satisfactory
π Selected for report: Bauchibred
Also found by: 0x11singh99, 0x175, 0xAlix2, 0xDemon, 0xGreyWolf, 0xPhantom, 0xspryon, 14si2o_Flint, Arabadzhiev, Aymen0909, Bigsam, BowTiedOriole, CRYP70, DanielArmstrong, FastChecker, JecikPo, KupiaSec, MohammedRizwan, Norah, Timenov, Topmark, VAD37, adeolu, btk, crypticdefense, cryptphi, givn, grearlake, jnforja, kennedy1030, kfx, ktg, lanrebayode77, n1punp, santiellena, stonejiajia, t4sk, thank_you, tpiliposian, wangxx2026, y0ng0p3, zaevlad
42.7786 USDC - $42.78
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L877-L917 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L920-L952
The protocol missing the DEADLINE check at all in logic.
This is actually how uniswap implemented the deadline
https://github.com/Uniswap/v2-periphery/blob/0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f/contracts/UniswapV2Router02.sol#L32-L76
// **** ADD LIQUIDITY **** function _addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin ) internal virtual returns (uint amountA, uint amountB) { // create the pair if it doesn't exist yet if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) { IUniswapV2Factory(factory).createPair(tokenA, tokenB); } (uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB); if (reserveA == 0 && reserveB == 0) { (amountA, amountB) = (amountADesired, amountBDesired); } else { uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB); if (amountBOptimal <= amountBDesired) { require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT'); (amountA, amountB) = (amountADesired, amountBOptimal); } else { uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA); assert(amountAOptimal <= amountADesired); require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT'); (amountA, amountB) = (amountAOptimal, amountBDesired); } } } function addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin, address to, uint deadline ) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) { (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin); address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB); TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA); TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB); liquidity = IUniswapV2Pair(pair).mint(to); }
The point is the deadline check
modifier ensure(uint deadline) { require(deadline >= block.timestamp, 'UniswapV2Router: EXPIRED'); _; }
The deadline check ensure that the transaction can be executed on time and the expired transaction revert.
The transaction can be pending in mempool for a long and the trading activity is very time sensitive. Without deadline check, the trade transaction can be executed in a long time after the user submit the transaction, at that time, the trade can be done in a sub-optimal price, which harms user's position.
The deadline check ensure that the transaction can be executed on time and the expired transaction revert.
Manual review
Consider adding deadline check like in the functions like withdraw and deposit.
Invalid Validation
#0 - c4-pre-sort
2024-03-21T13:28:59Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-21T13:29:02Z
0xEVom marked the issue as sufficient quality report
#2 - 0xEVom
2024-03-21T13:29:05Z
#3 - c4-sponsor
2024-03-26T22:00:44Z
kalinbas (sponsor) acknowledged
#4 - kalinbas
2024-03-26T22:01:41Z
ERC4626 does not support this parameter, so we can't add it to the main deposit and withdraw functions.
#5 - kalinbas
2024-03-26T22:02:42Z
Related https://github.com/code-423n4/2024-02-uniswap-foundation-findings/issues/331
Could you give more info please? I do not have access to the linked repo. @0xEVom
#6 - kalinbas
2024-03-26T22:04:45Z
Also deposit and withdraw do not have any slippage problem (like trading does) so it is not needed here. This should be a QA severity.
#7 - c4-sponsor
2024-03-26T22:04:49Z
kalinbas marked the issue as disagree with severity
#8 - 0xEVom
2024-03-27T07:06:17Z
@kalinbas yes sorry, that was more for future and internal reference. Severity of a similar issue is currently being debated.
I guess this is very similar to https://github.com/code-423n4/2024-03-revert-lend-findings/issues/281#issuecomment-2007420728
Note that trading is also mentioned in this finding (see https://github.com/code-423n4/2024-03-revert-lend-findings/issues/129)
#9 - kalinbas
2024-03-27T15:47:05Z
Ok i agree with deadline for trading. Will add it there
#10 - c4-sponsor
2024-03-27T15:47:50Z
kalinbas (sponsor) confirmed
#11 - jhsagd76
2024-03-31T00:12:26Z
Based on this well-known case that has been discussed by several senior judges, QA is appropriate. https://github.com/code-423n4/2024-02-uniswap-foundation-findings/issues/331
#12 - c4-judge
2024-03-31T00:12:34Z
jhsagd76 changed the severity to QA (Quality Assurance)
#13 - c4-judge
2024-03-31T00:12:40Z
jhsagd76 marked the issue as grade-a