DeFi Liquidation Vulnerabilities
Liquidation code can contain subtle bugs and vulnerabilities..
Prompt and efficient liquidation is crucial to maintaining solvency in DeFi protocols, yet it is among the hardest and most complex code to implement in a safe and especially trustless manner. There are many potential vulnerabilities and bugs which can have disastrous effects on protocol solvency and user trust, resulting in liquidation implementations having high “bug density”. Smart contract developers and auditors should take note of the following vulnerability classes and verify whether their liquidation code is vulnerable.
Two common terms frequently used with their definitions:
Liquidatable :
collateral_value * loan_to_value_ratio < borrow_value
- sufficient collateral remains to cover the borrow
- no bad debt is created if promptly liquidatedInsolvent :
collateral_value < borrow_value
- insufficient collateral remains to cover the borrow
- creates bad debt for the protocol even after liquidation
The goal of liquidation is to avoid bad debt by promptly liquidating “Liquidatable” positions so they do not become “Insolvent”.
No Liquidation Incentive
Many decentralized protocols don’t use trusted liquidators but allow any address to perform “trustless” liquidation. To incentivize prompt and efficient trustless liquidators such protocols offer a liquidation incentive usually in the form of a liquidation “bonus” or “reward”.
Incentivized trustless liquidators (usually in the form of MEV bots) promptly liquidate any liquidatable positions if the liquidation incentive is greater than the gas cost to perform the liquidation, generating small risk-free profits on a consistent basis.
Heuristic: if the protocol relies on trustless liquidators, does it incentivize liquidators via a reward or bonus?
No Incentive To Liquidate Small Positions
If the protocol does not enforce minimum deposits and position sizes, small positions can accumulate which trustless liquidators have no incentive to liquidate. An accumulation of insolvent small positions can theoretically be especially problematic for stablecoin protocols by allowing bad debt to accumulate causing the protocol to become under-collateralized.
In addition to enforcing minimum position size for new positions, for protocols that allow:
modification of an existing position size, the final position size should be checked to not be too small
partial liquidation, a partial liquidation should not result in the remaining debt position being too small
Heuristic: does the protocol enforce minimum position size? Is this enforced in every function which can change position size, or only when opening a new position?
More examples: [1, 2, 3, 4, 5]
Profitable User Withdraws All Collateral Removing Liquidation Incentive
In trading protocols such as perpetuals, users with open long/short positions have their current profit/loss (PNL) counted when determining the total value of their collateral. When a user’s position has a large positive PNL, that user may be able to withdraw most or even all of their deposited collateral while continuing to remain solvent.
If the user’s PNL subsequently declines the position will become liquidatable. However since the user has withdrawn their collateral there is nothing to seize and give as incentive to the liquidator beyond the remaining positive PNL. The lack of collateral may result insufficient liquidation incentive (or even a panic revert when attempting liquidation) causing the position to not be liquidated and subsequently become insolvent.
A simple mitigation is to always ensure that a user with open trading positions must have a minimal amount of collateral deposited regardless of their potentially large positive PNL. Another mitigation may be to “discount” positive PNL such that it doesn’t provide the same “collateral weight” compared to actually deposited collateral.
Allowing users to borrow deposited collateral without limits can also be dangerous as it can lead to the same state.
Heuristic: can a profitable user withdraw their deposited collateral? If yes, what happens if the market reverses and the user becomes unprofitable?
More examples: [1]
No Mechanism To Handle Bad Debt
A liquidatable position if not promptly liquidated can reach an insolvent state where the liquidation reward and seized collateral received for liquidating the position is worth less than the debt tokens required to resolve the bad debt and liquidate the position.
In such a scenario trustless liquidators have no incentive to liquidate the position, allowing bad debt to accrue in the protocol. Protocols may also not account for this state causing the liquidation transaction to panic revert, making liquidation of insolvent positions impossible. This issue can be mitigated by:
operating trusted liquidators which promptly liquidate all liquidatable positions
having an “insurance fund” typically funded via protocol fees which can absorb the bad debt such that trustless liquidators still profit from liquidating normally unprofitable positions
socializing bad debt amongst protocol users such as liquidity providers
Heuristic: does the protocol implement a mechanism to handle bad debt? What happens when an insolvent position gets liquidated?
Partial Liquidation Bypasses Bad Debt Accounting
Consider this liquidation code:
// additional processing when position closed by liquidation
if (!hasPosition) {
int256 remainingMargin = vault.margin;
// credit positive margin to vault recipient
if (remainingMargin > 0) {
if (vault.recipient != address(0)) {
vault.margin = 0;
sentMarginAmount = uint256(remainingMargin);
ERC20(pairStatus.quotePool.token).safeTransfer(
vault.recipient, sentMarginAmount);
}
}
// otherwise ensure liquidator covers bad debt
else if (remainingMargin < 0) {
vault.margin = 0;
// any losses that cannot be covered by the vault
// must be compensated by the liquidator
ERC20(pairStatus.quotePool.token).safeTransferFrom(
msg.sender, address(this), uint256(-remainingMargin));
}
}
If the liquidation transaction was a full liquidation that closed the position, this code ensures that the liquidator covers any bad debt associated with the position. However as this check only occurs if the position was completely liquidated, a partial liquidator can bypass this check by not liquidating the entire position.
This allows a liquidator to bypass the bad debt accounting allowing bad debt to accrue within the protocol. One potential mitigation is to ensure that when partial liquidations are performed on an insolvent position, the corresponding amount of the position’s bad debt is covered by the insurance fund or bad debt socialization mechanism.
Heuristic: is bad debt accounted for during partial liquidation of an insolvent position?
No Partial Liquidation Prevents Whale Liquidation
In protocols where trustless liquidators supply the debt tokens required to resolve a borrower’s bad debt, partial liquidation should be supported to allow liquidators to liquidate portions of large liquidatable positions. If partial liquidation is not supported this makes it impossible to liquidate large liquidatable positions opened by whales since individual liquidators may not have enough tokens to resolve the large debt.
Flash loans can be used to liquidate large positions but only if the loan size does not exceed current market liquidity - which is not guaranteed to always be true.
Heuristic: does the protocol support partial liquidation? If not, how can a whale user be liquidated? Are there other safeguards such as caps on maximum position size? If so, how effective are these additional safeguards at ensuring the largest possible position can be liquidated?
Liquidation Denial Of Service
If an attacker can permanently cause liquidations to revert or prevent themselves from being liquidated, this represents a critical danger to the solvency of many protocols as it allows bad debt to build up in the system. Several known attack paths have been found in audits of real-world protocols:
Attacker Uses Many Small Positions To Prevent Liquidation
Consider the following liquidation code which iterates over all of a user’s current positions:
function _removePosition(uint256 positionId) internal {
address trader = userPositions[positionId].owner;
positionIDs[trader].removeItem(positionId);
}
// @audit called by `_removePosition`
function removeItem(uint256[] storage items, uint256 item) internal {
uint256 index = getItemIndex(items, item);
removeItemByIndex(items, index);
}
// @audit called by `removeItem`
function getItemIndex(uint256[] memory items, uint256 item) internal pure returns (uint256) {
uint256 index = type(uint256).max;
// @audit OOG revert for large items.length
for (uint256 i = 0; i < items.length; i++) {
if (items[i] == item) {
index = i;
break;
}
}
return index;
}
A malicious user could exploit this for loop to make themselves impossible to liquidate by opening many small positions and allowing the last position to become liquidatable; whenever a liquidator attempts to liquidate that last position the liquidation will revert due to out of gas. This issue can be mitigated by:
enforcing a minimum position size to prevent many “dust” positions
using a
mappingor other data structure that prevents iterating over every position
Heuristic: does the protocol iterate over an unbounded list which the users can add items to? Is there a minimum position size enforced?
Attacker Uses Multiple Positions To Prevent Liquidation
In some protocols where a user can have multiple open positions, their health score is considered collectively across all positions to determine whether that user is subject to liquidation. In these protocols when liquidation occurs all of a user’s open positions are liquidated in the same transaction.
Consider the following code:
// load open markets for account being liquidated
ctx.amountOfOpenPositions = tradingAccount.activeMarketsIds.length();
// iterate through open markets
for (uint256 j = 0; j < ctx.amountOfOpenPositions; j++) {
// load current active market id into working data
// @audit assumes constant ordering of active markets
ctx.marketId = tradingAccount.activeMarketsIds.at(j).toUint128();
// snip - a bunch of liquidation processing code //
// remove this active market from the account
// @audit this calls `EnumerableSet::remove` which changes the order of `activeMarketIds`
tradingAccount.updateActiveMarkets(ctx.marketId, ctx.oldPositionSizeX18, SD_ZERO);
Because EnumerableSet provides no guarantees that the order of elements is preserved and its remove function uses the swap-and-pop method for performance reasons, the ordering of a user’s active markets will be corrupted when an active market is removed if that active market is not the last active market for that user.
A malicious user can weaponize this to make their account impossible to liquidate by opening multiple positions and triggering this corruption, causing any liquidation attempt to revert with panic: array out-of-bounds access.
A simple way to prevent this issue is to iterate over a memory copy of activeMarketIds by calling EnumerableSet::values instead of iterating over the storage directly.
Heuristic: can a user with multiple open positions be liquidated? Does the test suite contain a test for this scenario?
Attacker Uses Front-Run To Prevent Liquidation
Can a liquidatable user change variables used during the liquidation transaction such that this change would cause liquidation to revert? If so a user can make themselves impossible to liquidate by front-running any liquidation transaction to make the required changes and hence cause that transaction to subsequently revert. Some examples include blocking liquidation by:
partial (very small) self-liquidation which prevents liquidation in the same block or for a longer period by resetting a cool-down
Heuristic: is there any user-controlled variable that makes liquidation revert? If so, can a liquidatable user front-run the liquidation transaction to change this variable forcing liquidation to revert? What actions can a liquidatable user perform, and should they be able to perform those actions?
More examples: [1, 2, 3, 4, 5, 6]
Attacker Uses Pending Action To Prevent Liquidation
Consider this check during liquidation:
require(balance - (withdrawalPendingAmount + depositPendingAmount) > 0);
A malicious user can abuse this by creating a pending withdrawal equal to their balance which forces all subsequent liquidation attempts to revert, making themselves impossible to liquidate. A simple mitigation is to prevent users who are subject to liquidation from performing many protocol functions such as deposits, withdrawals and swaps, though this still leaves an edge case when an innocent user had a pending withdrawal then became subject to liquidation.
An attacker may also be able to use protocol functions while being in a liquidatable state to profit from the subsequent liquidation - protocols should carefully evaluate which actions if any a liquidatable user can perform.
Heuristic: are there any actions which take several transactions over multiple blocks to complete? If so, what happens when a user is liquidated while these actions are in a “pending” state? What actions can a liquidatable user perform, and should they be able to perform those actions?
Attacker Uses Malicious onERC721Received Callback To Prevent Liquidation
If an NFT ERC721 token is “pushed” to an attacker-controlled address during liquidation, the attacker can configure their deployed contract at that address to revert in the onERC721Received callback function making it impossible for them to be liquidated.
A simple mitigation is to implement a “pull” function by which ERC721 token owners can retrieve their NFT in a separate transaction.
The same attack can occur if an ERC20 token used for liquidation settlement contains transfer hooks.
Heuristic: if liquidation uses “push” to send tokens, can an attacker leverage callbacks to force the liquidation to revert?
Attacker Uses Yield Vault To Prevent Collateral Seizure During Liquidation
Multi-collateral protocols may allow users to deposit their collateral into vaults or farms which generate yield to achieve maximum capital efficiency for users’ deposited collateral. Such protocols must ensure they correctly account for collateral deposited into yield vaults and generated yield when:
calculating minimum collateral required to avoid liquidation
seizing collateral and generated yield during liquidation
If the first is implemented but not the second, an attacker can drain the protocol by:
taking a loan against their deposited collateral
allowing the loan to be liquidated
withdrawing their collateral & yield from the vault/farm
Smart contract auditors should carefully check that all instruments which can be used as collateral are accounted for during liquidation and that any other contracts where collateral can be deposited or registered are notified of the liquidation.
Heuristic: are there any features which allow users to do interesting things such as earning yield with their deposited collateral? If so, is the liquidation code aware of and integrated with the additional features? Can a user “hide” their deposited collateral anywhere the liquidation code is not aware of?
More examples: [1]
Liquidation Reverts When Bad Debt Greater Than Insurance Fund
For protocols that use an insurance fund to cover bad debt, liquidation will revert if the bad debt is larger than the insurance fund unless the protocol has special handling for this edge-case. Such protocols can enter into and indefinitely remain in a state where large insolvent positions are unable to be liquidated until the insurance fund accrues enough fees to cover the bad debt.
Heuristic: what happens when the bad debt from liquidating an insolvent position is greater than the amount in the insurance fund?
More examples: [1]
Liquidation Reverts From Insufficient Funds Due To Fixed Liquidation Bonus
Consider this code which aims to always provide a fixed 10% liquidation bonus in the form of additional seized collateral to the liquidator:
uint256 tokenAmountFromDebtCovered = getTokenAmountFromUsd(collateral, debtToCover);
// liquidator always receives 10% bonus
uint256 bonusCollateral = (tokenAmountFromDebtCovered * LIQUIDATION_BONUS) / LIQUIDATION_PRECISION;
_redeemCollateral(collateral, tokenAmountFromDebtCovered + bonusCollateral, user, msg.sender);
The fixed liquidation bonus causes liquidation to revert when the user has a < 110% collateral ratio since there is insufficient collateral to pay the bonus, even though at < 110% the user in that protocol was considered under-collateralized and subject to liquidation. A simple mitigation is to check whether the borrower has sufficient collateral to provide the bonus and if not then cap the bonus to the maximum possible amount.
Heuristic: what happens if there is not enough collateral to cover the liquidation bonus?
Liquidation Reverts For Non-18 Decimal Collateral
Multi-collateral protocols can support a wide range of collateral some of which don’t use standard ERC20 18 decimal precision. Protocols usually employ a strategy which uses:
18 decimals for all internal calculation and storage
native token decimals when transferring tokens
native token decimals in external function inputs that users call
This strategy works well when consistently used however in large protocols with multiple developers an inconsistency can easily slip in; auditors should always verify that liquidation works correctly when either the collateral token or the debt token does not have 18 decimal places.
Heuristic: does liquidation work correctly when tokens use different decimal precision?
Liquidation Reverts Due To Multiple nonReentrancy Modifiers
In larger protocols liquidation code can be quite complicated involving optional calls to multiple other contracts. Smart contract auditors should carefully verify that there is no execution path through which two functions with the nonReentrant modifier are called on the same contract, causing liquidation to revert.
Heuristic: are there any liquidation execution paths that would hit multiple nonReentrant modifiers in the same contract?
Liquidation Reverts From Zero Value Token Transfers
Liquidation code usually involves calculating multiple token amounts such as the liquidator reward and associated fees followed by multiple token transfers. If there is no zero value checks prior to token transfers this can cause liquidation to revert with tokens that revert on zero value transfer.
Heuristic: does the protocol do zero value checks before token transfers? If not, does it support tokens which revert on zero token transfer?
More examples: [1]
Liquidation Reverts From Token Deny List
Some tokens such as USDC implement “deny lists” which allow token admins to freeze user funds, causing all transfer attempts to revert for addresses on the deny list. Many liquidation implementations use “push” mechanisms which send token amounts to different addresses during the liquidation transaction. If the protocol supports tokens which use deny lists and any of the addresses sent tokens during liquidation are on the deny list, then liquidation will revert due to deny list making it impossible to liquidate that position.
As the risk is low many protocols choose to simply acknowledge this risk, but one slightly more complicated mitigation is allowing users to claim tokens (“pull”) instead of sending them out (“push”).
Heuristic: does the protocol use “push” during liquidation and support tokens with deny lists? If so, what happens during liquidation when sending tokens to blocked users?
Impossible To Liquidate When Only One Borrower
Consider this liquidation logic:
// get number of borrowers
uint256 troveCount = troveManager.getTroveOwnersCount();
// only process liquidations when more than 1 borrower
while (trovesRemaining > 0 && troveCount > 1) {
This code fails to liquidate if there is only one borrower. This is a design flaw as even if there is only one borrower, the borrower should still be subject to liquidation if their position becomes liquidatable.
Heuristic: if there is only one borrower, can that user be liquidated?
More examples: [1]
Incorrect Liquidation Calculations
During a liquidation there are many required calculations such as the value of collateral, the amount of bad debt, calculation of liquidator rewards and fees. Subtle errors can slip into these calculations with disastrous effects:
Incorrect Calculation Of Liquidator Reward
Liquidation can often involve dealing with debt and collateral tokens which use different decimal precisions. Errors when handling precision differences between debt and collateral tokens can result in the calculated liquidation reward being:
too small so there will be no incentive to liquidate
too large so liquidators will receive much more than they should
Consider this simplified code:
function executeLiquidate(State storage state, LiquidateParams calldata params)
external returns (uint256 liquidatorProfitCollateralToken) {
// @audit debtPosition = USDC using 6 decimals
DebtPosition storage debtPosition = state.getDebtPosition(params.debtPositionId);
// @audit assignedCollateral = WETH using 18 decimals
uint256 assignedCollateral = state.getDebtPositionAssignedCollateral(debtPosition);
// @audit debtPosition.futureValue = USDC using 6 decimals
// debtInCollateralToken = WETH using 18 decimals
uint256 debtInCollateralToken = state.debtTokenAmountToCollateralTokenAmount(debtPosition.futureValue);
if (assignedCollateral > debtInCollateralToken) {
uint256 liquidatorReward = Math.min(
assignedCollateral - debtInCollateralToken,
// @audit liquidatorReward calculated using debtPosition.futureValue using
// 6 decimals instead of debtInCollateralToken using 18 decimals, even though
// liquidation reward is paid in WETH collateral which uses 18 decimals
Math.mulDivUp(debtPosition.futureValue, state.feeConfig.liquidationRewardPercent, PERCENT)
// @audit should be:
// Math.mulDivUp(debtInCollateralToken, ...)
);
This liquidation functions pays out the liquidation reward using the collateral token (WETH with 18 decimals), but it is calculating the liquidation reward paid out using the debt token position (USDC with 6 decimals). Hence liquidators won’t be incentivized as their expected reward will be dramatically reduced.
The liquidation reward should scale linearly such that if the total borrow amount is identical, borrowing against 3 lenders using one account should result in the liquidation reward being roughly the same as borrowing against 3 lenders using 3 individual accounts.
Incorrect calculation of liquidation reward can occur due to many different errors in the reward calculation; there is no clear heuristic so auditors need to carefully examine the particular implementation for all sorts of errors. More examples: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Failure To Prioritize Liquidation Reward
During liquidation there may be a number of associated fees that need to be paid to different entities. If there is insufficient collateral (or insurance fund in the case of bad debt) to pay out all the fees, the protocol should prioritize paying the liquidation reward in order to incentivize prompt liquidation.
Heuristic: what happens if there is not enough collateral to pay out all the fees as part of a liquidation? Is the liquidator reward prioritized - especially in protocols relying on trustless liquidators?
Incorrect Calculation Of Protocol Liquidation Fee
Some protocols charge a “protocol fee” which the liquidator or user being liquidated pays during a liquidation. If this fee is incorrectly calculated to be larger than it should be, this can make many liquidations unprofitable which removes the incentive to liquidate causing bad debt to accrue in the protocol. Consider this protocol liquidation fee calculation code:
function _transferAssetsToLiquidator(address position, AssetData[] calldata assetData) internal {
// transfer position assets to the liquidator and accrue protocol liquidation fees
uint256 assetDataLength = assetData.length;
for (uint256 i; i < assetDataLength; ++i) {
// ensure assetData[i] is in the position asset list
if (Position(payable(position)).hasAsset(assetData[i].asset) == false) {
revert PositionManager_SeizeInvalidAsset(position, assetData[i].asset);
}
// compute fee amt
// [ROUND] liquidation fee is rounded down, in favor of the liquidator
// @audit liquidator fee calculated from seized collateral amount
// makes many liquidations unprofitable
uint256 fee = liquidationFee.mulDiv(assetData[i].amt, 1e18);
// transfer fee amt to protocol
Position(payable(position)).transfer(owner(), assetData[i].asset, fee);
// transfer difference to the liquidator
Position(payable(position)).transfer(msg.sender, assetData[i].asset, assetData[i].amt - fee);
}
}
Here the protocol liquidation fee is calculated as a percentage of the total amount of seized collateral which makes many liquidations unprofitable; in this case the protocol liquidation fee was 30% of the seized collateral significantly removing incentives to liquidate many liquidatable positions. Possible mitigations include:
having no protocol liquidation fee or having a small flat fee
calculating the protocol liquidation fee as a percentage of the liquidator’s profit, not the raw seized collateral amount
The protocol liquidation fee calculation can also be incorrectly implemented the other way where a liquidator pays less than required to liquidate a liquidatable position.
Heuristic: is the protocol liquidation fee calculated as a percentage of the liquidator’s profit? If not, can the protocol fee make liquidation unprofitable disincentivizing liquidators?
More examples: [1, 2, 3, 4, 5]
Liquidation Fees Not Counted In Minimum Collateral Requirement
When opening a new position or calculating if a position is solvent, the minimum collateral requirement calculation should include liquidation fees if that position would subsequently be liquidated.
If the liquidation fee isn’t included in the minimum collateral requirement calculation then sufficient collateral may not be exist when the position is liquidated and the protocol may revert upon liquidation or incur bad debt in the form of any liquidation fees. This is debatable however and some protocols may choose not to implement this as being unfair to the user.
Heuristic: are liquidation fees included in minimum collateral requirements? If not, has the protocol explicitly documented the reason?
Unfair Liquidation As Earned Yield Not Added To Collateral Value
Some protocols support mechanisms for deposited collateral to earn yield in order to maximize capital efficiency. When valuing a user’s collateral if the earned yield is not factored into the total collateral value the user can be unfairly liquidated.
Heuristic: is earned yield factored into a user’s total collateral value? If not, can the user be unfairly liquidated? If yes, what happens to the earned yield - is it lost?
Unfair Liquidation As Positive PNL Not Added To Collateral Value
Liquidation mechanisms in trading protocols should consider the current profitability of an open leveraged position when calculating the total value of a trader’s collateral:
if a position has negative PNL, the negative PNL should be deducted from the collateral value causing liquidation to occur sooner - this should always be implemented in all protocols
if a position has positive PNL, the positive PNL should be added to the collateral value delaying liquidation - some protocols may have valid reasons for not doing this though this should be explicitly documented for users
Leveraged trading protocols which don’t consider open PNL during liquidation can unfairly liquidate traders with large positive PNL.
Heuristic: is open user PNL accounted for when determining if a user can be liquidated? If not, can the user be unfairly liquidated? If yes, what happens to the open PNL - is it lost?
Unfair Liquidation After L2 Sequencer Grace Period
Chainlink’s official documentation recommends implementing a grace period after an L2 sequencer has come back online and only fetching price data after that grace period has expired. If transactions such as depositing additional collateral are prevented during this grace period, then users may be immediately unfairly liquidated once the grace period expires and fresh price data starts being fetched.
Protocols should consider whether to allow depositing additional collateral during the grace period in order to allow users to protect their open positions once the L2 sequencer has come back online but before the grace period has expired and fresh price data becomes available.
Heuristic: once the L2 sequencer comes back online, can users deposit additional collateral during the grace period before price data resumes? Does the protocol implement a grace period to allow this, or will it liquidate users immediately after the L2 sequencer comes back online?
Unfair Liquidation As Borrow Interest Accumulates While Paused
If the protocol supports pausing and the user is prevented from repaying their loan while the protocol is paused, then borrow interest should not accumulate during the paused period otherwise the user can be instantly unfairly liquidated when the protocol is unpaused due to the interest build-up while paused.
Heuristic: does borrow interest accumulate while the protocol is paused and users are unable to repay?
Unfair Liquidation As Repayment Paused While Liquidations Enabled
Protocols ideally shouldn’t be able to enter a state where repayments are paused but liquidations are enabled, as this will result in unfair liquidations for borrowers who wanted to repay but were prevented from doing so by the protocol admin. Ideally there should also be a grace period after liquidations are unpaused to allow repayments and collateral deposits for users who became liquidatable while the protocol was paused.
Heuristic: can the protocol enter a state where users are unable to repay but subject to liquidation? After unpausing is there a grace period or are users who became liquidatable during the pause period immediately liquidated?
More examples: [1, 2, 3, 4, 5, 6]
Late Liquidation As isLiquidatable Doesn’t Refresh Interest / Funding Fees
Whenever a protocol checks that a user is liquidatable, it must always first refresh any fees such as total interest owed on a loan or total funding fees owed on a leveraged trading position, before determining whether a user is liquidatable.
Smart contract auditors should be especially attentive to view functions that don’t change state but need to calculate the latest owed fees prior to determining whether an account is liquidatable. When liquidation occurs all of these fees also need to be updated prior to the user being liquidated.
Heuristic: does the protocol always refresh all interest, yield, funding fees, PNL etc before determining whether a user is liquidatable?
Positive PNL, Yield & Rewards Lost When Account Liquidated
In leveraged trading protocols the following interesting edge case can occur:
trader deposits collateral $C and uses it to open a long leveraged trading position on asset $A
the market value of $A increases such that the trader has significant positive unrealized profit
the market value of $C decreases even more such that even though the trader is in profit on their trade, their overall position is liquidatable
alternatively borrow / funding fees accumulate such that the total owed fees is greater than the position’s profit and the overall position becomes liquidatable
When this edge-case occurs the trader’s positive PNL should be credited to the account during liquidation otherwise it will be lost. The same is true for yield and other rewards that can be earned by a borrower/trader; all rewards should be accumulated prior to liquidation.
Heuristic: is all open profit such as yield, rewards and positive PNL realized prior to liquidation and factored into the liquidation calculations? If not, is it lost after liquidation?
No Swap Fee Charged On Liquidation
Protocols may implement swap fees when swapping internally from one asset to another. If internal swap fees are implemented, they may also need to be charged when a liquidator provides debt tokens in order to receive seized collateral tokens as part of the liquidation. Failing to charge swap fees during liquidation leads to the protocol and potentially the insurance fund accruing less tokens than it should.
Heuristic: does the protocol generally charge swap fees and also perform swaps during liquidation? If yes, does it charge a swap fee during liquidation? If no, does it explicitly document the reason for this discrepancy?
Profitable Self-Liquidation Using Oracle Update Sandwich
An attacker can exploit a user-triggered oracle update for profitable self-liquidation by using an attack contract to:
flash-loan a large amount of collateral tokens
deposit the collateral and borrow the maximum amount of debt tokens (max leverage)
trigger the oracle price update
liquidate themselves
The attack is profitable when the oracle price update results in the entire collateral balance being recovered while repaying fewer debt tokens than were borrowed. Simple mitigations which help to reduce the profitability of this attack are:
charging borrow and liquidation fees
implementing a cool-off period during which an account can’t be liquidated
More advanced mitigations involve restricting leverage for volatile collateral assets and choosing oracles with smaller price deviation updates that can’t be user-triggered.
Self-liquidation can be a dangerous attack vector especially when users can cause themselves to become liquidatable.
Heuristic: Can a user make themselves liquidatable and self-liquidate? Can a user weaponize oracle price updates to extract value from the protocol?
Healthy Borrower Can Be Liquidated
Liquidation should only be allowed for borrowers that are liquidatable, and this check should be done prior to any actions which occur as part of liquidation such as debt repayment and collateral transfer. If this check is performed after debt repayment and collateral transfer, due to the liquidation bonus liquidators can over-remove collateral relative to repaid debt, forcing healthy positions into an under-collateralized liquidatable state.
Liquidation Leaves Borrower With Lower Health Score
Liquidation (whether full or partial) should always leave the borrower being liquidated in a “healthier” state where after liquidation they are less likely to be liquidated in the future. But in advanced protocols which support multiple collateral types and partial liquidation, subtle bugs can exist which leave borrowers in an unhealthier position post-liquidation, making it more likely for them to be liquidated in the future.
Consider this liquidate function:
function liquidatePartiallyFromTokens(
uint256 _nftId,
uint256 _nftIdLiquidator,
address _paybackToken,
address _receiveToken, //@audit liquidator can choose collateral
uint256 _shareAmountToPay
)
This liquidate function allows the liquidator to choose which collateral to seize and receive in compensation for the liquidation. Why is this dangerous? Because different collateral have different:
borrowing factors which enable users to borrow more or less against a particular collateral
risk profiles as some collateral can be very stable (USDC) while other collateral can be much more volatile (ETH and especially speculative ERC20 tokens)
A liquidator can abuse this feature by choosing to first liquidate a user’s more stable, higher borrowing factor collateral. After liquidation this leaves the user with a less healthier collateral basket as:
their remaining collateral is more volatile in regards to price movement
they have a reduced borrowing factor since they are left with riskier more volatile collateral which has reduced borrowing factor
Both of these outcomes make it more likely the user will be liquidated in the future and may subject to user to cascading liquidation where the first liquidation transaction makes them immediately subject to a second liquidation and so on, as liquidation leaves the trader with an unhealthier and riskier collateral basket.
One potential mitigation is during the liquidation transaction:
calculate the borrower’s health score before and after liquidation
revert if the “after” health score <= the “before” health score
Two simple health score implementations are:
collateral_value / borrow_value(collateral : debt ratio)collateral_value * loan_to_value_ratio / borrow_value(borrowing power)
Heuristic: can liquidation leave a borrower in an unhealthier state? Can the liquidator choose which collateral to seize, leaving the borrower with a more volatile collateral basket with reduced borrowing factor?
Corruption Of Collateral Priority Order
In protocols which support multiple collaterals, another mitigation for the previous vulnerability is to enforce a collateral priority order for liquidation where the riskier more volatile collateral is liquidated first. However care must be taken when implementing functions which change the collateral priority order to prevent corrupting the collateral priority order resulting in incorrect order of collateral liquidation.
Heuristic: can the collateral liquidation priority order become corrupt by functions which change it?
Borrower Replacement Results In Incorrect Repayment Attribution
Some protocols support an optional “replacement” liquidation technique where a liquidatable position can be used to “fill” an order from an order-book, effectively replacing an unhealthy borrower with a healthy borrower.
Other protocols allow users to “buy” a liquidatable position effectively taking it over as long as they have enough collateral deposited to make the position solvent. This achieves the same effective end state which is the transfer of a debt position from the original unhealthy borrower to a different healthy borrower.
In both cases the borrower’s address is changed to the new borrower but most other fields including the id and debt of the position remain the same. Consider what can happen when the following two transactions are concurrently initiated:
TX1 - the liquidatable original borrower attempts to repay their liquidatable position passing as input their position’s
idTX2 - a replacement liquidation transaction attempts to transfer the liquidatable position to a healthier borrower
If TX2 is executed before TX1 the liquidatable position is acquired by the new borrower prior to the original borrower’s repayment transaction being executed - the original borrower effectively repays someone else’s debt! In protocols where users can “buy” liquidatable positions an MEV attacker could weaponize this to front-run repayment transactions, buying the liquidatable positions then having the original borrower repay the debt on the position they just acquired!
One potential mitigation is to have repayment transactions specify the borrower’s address as part of the input and revert if the current borrower’s address does not match.
Heuristic: can a liquidatable position be transferred from an unhealthy user to a healthy user? If so, what happens if the unhealthy user attempts to repay at the same time as the transfer?
More examples: [1]
No Gap Between Borrow And Liquidation Loan To Value Ratio
Most protocols require a lower Loan To Value (LTV) ratio to open a new borrow but use a higher LTV ratio for determining whether a position is liquidatable; this design aims to prevent borrowers from being liquidated very soon after opening a new borrow.
If there is no gap between the borrow and liquidation LTV ratio then borrowers can open new positions on the edge of liquidation which increases the likelihood of subsequent liquidations, threatening the stability of the protocol and creating a poor user experience.
Heuristic: can a user become liquidatable very soon after opening a new position? What about after modifying an existing position?
Borrower Accrues Interest While Liquidation Auction Running
Some protocols use an “auction” mechanism where a liquidatable position is put up for auction over a period of time. In such cases interest payments on debt should be paused as soon as debt is put up for auction; positions being liquidated shouldn’t continue accruing additional interest during the liquidation auction process.
Heuristic: does a borrower continue to accrue interest while their debt is being auctioned?
No Slippage For Liquidation & Swaps
Ideally when performing a liquidation the liquidator should be able to specify the minimum amount of rewards (in the form of tokens, shares or other instruments) they are willing to receive. This is especially important for protocols which perform swaps during liquidation where those swaps could be exploited via MEV causing the liquidator to receive less rewards than they expected to receive.
Heuristic: can the liquidator specify a slippage parameter? If swaps occur during the liquidation, can that result in the liquidator (or the protocol or the user being liquidated) receiving less tokens than expected?