Search

LoopFi Contest Review - Final

태그
RedSpider
Blockchain
Property
LoopFiBlog.png
This article marks the final entry in the LoopFi contest series.
We will review the vulnerabilities discovered during the contest period and examine which aspects of yield-generating protocols are most prone to security issues.
Additionally, we will discuss the perspectives necessary to prevent such vulnerabilities and assess whether the patches applied were effective.

H-01. RewardManagern.sol: Potential Reward Loss Due to Inconsistency Between index and lastBalance Updates in _updateRewardIndex

Overview

This vulnerability arises from precision loss during index calculation, where lastBalance increases without a corresponding increment in index, resulting in the loss of a portion of rewards that should have been distributed to collateral depositors.

Description

In LoopFi, rewards to depositors are updated based on the collateral information deposited in the Vault whenever a position is modified. Position updates occur during actions such as deposit, withdraw, repay, and borrow, where collateral or debt balances can change.
Rewards are first received by the Vault from the Pendle Market, and are then distributed to depositors in proportion to their collateral shares. The final reward amount each depositor receives is calculated by multiplying the depositor’s share by the index, which represents the reward amount per unit of collateral. The _updateRewardIndex function in RewardManager.sol is responsible for receiving reward tokens from the Pendle Market and updating the index based on the accrued rewards.
The issue occurs when the newly accrued rewards are smaller than the total amount of collateral (totalShares) in the Vault. In such case, accrued.divDown(totalShares) evaluates to 0, causing the index to remain unchanged. However, lastBalance still increases by the amount of new rewards.
Since lastBalance is deducted from the Vault’s total reward balance when calculating the next index, the portion of rewards corresponding to the accrued amount is effectively omitted from future distributions.
function _updateRewardIndex() internal virtual override returns (address[] memory tokens, uint256[] memory indexes) { // ... // Total Collateral the CDPVault has uint256 totalShares = _rewardSharesTotal(); // Claim external rewards on Market market.redeemRewards(address(vault)); for (uint256 i = 0; i < tokens.length; ++i) { address token = tokens[i]; // the entire token balance of the contract must be the rewards of the contract RewardState memory _state = rewardState[token]; (uint256 lastBalance, uint256 index) = (_state.lastBalance, _state.index); uint256 accrued = IERC20(tokens[i]).balanceOf(vault) - lastBalance; if (index == 0) index = INITIAL_REWARD_INDEX; // initial index; 1 >> if (totalShares != 0) index += accrued.divDown(totalShares); rewardState[token] = RewardState({ index: index.Uint128(), >> lastBalance: (lastBalance + accrued).Uint128() }); indexes[i] = index; } // ... }
Solidity
복사
2024-10-loopfi/src/pendle-rewards/RewardManager.sol
Since totalShares represents the sum of all collateral across Vault positions, it is likely to be a very large number. Meanwhile, rewards accrue gradually, making it quite possible for the newly accrued rewards to be smaller than totalShares.
This issue is more likely to occure if _updateRewardIndex is called frequently, either due to a high number of users or malicious actors repeatedly triggering the reward collection function. As a result, small portions of rewards may continue to be lost over time.
It is also important to consider the difference in decimal places between the reward token and the collateral token. Regardless of the token type, deposited collateral is normalized to 18 decimals at the time of deposit. If the reward token has fewer decimals — for example, 6 decimals as in the case of USDC — the reward amount becomes significantly smaller compared to the 18-decimal collateral token, amplifying the precision loss.
tokenAmount = wdiv(amount, tokenScale); // Equivalent to `(x * WAD) / y` rounded down.
Solidity
복사
2024-10-loopfi/src/CDPVault.sol: deposit#L241

Comment

This vulnerability highlights the critical importance of thoroughly reviewing precision loss in arithmetic operations. When performing calculations between different types of tokens, especially division, developers must carefully ensure that one value does not become disproportionately smaller than the other. If the numerator is smaller than the denominator, the result becomes zero; if the denominator is smaller than the numerator, a division-by-zero error may occur.
Additionally, although it may not cause critical issues in this case, developers should still be mindful of potential precision differences due to different token decimals.

loopfi fix

In LoopFi’s patch, instead of simply adding accrued to the index, they changed the logic to add a newly calculated per-share reward increment (deltaIndex).
This approach ensures that when accrued.divDown(totalShares) is zero, index remains unchanged, and lastBalance is updated consistently by multiplying deltaIndex by the total shares. Thus, both index and lastBalance stay synchronized, effectively preventing any reward loss.
function _updateRewardIndex() internal virtual override returns (address[] memory tokens, uint256[] memory indexes) { ... uint256 accrued = IERC20(tokens[i]).balanceOf(vault) - lastBalance; + uint256 deltaIndex; + uint256 advanceBalance; if (totalShares != 0) { - uint256 accrued = IERC20(tokens[i]).balanceOf(vault) - lastBalance; + deltaIndex = accrued.divDown(totalShares); + advanceBalance = deltaIndex.mulDown(totalShares); + } if (index == 0) index = INITIAL_REWARD_INDEX; - if (totalShares != 0) index += accrued.divDown(totalShares); + if (totalShares != 0) index += deltaIndex; rewardState[token] = RewardState({ index: index.Uint128(), - lastBalance: (lastBalance + accrued).Uint128() + lastBalance: (lastBalance + advanceBalance).Uint128() }); indexes[i] = index; } } else { for (uint256 i = 0; i < tokens.length; i++) { indexes[i] = rewardState[tokens[i]].index; } } }
Diff
복사
loop-contracts/src/pendle-rewards/RewardManager.sol

