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: 50/105
Findings: 4
Award: $162.19
🌟 Selected for report: 1
🚀 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/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L360-L363 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L366-L369 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L372-L375 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L378-L381
The V3Vault::withdraw
function withdraws the assets
, parameter for the amount of asset, by burning shares
in the V3Vault::_withdraw
function subsequently called.
function withdraw(uint256 assets, address receiver, address owner) external override returns (uint256) { (, uint256 shares) = _withdraw(receiver, owner, assets, false); return shares; }
The V3Vault::deposit
function deposits the assets
, parameter for the amount of asset, by minting shares
in the V3Vault::_deposit
function subsequently called.
function deposit(uint256 assets, address receiver) external override returns (uint256) { (, uint256 shares) = _deposit(receiver, assets, false, ""); return shares; }
The V3Vault::redeem
function redeems a specific amount of shares, by burning them and withdrawing in the corresponding amount of assets in V3Vault::_withdraw
function subsequently called.
function redeem(uint256 shares, address receiver, address owner) external override returns (uint256) { (uint256 assets,) = _withdraw(receiver, owner, shares, true); return assets; }
The V3Vault::mint
function mints the assets
, parameter for the amount of asset, by minting shares
in the V3Vault::_deposit
function subsequently called.
function mint(uint256 shares, address receiver) external override returns (uint256) { (uint256 assets,) = _deposit(receiver, shares, true, ""); return assets; }
It is theoretically possible for the deposit amount to mint shares more than the maximum mint-able amount, or for the withdraw amount to burn shares more than the maximum withdraw-able amount. It is also theoretically possible for the mint amount to deposit assets more than the maximum deposit-able amount, or for the redeem amount to withdraw assets more than the maximum withdraw-able amount.
However, there is neither protection against burning or minting too many user's shares for their specific assets
, not protection against depositing or withdrawing too many user's assets for their specific amount of shares. Then the maxDeposit
, maxMint
, maxWithdraw
and maxRedeem
functions of V3Vault
should be used to protect user's shares.
There is no check to validate if the minted/burned shares would exceed the maximum amount of shares that could've been minted/burned by V3Vault, nor check to validate if the deposited/withdrawn amount of assets would exceed the maximum amount of assets that could've been deposited/withdrawn.
However, the expression amount.mulDiv(Q96, exchangeRateX96, rounding)
in the V3Vault::_convertToShares
function could return a value more than the maximum mint-able or the maximum redeem-able amount.
As well, the expression shares.mulDiv(exchangeRateX96, Q96, rounding)
in the V3Vault::_convertToAssets
function could return a value more than the maximum deposit-able amount or the maximum withdraw-able amount.
This is possible if the vault get exploited or is severely under-collateralized.
User call V3Vault::withdraw()
with assets = 1200
. Normally, the burned shares should be 1000
. However, there's an exploit in the vault which makes the shares burned to = 100_000
, the entire user's shares.
Users can lose their entire shares when withdrawing and obtain more shares with depositing due to lack of amount burned check. Users can obtain more assets when redeeming due to lack of check on amount of withdrawn assets.
Manual review
Include the maxMintable
, maxDepositable
, maxWithdrawable
and maxRedeemable
check in the V3Vault
deposit, withdraw, redeem and mint corresponding functions.
Below an example :
- function withdraw(uint256 assets, address receiver, address owner) external override returns (uint256) { + function withdraw( + uint256 assets, + address receiver, + address owner, + uint256 maxRedeem, + uint256 maxWithdraw + ) external override returns (uint256) { + if (assets > maxWithdraw) { + revert TooMuchAmount(); + } (, uint256 shares) = _withdraw(receiver, owner, assets, false); + if (shares > maxRedeem) { + revert TooMuchAmount(); + } return shares; }
Invalid Validation
#0 - c4-pre-sort
2024-03-20T16:00:12Z
0xEVom marked the issue as duplicate of #281
#1 - c4-pre-sort
2024-03-20T16:00:16Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T03:21:19Z
jhsagd76 changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-03-31T15:55:13Z
jhsagd76 marked the issue as grade-b
#4 - c4-judge
2024-04-01T09:09:10Z
jhsagd76 removed the grade
#5 - c4-judge
2024-04-03T00:30:45Z
This previously downgraded issue has been upgraded by jhsagd76
#6 - c4-judge
2024-04-03T00:31:43Z
jhsagd76 marked the issue as not a duplicate
#7 - c4-judge
2024-04-03T00:32:01Z
jhsagd76 marked the issue as duplicate of #249
#8 - khalidfsh
2024-04-03T02:39:27Z
I believe this is not duplicate of #249, the mitigation suggest to use functions rather than fixing them.
Also, issue #201 is unsatisfactory as of issue #151 Multiple duplicates of #249 explained 2 issues or even 1 out of the 4 issues, for consistency they should be partial-50 duplicates or issue #111 should have full reward.
#9 - jhsagd76
2024-04-04T08:26:23Z
#151 and #201 regard the penalties related to the good citizenship policy, I will revise them according to our current conclusions. We will not elaborate here (C4 staff may disclose the details).
#10 - jhsagd76
2024-04-04T08:32:02Z
This issue remains unchanged. #111 is escalated to 100%.
#11 - c4-judge
2024-04-04T14:15:05Z
jhsagd76 marked the issue as satisfactory
🌟 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:50:33Z
0xEVom marked the issue as duplicate of #249
#1 - c4-pre-sort
2024-03-21T13:50:36Z
0xEVom marked the issue as sufficient quality report
#2 - c4-pre-sort
2024-03-25T14:58:12Z
0xEVom marked the issue as not a duplicate
#3 - c4-pre-sort
2024-03-25T14:58:38Z
0xEVom marked the issue as duplicate of #179
#4 - c4-pre-sort
2024-03-25T14:59:38Z
0xEVom marked the issue as insufficient quality report
#5 - 0xEVom
2024-03-25T14:59:48Z
EIP specifies:
if called in the same transaction.
Which invalidates:
This basically allows the two functions be called in different blocks, which means that global interest rates would be recalculated.
#6 - c4-judge
2024-04-01T11:10:25Z
jhsagd76 marked the issue as unsatisfactory: Insufficient proof
#7 - c4-judge
2024-04-04T08:26:57Z
jhsagd76 marked the issue as not a duplicate
#8 - c4-judge
2024-04-04T08:27:14Z
jhsagd76 marked the issue as duplicate of #249
#9 - c4-judge
2024-04-04T08:29:01Z
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); }
Oracle
#0 - c4-pre-sort
2024-03-19T09:53:33Z
0xEVom marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-03-19T09:53:50Z
0xEVom marked the issue as duplicate of #191
#2 - c4-judge
2024-03-31T14:28:15Z
jhsagd76 marked the issue as duplicate of #175
#3 - c4-judge
2024-03-31T14:43:28Z
jhsagd76 marked the issue as partial-50
#4 - 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
94.3013 USDC - $94.30
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.
In 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.
Other
#0 - c4-pre-sort
2024-03-18T10:55:51Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-18T14:38:57Z
0xEVom marked the issue as high quality report
#2 - c4-sponsor
2024-03-26T15:29:44Z
kalinbas (sponsor) confirmed
#3 - jhsagd76
2024-03-31T04:04:00Z
Aha, although the debate over such issues continues, according to the current C4 rules, it's valid M.
#4 - c4-judge
2024-03-31T04:04:32Z
jhsagd76 marked the issue as satisfactory
#5 - c4-judge
2024-04-01T15:33:53Z
jhsagd76 marked the issue as selected for report
#6 - kalinbas
2024-04-09T23:13:54Z
🌟 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.
Other
#0 - c4-pre-sort
2024-03-21T13:29:36Z
0xEVom marked the issue as duplicate of #200
#1 - c4-pre-sort
2024-03-21T13:29:39Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T00:12:33Z
jhsagd76 changed the severity to QA (Quality Assurance)