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: 8/105
Findings: 6
Award: $1,398.31
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Arz
Also found by: lanrebayode77
545.9832 USDC - $545.98
The collateralValueLimitFactorX32
sets the maximum exposure limit for each token, expressed as a fraction of the lending pool, as stated in the docs.
To ensure this invariant holds in all actions, _updateAndCheckCollateral
is called in onERC721Received
(during transformation), borrow
, repay
, and _cleanupLoan
. This is done to increase or decrease tokenConfigs[token0].totalDebtShares
depending on the action and to ensure that the collateral token threshold is not exceeded.
During repay tokenConfigs[token0].totalDebtShares
increases and during borrow, it increases and check is done.
However, the _updateAndCheckCollateral
is not called during withdrawal, this makes it possible to borrow beyond the threshold of a collateral token by first depositing a large amount before borrowing, then withdrawing deposited funds after borrowing.
collateralValueLimitFactorX32
was set to 0.5 * Q32;tokenConfigs[tokenA].totalDebtShares == 300_000
tokenConfigs[tokenA].totalDebtShares == 800_000
and does not exceed threshold.if ( collateralValueLimitFactorX32 < type(uint32).max && _convertToAssets(tokenConfigs[token0].totalDebtShares, debtExchangeRateX96, Math.Rounding.Up) > lentAssets * collateralValueLimitFactorX32 / Q32 ) { revert CollateralValueLimit(); //@audit exposure check not eeffective, both should be added toggether, } collateralValueLimitFactorX32 = tokenConfigs[token1].collateralValueLimitFactorX32; if ( collateralValueLimitFactorX32 < type(uint32).max && _convertToAssets(tokenConfigs[token1].totalDebtShares, debtExchangeRateX96, Math.Rounding.Up) > lentAssets * collateralValueLimitFactorX32 / Q32 ) { revert CollateralValueLimit(); }
tokenConfigs[tokenA].totalDebtShares == 800_000
and threshold is now 500_000.Manual review
Expressing is relative to total borrowed will mitigate issue, since user withdrawal cannot be stopped due to threshold on borrowers.
Invalid Validation
#0 - c4-pre-sort
2024-03-20T08:55:23Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-20T08:55:26Z
0xEVom marked the issue as primary issue
#2 - c4-pre-sort
2024-03-23T18:01:21Z
0xEVom marked the issue as duplicate of #466
#3 - c4-judge
2024-04-01T11:09:45Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: alix40
Also found by: 0xPhantom, Norah, ktg, lanrebayode77
159.2087 USDC - $159.21
The V3Vault.transform()
allows users through a verified contract to transform a loan and check the health factor at the end of the function.
When V3Vault.transform()
is called, transformedTokenId
is set to tokenId
of the loan to be transformed, and the function performs low-level calls in the data input.
(bool success,) = transformer.call(data); if (!success) { revert TransformFailed(); }
This call could be to any function in the vault except for ones where calls from the transformer were explicitly denied with a check(liquidate and decreaseLiquidityAndCollect ).
function decreaseLiquidityAndCollect(DecreaseLiquidityAndCollectParams calldata params) external override returns (uint256 amount0, uint256 amount1) { // this method is not allowed during transform - can be called directly on nftmanager if needed from transform contract if (transformedTokenId > 0) { revert TransformNotAllowed(); }
function liquidate(LiquidateParams calldata params) external override returns (uint256 amount0, uint256 amount1) { // liquidation is not allowed during transformer mode if (transformedTokenId > 0) { revert TransformNotAllowed(); }
The V3Vault.borrow()
, calls are not rejected, and when borrowing in transform mode, loan health is not checked, this makes it possible to borrow undercollateralized loans in transform mode, since the check is only done at the end of the transform function.
Malicious users can exploit this to get free flash-loan. Couple with the fact that repay()
does not have any restriction against transform mode, the loan can be repaid without any interest before control goes back to the transform function where loan health is checked.
A call through transform()
to borrow()
the largest possible amount considering available asset, GlobalDebtLimit, dailyDebtIncreaseLimitLeft and collateral threshold.
The user uses the asset in the same call and calls repay() with the borrowed amount to return the position to a healthy one in the same transaction.
function borrow(uint256 tokenId, uint256 assets) external override { bool isTransformMode = transformedTokenId > 0 && transformedTokenId == tokenId && transformerAllowList[msg.sender]; (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) = _updateGlobalInterest(); _resetDailyDebtIncreaseLimit(newLendExchangeRateX96, false); ... ... // only does check health here if not in transform mode if (!isTransformMode) { _requireLoanIsHealthy(tokenId, debt); }
function _repay(uint256 tokenId, uint256 amount, bool isShare, bytes memory permitData) internal { //@audit repay in transform (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) = _updateGlobalInterest();
Since all the transactions are happening in the same block, the loan is repaid with the same newDebtExchangeRateX96
that the loan was taken, so there is zero interest payment. All that is needed is for a user to have a loan position, even with the minimum acceptable amount.
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; } }
Manual review
Check if in transform mode in repay()
and charge a specific interest fee, since newDebtExchangeRateX96
cannot be updated twice in a single block.
Invalid Validation
#0 - c4-pre-sort
2024-03-22T11:49:55Z
0xEVom marked the issue as duplicate of #435
#1 - c4-pre-sort
2024-03-22T11:49:59Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T03:43:33Z
jhsagd76 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-04-01T01:25:02Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: kfx
Also found by: 0x175, 0xAlix2, 0xjuan, AMOW, Aymen0909, CaeraDenoir, Giorgio, JCN, JecikPo, JohnSmith, Norah, SpicyMeatball, alexander_orjustalex, atoko, erosjohn, falconhoof, givn, grearlake, jnforja, kinda_very_good, lanrebayode77, nmirchev8, shaka, web3Tycoon, zxriptor
3.3501 USDC - $3.35
When a user loan position becomes unhealthy, it becomes eligible for liquidation.
Due to a check, liquidate()
can be denied by a malicious borrower by calling repay()
with dust amount of asset to change debtShares.
function liquidate(LiquidateParams calldata params) external override returns (uint256 amount0, uint256 amount1) { // liquidation is not allowed during transformer mode if (transformedTokenId > 0) { revert TransformNotAllowed(); } LiquidateState memory state; (state.newDebtExchangeRateX96, state.newLendExchangeRateX96) = _updateGlobalInterest(); uint256 debtShares = loans[params.tokenId].debtShares; @>> if (debtShares != params.debtShares) { //@audit frontrunning to repay dust!!!!!! revert DebtChanged(); }
// if resulting loan is too small - revert if (_convertToAssets(loanDebtShares, newDebtExchangeRateX96, Math.Rounding.Up) < minLoanSize) { revert MinLoanSize(); }
repay()
to ensure the resulting loan is healthy, so the position remains unhealthy and liquidators cannot liquidate, driving the vault to a state of insolvency.debtShares
difference.Manual review.
There are three options here:
_requireLoanIsHealthy(tokenId, debt);
DoS
#0 - c4-pre-sort
2024-03-22T12:04:24Z
0xEVom marked the issue as duplicate of #222
#1 - c4-pre-sort
2024-03-22T12:04:28Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T14:47:29Z
jhsagd76 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-31T15:54:09Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: lanrebayode77
425.8669 USDC - $425.87
On days with a significant number of liquidated positions, particularly when the asset quantity is substantial, there will be an excess of assets available in the vault that cannot be borrowed, thereby causing a drastic decrease in the utilization rate.
This also contradicts what was stated in the repay()
function, which asserts that repaid amounts should be borrowed again. Liquidation is also a form of repayment.
// when amounts are repayed - they maybe borrowed again dailyDebtIncreaseLimitLeft += assets;
dailyDebyIncreaseLimitLeft
was not increamented in liquidate()
. here
Manual review.
Include dailyDebyIncreaseLimitLeft
increment in liquidate()
.
dailyDebtIncreaseLimitLeft += state.liquidatorCost;
Context
#0 - c4-pre-sort
2024-03-22T11:51:45Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-22T11:54:36Z
0xEVom marked the issue as primary issue
#2 - c4-sponsor
2024-03-26T15:07:49Z
kalinbas (sponsor) confirmed
#3 - c4-judge
2024-03-31T00:17:41Z
jhsagd76 marked the issue as satisfactory
#4 - c4-judge
2024-04-01T15:34:25Z
jhsagd76 marked the issue as selected for report
#5 - kalinbas
2024-04-09T17:46:17Z
🌟 Selected for report: t4sk
Also found by: Bauchibred, hunter_w3b, lanrebayode77
221.1232 USDC - $221.12
To determine the health status of a loan, the collateral position is evaluated using Chainlink/Uniswap V3 Twap. The protocol tries to prevent price manipulation by providing a counter-check on the valuation and ensuring that the difference does not exceed a certain threshold.
However, the threshold difference can be surpassed without being flagged due to an error in the check.
function _requireMaxDifference(uint256 priceX96, uint256 verifyPriceX96, uint256 maxDifferenceX10000) internal pure { uint256 differenceX10000 = priceX96 > verifyPriceX96 ? (priceX96 - verifyPriceX96) * 10000 / priceX96 @>> : (verifyPriceX96 - priceX96) * 10000 / verifyPriceX96; // if too big difference - revert if (differenceX10000 >= maxDifferenceX10000) { revert PriceDifferenceExceeded(); } }
The priceX96 is obtained by calling _getReferencePoolPriceX96(pool, 0);
which returns slot zero price, while the derivedPoolPriceX96
is the price to be verified.
The problem is when priceX96 < verifyPriceX96, the difference in % is obtained by dividing the difference by verifyPriceX96 which will return a lower value Instance:
(verifyPriceX96 - priceX96) * 10000 / verifyPriceX96;
priceX96
is greater than differenceX1000
differenceX10000 = (4200 - 4000)*10_000 / 4000 == 500;Note: this is in both directions. If the price to be verified is lower than expected, the differences might not be caught since it is divided by the larger price.
Manual review.
To mitigate this in both directions, the difference should be divided by the smaller value, to catch max difference in %.
This is to prevent a manipulation that tries to reduce the price to liquidate a position that is still healthy.
function _requireMaxDifference(uint256 priceX96, uint256 verifyPriceX96, uint256 maxDifferenceX10000) internal pure { uint256 differenceX10000 = priceX96 > verifyPriceX96 -- ? (priceX96 - verifyPriceX96) * 10000 / priceX96 ++ ? (priceX96 - verifyPriceX96) * 10000 / verifyPriceX96 -- : (verifyPriceX96 - priceX96) * 10000 / verifyPriceX96; ++ : (verifyPriceX96 - priceX96) * 10000 / priceX96; // if too big difference - revert if (differenceX10000 >= maxDifferenceX10000) { revert PriceDifferenceExceeded(); } }
Math
#0 - c4-pre-sort
2024-03-22T07:39:08Z
0xEVom marked the issue as duplicate of #10
#1 - c4-pre-sort
2024-03-22T07:39:11Z
0xEVom marked the issue as insufficient quality report
#2 - 0xEVom
2024-03-22T07:41:14Z
Highlight in POC points to the correct case and mitigation reintroduces the issue.
#3 - c4-judge
2024-04-01T11:09:37Z
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#L718-L724 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L894-L897 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L979-L982
Permit-related functions require the user to sign an approval message using the contract as the spender/approved so that safeTransferFrom can be called.
This feature comes with a vulnerability that enables a malicious observer to copy the signature on the chain and call permit directly on the token contract to grant vault approval. For more info
The problem is that when the user transaction now tries to call permit with the same details, it reverts due to nonce usage and the overall transaction fails.
if (permitData.length > 0) { (ISignatureTransfer.PermitTransferFrom memory permit, bytes memory signature) = abi.decode(permitData, (ISignatureTransfer.PermitTransferFrom, bytes)); permit2.permitTransferFrom( permit, ISignatureTransfer.SignatureTransferDetails(address(this), assets), msg.sender, signature ); If a malicious front-runs this transaction to call permit, it will cause the entire function to fail.
manual review and Trust article
Wrap the permit in a try-catch block. Or check if approval is already >= to amount.
DoS
#0 - c4-pre-sort
2024-03-19T09:34:39Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-19T09:42:02Z
0xEVom marked the issue as primary issue
#2 - c4-pre-sort
2024-03-19T09:42:09Z
0xEVom marked the issue as duplicate of #229
#3 - c4-judge
2024-03-30T02:09:35Z
jhsagd76 changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-04-01T11:06:42Z
jhsagd76 marked the issue as grade-a