H-02. CDPVault.sol: Incorrect Handling of Pool Profit and Loss in liquidatePositionBadDebt

Overview

In the process of liquidating bad debt, the loss that the treasury must cover is miscalculated, causing the treasury to bear more than it should.
Additionally, the function dose not correctly handle the case where both profits and losses exist in the pool. As a result, the pool’s losses are improperly accounted for.

Description

The liquidatePositionBadDebt function aims to minimize losses by selling the collateral to liquidators from a defaulted position at a discount . It calculates the pool’s loss by subtracting the repayment amount from the total debt(principal + accrued interest). If the repayment amount exceeds the principal, the surplus is considered profit for the pool, interpreted as partially realized interest.
At first glance, this calculation appears reasonable. However, if we think carefully about "What is the extent of the loss that the treasury must bear?," it becomes clear that the profit should be the interest accrued on the position, debt.accruedInterest.
The debt principal represents the ETH originally lent from the pool — the assets belonging to Lenders. If the repayAmount is less than the principal, the ETH originally in the pool becomes insufficient, resulting in a direct loss to the Lender’s assets. In contrast, unpaid interest represents future expected profits, not a current asset loss. The current implementation mistakenly treats the unpaid interest as a realized loss, leading to an excessive burn of treasury shares. Since treasury shares contribute to rewards for lpETH stakers, this over-burning imposes an undue burden on lpETH stakers.
For example, suppose a position has a principal of 100 and accrued interest of 50 (totalDebt = 150). If the repayAmount is 80, the pool suffers a 20 loss compared to the original loan of 100. The 50 in interest is an unrealized gain. However, the current implementation counts the loss as 70, resulting in a burn of 70 treasury shares. By defining profit as debt.accruedInterest and excluding unpaid interest from the loss calculation, the pool can more accurately reflect its actual loss.
It might seem counterintuitive to count unpaid interest as profit. This approach ensures that the protocol absorbs the missed interest internally, protecting lpETH stakers from negative impacts as if the full interest had been realized.
function liquidatePositionBadDebt(address owner, uint256 repayAmount) external whenNotPaused { // ... takeCollateral = position.collateral; repayAmount = wmul(takeCollateral, discountedPrice); >> uint256 loss = calcTotalDebt(debtData) - repayAmount; uint256 profit; if (repayAmount > debtData.debt) { >> profit = repayAmount - debtData.debt; } // transfer the repay amount from the liquidator to the vault poolUnderlying.safeTransferFrom(msg.sender, address(pool), repayAmount); // ... >> pool.repayCreditAccount(debtData.debt, profit, loss); // U:[CM-11] // ... }
Solidity
복사
2024-10-loopfi/src/CDPVault.sol
Another issue lies in the logic for handling pool profits and losses, which fails to account for the case where both profit and loss exist simultaneously. The current implmentation assumes that only one of the two — either profit or loss — will occure, which causes loss to be consistently ignored when both are present.
function repayCreditAccount( uint256 repaidAmount, uint256 profit, uint256 loss ) external override creditManagerOnly // U:[LP-2C] whenNotPaused // U:[LP-2A] nonReentrant // U:[LP-2B] { // ... if (profit > 0) { _mint(treasury, _convertToShares(profit)); // U:[LP-14B] } else if (loss > 0) { address treasury_ = treasury; uint256 sharesInTreasury = balanceOf(treasury_); uint256 sharesToBurn = _convertToShares(loss); if (sharesToBurn > sharesInTreasury) { unchecked { emit IncurUncoveredLoss({ creditManager: msg.sender, loss: _convertToAssets(sharesToBurn - sharesInTreasury) }); // U:[LP-14D] } sharesToBurn = sharesInTreasury; } _burn(treasury_, sharesToBurn); // U:[LP-14C,14D] } // ... }
Solidity
복사
2024-10-loopfi/src/PoolV3.sol

Comment

This vulnerability stems from a subtly flawed calculation that can only be identified by understanding the protocol’s broader mechanisms — including how the pool’s profit and loss are handled, why treasury shares are burned, and how these factors impact the system.
At first glance, treating the unrecovered portion of the total debt (principal + interest) as a loss may appear reasonable, especially since most protocols consider unpaid interest a form of loss.
However, LoopFi addresses losses by burning treasury shares, which directly reduces the rewards distributed to lpETH (pool share) stakers. Given this mechanism, the value of lpETH should be determined solely based on actual realized profit or loss within the pool. From the perspective of lpETH stakers, adjusting the ETH-lpETH exchange rate based on losses that were not truly incurred introduces undesirable accounting inconsistencies.

loopfi fix

Removed logic for calculating repayAmount, set profit to the accrued interest in the position when reporting the repayment to the pool.
function liquidatePositionBadDebt(address owner, uint256 repayAmount) external whenNotPaused { // ... - uint256 profit; - if (repayAmount > debtData.debt) { - profit = repayAmount - debtData.debt; - } // ... - pool.repayCreditAccount(debtData.debt, profit, loss); + pool.repayCreditAccount(scaledDebt, scaledInterest, scaledLoss); // U:[CM-11] // ... } }
Diff
복사
2024-10-loopfi/src/CDPVault.sol: liquidatePositionBadDebt
function repayCreditAccount( uint256 repaidAmount, uint256 profit, uint256 loss ){ // ... if (profit > 0) { _mint(treasury, _convertToShares(profit)); // U:[LP-14B] } - else if (loss > 0) {...} + if (loss > 0) {...} // ... }
Diff
복사
loop-contracts/src/PoolV3.sol: repayCreditAccount

M-01. PositionAction.sol: Incorrect Flash Loan Fee Handling in onCreditFlashLoan Causes decreaseLever to Always Fail

Overview

This vulnerability arises during the process of partially repaying debt to reduce collateral, where the contract fails to properly account for incoming token amounts.
As a result, it does not secure enough tokens to cover the flash loan fee, leading to a transaction failure.

Description

Users can reduce leverage by removing a portion of their collateral from the Vault. By using decreaseLever in PositionAction to reduce collateral, users can immediately sell the withdrawn collateral.
To do so, they set the amount of collateral to remove from the vault, subCollateral, and the amount of collateral to sell, leverParams.primarySwap.amount, and then call the decreaseLever function. This function borrows the underlying token(WETH) from the pool via a flash loan, corresponding to the set collateral sale amount(leverParams.primarySwap.amount). The borrowed WETH is first used to repay the position’s debt. Immediately after, the removed collateral is sold to repay the flash loan.
We can notice here that the repayment amount for the flash loan must include both the principal and the associated fee. However, since all of the borrowed WETH is used to repay the debt, the contract lacks sufficient tokens to cover the flash loan fee, causing the transaction to revert.
Let's look more closely at the code. The user calls decreaseLever by specifying the amount of collateral to remove (subCollateral) and the amount to sell (leverParams.primarySwap.amount), thereby triggering the borrowing of WETH through a flash loan.
function decreaseLever( >> LeverParams memory leverParams, uint256 subCollateral, // collateral amount to withdraw from the CDPVault address residualRecipient ) external onlyDelegatecall onlyRegisteredVault(leverParams.vault) { // ... if (leverParams.primarySwap.swapType == SwapType.EXACT_OUT) { uint256 totalDebt = ICDPVault(leverParams.vault).virtualDebt(leverParams.position) >> leverParams.primarySwap.amount = min(totalDebt, leverParams.primarySwap.amount); // ... // take out credit flash loan flashlender.creditFlashLoan( ICreditFlashBorrower(self), >> leverParams.primarySwap.amount, abi.encode(leverParams, subCollateral, residualRecipient) ); IPermission(leverParams.vault).modifyPermission(leverParams.position, self, false); }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol
After the flash loan is issued, the FlashLender calls the onCreditFlashLoan hook in PositionAction, which handles actions such as repaying the position’s debt, withdrawing collateral, and repaying the flash loan using the borrowed funds and the calculated fee.
Within this hook, the Vault’s and FlashLender’s underlyingToken allowances are increased as needed to repay the flash loan. The borrowed tokens are then used to repay the position’s debt.
The problem arises at this point: the borrowed tokens are assigned entirely to subDebt without considering the flash loan fee, meaning that all borrowed tokens are consumed for debt repayment, leaving the WETH balance in PositionAction at zero.
In this case, the WETH obtained from selling the withdrawn collateral must be sufficient to cover the flash loan principal plus the fee. Let’s take a look atprimarySwap.amount.
If the swap mode is EXACT_IN, the entire amount of withdrawn collateral is set as primarySwap.amount. Thus, if the user provides a sufficiently large subCollateral, it is possible to secure enough WETH to pay the fee. However, in the case of EXACT_OUT, the amount of WETH received from selling collateral is fixed to exactly match the flash loan principal — the totalDebt or a smaller value. As a result, there will always be a shortfall equivalent to the flash loan fee.
Consequently, the FlashLender cannot retrieve the full repayment amount (principal + fee), causing the transaction to revert.
if (leverParams.primarySwap.swapType == SwapType.EXACT_OUT) { uint256 totalDebt = ICDPVault(leverParams.vault).virtualDebt(leverParams.position); >> leverParams.primarySwap.amount = min(totalDebt, leverParams.primarySwap.amount); // ... }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol: decreaseLever#L376
function onCreditFlashLoan( address /*initiator*/, uint256 /*amount*/, uint256 fee, bytes calldata data ) external returns (bytes32) { // ... uint256 subDebt = leverParams.primarySwap.amount; >> underlyingToken.forceApprove(address(leverParams.vault), subDebt + fee); ICDPVault(leverParams.vault).modifyCollateralAndDebt( leverParams.position, address(this), address(this), 0, >> -toInt256(subDebt) ); // withdraw collateral and handle any CDP specific actions uint256 withdrawnCollateral = _onDecreaseLever(leverParams, subCollateral); if (leverParams.primarySwap.swapType == SwapType.EXACT_IN) { >> leverParams.primarySwap.amount = withdrawnCollateral; // ... } else { bytes memory swapData = _delegateCall( address(swapAction), >> abi.encodeWithSelector(swapAction.swap.selector, leverParams.primarySwap) ); // ... } >> underlyingToken.forceApprove(address(flashlender), subDebt + fee); return CALLBACK_SUCCESS_CREDIT; }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol

Comment

This case highlights the importance of meticulously tracking token inflows and outflows at each step of a complex workflow involving multiple contracts. It is difficult to predict where token shortages or surpluses may occur. While a shortage of required tokens may cause a transaction to revert — making the issue immediately noticeable — a more serious problem arises when surplus tokens become stuck in the contract and cannot be recovered.
In fact, the onCreditFlashLoan function also has a vulnerability where residual tokens can be trapped within PositionAction, which will be discussed in detail in the M-05 section.

loopfi fix

The fix involved reserving a portion of the borrowed funds to cover the flash loan fee before repaying the position’s debt.
Additionally, instead of uniformly setting subDebt regardless of swap type as in the previous implementation, the updated logic now calculates and sets subDebt differently based on each swap type to properly manage debt repayment.
function onCreditFlashLoan( address /*initiator*/, uint256 /*amount*/, uint256 fee, bytes calldata data ) external returns (bytes32) { // ... - uint256 subDebt = leverParams.primarySwap.amount - ICDPVault(leverParams.vault).modifyCollateralAndDebt( - leverParams.position, - address(this), // action contract - address(this), - 0, - -toInt256(subDebt) - ); if (leverParams.primarySwap.swapType == SwapType.EXACT_IN) { // ... + uint256 scaledDebt = wdiv(vars.subDebt - fee, poolScale) + vars.totalDebtLoss; + ICDPVault(leverParams.vault).modifyCollateralAndDebt( + leverParams.position, + address(this), + address(this), + 0, + -toInt256(scaledDebt) ); // ... } else { // ... + uint256 scaledAmount = wdiv(vars.subDebt - fee, poolScale) + vars.totalDebtLoss; + ICDPVault(leverParams.vault).modifyCollateralAndDebt( + leverParams.position, + address(this), + address(this), + 0, + -toInt256(scaledAmount) ); // ... underlyingToken.forceApprove(address(flashlender), vars.subDebt + fee); } return CALLBACK_SUCCESS_CREDIT; } }
Diff
복사
loop-contracts/src/proxy/PositionAction.sol: onCreditFlashLoan#L502

M-02. PositionAction.sol: Transaction Failure Due to Incorrect residualAmount Handling in onCreditFlashLoan

Overview

This vulnerability arises from a failure to consider all possible user position states when handling the remaining underlyingToken after repaying a flash loan.

Description

In the EXACT_IN swap type for primarySwap, swapAmountOut represents the amount of underlyingToken obtained by swapping collateral. If the amount of underlyingToken (WETH) received exceeds the amount needed to repay the flash loan, onCreditFlashLoan attempts to use the residual tokens to repay the position’s debt.
However, if there is no remaining debt in the position, the transaction fails when calling the pool’s repayCreditAccount. Even if some debt remains and the transaction does not revert, an excessive amount may still be used for debt repayment, leading to unintended repayment amounts beyond what the user intended.
uint256 residualAmount = swapAmountOut - subDebt; // sub collateral and debt if (residualAmount > 0) { underlyingToken.forceApprove(address(leverParams.vault), residualAmount); ICDPVault(leverParams.vault).modifyCollateralAndDebt( leverParams.position, address(this), address(this), 0, -toInt256(residualAmount) ); }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol: onCreditFlashLoan#L491
if (cmBorrowed == 0) { revert CallerNotCreditManagerException(); // U:[LP-2C,14A] }
Solidity
복사
2024-10-loopfi/src/PoolV3.sol: repayCreditAccount#L587
When reducing leverage, there are a few scenarios where a user's position may have no remaining debt:
1.
The user intends to fully repay their debt through decreaseLever.
In this case, the user likely sets primarySwap.amount to match the total debt amount. As a result, the flash loan proceeds will already have been used to repay all outstanding debt.
2.
The position enters the liquidation phase.
During liquidation, third parties can purchase the collateral at a discount and repay the position's debt. If a liquidator has already repaid the debt and the position owner subsequently calls decreaseLever, the position may have no remaining debt.
Although these scenarios are not common, they are entirely possible in practice.

Comment

This case highlights the critical importance of handling edge cases.
Beyond normal and intended flows, all possible states must be carefully considered to prevent unexpected side effects. Particularly when multiple contracts share or modify the same state, any changes must be thoroughly reviewed to understand how they impact each contract involved.

loopfi fix

The fix was to adjust the logic so that if any residual tokens remain, they are used to repay debt only if debt still exists. If there is no remaining debt, the residual tokens are transferred to the residualRecipient.
However, the behavior still prioritizes debt repayment over realizing profit through collateral withdrawal, suggesting that LoopFi has adopted a policy where repaying debt is prioritized during the decreaseLever process.
vars.residualDestAmount = vars.swapAmountOut - (vars.subDebt + fee); if (vars.residualDestAmount > 0) { if (vars.subDebt < vars.totalDebt) { // ... } else if ( leverParams.auxSwap.assetIn != address(0) && leverParams.auxSwap.swapType == SwapType.EXACT_IN ) { // ... + } else { + underlyingToken.safeTransfer(residualRecipient, vars.residualDestAmount); } }
Diff
복사
loop-contracts/PositionAction.sol: onCreditFlashLoan#L523

M-03. PositionAction.sol: Incorrect Owner Address Passed in _onWithdraw

Overview

This vulnerability occurs due to confusion about the address of the position from which collateral should be withdrawn, leading to an attempt to withdraw collateral from a non-existent position.

Description

PositionAction4626.sol extends PositionAction.sol to provide handlers for deposit and withdraw operations when the Vault’s underlying token conforms to the ERC4626 standard.
When a user calls withdraw through a proxy contract to withdraw collateral from the Vault, the internal _onWithdraw handler is triggered, which in turn calls the Vault’s withdraw function to execute the withdrawal.
The issue arises because, as shown in the code below, _onWithdraw incorrectly passes address(this) — the address of the current proxy contract — instead of the actual position address as a parameter. As a result, the contract attempts to withdraw collateral from an invalid address rather than from the correct user position.
function _onWithdraw( address vault, address /*position*/, address dst, uint256 amount ) internal override returns (uint256) { >> uint256 collateralWithdrawn = ICDPVault(vault).withdraw(address(this), amount); // if collateral is not the dst token, we need to withdraw the underlying from the ERC4626 vault address collateral = address(ICDPVault(vault).token()); if (dst != collateral) { collateralWithdrawn = IERC4626(collateral).redeem(collateralWithdrawn, address(this), address(this)); } return collateralWithdrawn; }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction4626.sol
Originally, the position was passed as a parameter, but it appears that it was commented out under the assumption that, due to delegatecall during deposit operations, the proxy contract address would serve as the position address.
However, a closer look at _onDeposit shows that the position address is explicitly passed separately. As a result, _onWithdraw attempts to withdraw collateral from a non-existent position, causing the transaction to fail.
function _onDeposit(address vault, address position, address src, uint256 amount) internal override returns (uint256) { address collateral = address(ICDPVault(vault).token()); // if the src is not the collateralToken, we need to deposit the underlying into the ERC4626 vault if (src != collateral) { address underlying = IERC4626(collateral).asset(); IERC20(underlying).forceApprove(collateral, amount); amount = IERC4626(collateral).deposit(amount, address(this)); } IERC20(collateral).forceApprove(vault, amount); >> return ICDPVault(vault).deposit(position, amount); }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction4626.sol

Comment

This vulnerability occurred because of a misunderstanding of what address(this) refers to when using a combination of proxy contracts and delegatecall.
When delegatecall is used, address(this) within the called contract refers to the calling contract — in this case, the proxy contract — rather than the target contract. When implementing proxy contracts or using delegatecall, thorough validation and testing must be performed to ensure that the correct contextual address is used across all call paths.

loopfi fix

The logic was corrected to withdraw collateral from the proper position.
function _onWithdraw( address vault, address position, address dst, uint256 amount, uint256 /*minAmountOut*/ ) internal override returns (uint256) { - uint256 collateralWithdrawn = ICDPVault(vault).withdraw(address(this), amount); + uint256 scaledCollateralWithdrawn = ICDPVault(vault).withdraw(position, amount); + uint256 collateralWithdrawn = wmul(scaledCollateralWithdrawn, ICDPVault(vault).tokenScale()); // ... } }
Diff
복사
loop-contracts/src/proxy/PositionAction4626.sol

M-04. PositionActionPendle.sol: Slippage Omission During pendleToken Withdrawal

Overview

In PositionActionPendle.sol, which interacts with Vaults that use pendle tokens as collateral, there is a vulnerability where slippage is not properly set when withdrawing collateral.
As a result, users may suffer losses if significant price fluctuations occur during the withdrawal process.

Description

When collateral is withdrawn from the Vault, the _onWithdraw hook is triggered, which first calls the Vault’s withdraw function to retrieve the collateral.
If the user intends to receive a different token from the collateral token and sets dst, the collateral is exited through the Pendle market and converted into the base token. However, during this exit process, the minimum output amount(minOut) that users can specify is currently hardcoded to zero. This creates a risk where, depending on market conditions, the user may receive little or even no base tokens relative to the withdrawn collateral, resulting in substantial losses.
function _onWithdraw( address vault, address position, address dst, uint256 amount ) internal override returns (uint256) { uint256 collateralWithdrawn = ICDPVault(vault).withdraw(address(position), amount); address collateralToken = address(ICDPVault(vault).token()); if (dst != collateralToken && dst != address(0)) { PoolActionParams memory poolActionParams = PoolActionParams({ protocol: Protocol.PENDLE, >> minOut: 0, recipient: address(this), args: abi.encode( collateralToken, collateralWithdrawn, dst // base token ) }); bytes memory exitData = _delegateCall( address(poolAction), abi.encodeWithSelector(poolAction.exit.selector, poolActionParams) ); collateralWithdrawn = abi.decode(exitData, (uint256)); } return collateralWithdrawn; }
Solidity
복사
2024-10-loopfi/src/proxy/PositionActionPendle.sol

Comment

-

loopfi fix

The logic was corrected to receive the slippage value from the user when withdrawing collateral.
- uint256 collateral = _onWithdraw(vault, position, collateralParams.targetToken, collateralParams.amount); + uint256 withdrawnCollateral = _onWithdraw(vault, position, collateralParams.targetToken, collateralParams.amount, collateralParams.minAmountOut);
Diff
복사
loop-contracts/src/proxy/PositionAction.sol: _withdraw#L642
function _onWithdraw( // ... + uint256 minAmountOut ) internal override returns (uint256) { // ... if (dst != collateralToken && dst != address(0)) { PoolActionParams memory poolActionParams = PoolActionParams({ protocol: Protocol.PENDLE, - minOut: 0, + minOut: minAmountOut, recipient: address(this), args: abi.encode( collateralToken, collateralWithdrawn, dst ) }); // ... }
Diff
복사
loop-contracts/src/proxy/PositionActionPendle.sol

M-05. PositionAction.sol: Potential Fund Lockup in onCreditFlashLoan During EXACT_IN Primary Swap

Overview

This vulnerability occurs in the process of handling the residualAmount — the leftover tokens after repaying a flash loan in onCreditFlashLoan.
If the position's remaining debt is smaller than the residualAmount, the unused tokens can become trapped within the contract.

Description

This issue specifically arises when the primarySwap swap type is set to EXACT_IN. If the amount of WETH obtained from collateral removal exceeds the flash loan repayment amount, the excess (residualAmount) is used to further repay any remaining position debt.
In CDPVault, when repaying debt, if the tokens sent exceed the position’s total debt, the debtToDecrease value is adjusted to prevent receiving more tokens than necessary. Thus, if the residualAmount exceeds the remaining debt, unused tokens remain within PositionAction.
However, onCreditFlashLoan lacks any logic to handle these leftover tokens, resulting in user funds becoming permanently locked inside the contract.
bytes memory swapData = _delegateCall( address(swapAction), abi.encodeWithSelector(swapAction.swap.selector, leverParams.primarySwap) ); uint256 swapAmountOut = abi.decode(swapData, (uint256)) uint256 residualAmount = swapAmountOut - subDebt; // sub collateral and debt if (residualAmount > 0) { underlyingToken.forceApprove(address(leverParams.vault), residualAmount); ICDPVault(leverParams.vault).modifyCollateralAndDebt( leverParams.position, address(this), address(this), 0, >> -toInt256(residualAmount) ); }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol: onCreditFlashLoan#L482
else if (deltaDebt < 0) { uint256 debtToDecrease = abs(deltaDebt); uint256 maxRepayment = calcTotalDebt(debtData); if (debtToDecrease >= maxRepayment) { >> debtToDecrease = maxRepayment; deltaDebt = -toInt256(debtToDecrease); } // ... }
Solidity
복사
2024-10-loopfi/src/CDPVault.sol: modifyCollateralAndDebt#L469
The report recommended transferring the residualAmount to the residualRecipient rather than using it to repay the position’s debt.

Comment

Similar to the case in M-02, this highlights the same critical consideration.
In workflows involving complex token movements, it is essential to carefully verify whether tokens are being properly received and transferred, and whether there is a risk of token shortages or leftover tokens remaining unused.

loopfi fix

This issue was resolved together with the patch for the M-02 vulnerability.

M-06. [Bonus Vulnerability!] Incorrect residualAmount Calculation in PositionAction.sol: onCreditFlashLoan Can Trap User Funds

Overview

Discovered during the vulnerability review, this issue — similar to M-05 — can cause residual tokens to remain trapped in the contract, leading to the loss of user funds.
However, unlike M-05, the root cause here is the incorrect calculation of residualAmount itself.

Description

When using decreaseLever, if the swap type is EXACT_IN, the amount specified for the primary swap is not restricted. If the user sets an amount larger than the position’s outstanding debt, onCreditFlashLoan will attempt to borrow that amount through a flash loan to repay the debt.
However, CDPVault adjusts the repayment to match only the actual debt, meaning that the excess (subDebt - totalDebt) remains unused in PositionAction.
The critical issue arises during the calculation of residualAmount. Instead of considering the actual amount of tokens used for debt repayment, the contract incorrectly subtracts the intended transfer amount (subDebt).
As a result, the calculation underestimates the remaining tokens, leaving part of the user’s funds stuck inside PositionAction without proper accounting.
function onCreditFlashLoan( address /*initiator*/, uint256 /*amount*/, uint256 fee, bytes calldata data ) external returns (bytes32) { if (msg.sender != address(flashlender)) revert PositionAction__onCreditFlashLoan__invalidSender(); (LeverParams memory leverParams, uint256 subCollateral, address residualRecipient) = abi.decode( data, (LeverParams, uint256, address) ); uint256 subDebt = leverParams.primarySwap.amount; underlyingToken.forceApprove(address(leverParams.vault), subDebt + fee); // sub collateral and debt ICDPVault(leverParams.vault).modifyCollateralAndDebt( leverParams.position, address(this), // action contract address(this), 0, >> -toInt256(subDebt) ); // withdraw collateral and handle any CDP specific actions uint256 withdrawnCollateral = _onDecreaseLever(leverParams, subCollateral); if (leverParams.primarySwap.swapType == SwapType.EXACT_IN) { leverParams.primarySwap.amount = withdrawnCollateral; bytes memory swapData = _delegateCall( address(swapAction), abi.encodeWithSelector(swapAction.swap.selector, leverParams.primarySwap) ); uint256 swapAmountOut = abi.decode(swapData, (uint256)); >> uint256 residualAmount = swapAmountOut - subDebt; // ... }
Solidity
복사
2024-10-loopfi/src/proxy/PositionAction.sol
else if (deltaDebt < 0) { uint256 debtToDecrease = abs(deltaDebt); uint256 maxRepayment = calcTotalDebt(debtData); if (debtToDecrease >= maxRepayment) { >> debtToDecrease = maxRepayment; deltaDebt = -toInt256(debtToDecrease); } // ... }
Solidity
복사
2024-10-loopfi/src/CDPVault.sol: modifyCollateralAndDebt#L469

Comment

Fixing this vulnerability requires more than just the patches applied for M-02 and M-05.
When the swap type is EXACT_IN, the primarySwap.amount should be capped at the position’s total debt, or the residualAmount calculation should be corrected to subtract the actual amount used(deltaDebt) instead of the intended amount(subDebt).

loopfi fix

By the time this issue was identified, LoopFi had already deployed a patch.
In the updated implementation, when the swap type is EXACT_IN, decreaseLever now borrows only the minimum amount of underlyingToken necessary to satisfy the expected amount received from the primary swap.
flashlender.creditFlashLoan( ICreditFlashBorrower(self), + leverParams.primarySwap.swapType == SwapType.EXACT_IN + ? leverParams.primarySwap.limit + : leverParams.primarySwap.amount, abi.encode(leverParams, subCollateral, residualRecipient) );
Diff
복사
loop-contracts/src/proxy/PositionAction.sol: decreaseLever#L402
Additionally, in onCreditFlashLoan, the subDebt is now set to the smaller of either the limit or the sum of the position’s total debt and the flash loan fee, ensuring that no excess amount remains in PositionAction after repaying the loan and covering the fee.
if (leverParams.primarySwap.swapType == SwapType.EXACT_IN) { + vars.subDebt = min(vars.totalDebt + fee, leverParams.primarySwap.limit); underlyingToken.forceApprove(address(leverParams.vault), vars.subDebt + fee); // Scale to WAD and add back the precision loss uint256 scaledDebt = wdiv(vars.subDebt - fee, poolScale) + vars.totalDebtLoss; ICDPVault(leverParams.vault).modifyCollateralAndDebt( leverParams.position, address(this), address(this), 0, -toInt256(scaledDebt) ); // ... }
Diff
복사
loop-contracts/src/proxy/PositionAction.sol: onCreditFlashLoan#L496

Conclusion

Througout this series, we’ve explored the architecture and vulnerabilities of LoopFi, a leveraged lending protocol.
LoopFi is built with a complex structure that involves accepting various types of collateral tokens and interacting with external protocols such as PendleMarket.
This audit contest highlighted the importance of thoroughly understanding token flows and covering a wide range of edge cases through comprehensive testing. It also served as a reminder that traditional pitfalls like precision loss are still common and should not be overlooked.
The vulnerability discussed in H-02 particularly illustrates how crucial it is for auditors to understand not only the code, but also the protocol’s design logic and economic policies.
78ResearchLab provides practical and actionable security guides based on extensive experience conducting security audits for various DeFi and Web3 project.
Looking to build secure smart contracts?

Reference