Summary
-
DSCEngine.liquidate() calculates seized collateral as the debt covered plus a 10% liquidation bonus.
-
When a position becomes deeply insolvent (collateral value < debt covered + 10% bonus), subtracting this collateral amount from the user's balance underflows.
-
The subtraction reverts under Vyper 0.4.0 arithmetic checks, preventing liquidators from clearing underwater accounts and permanently locking bad debt in the protocol.
Vulnerability Details
Description
In dsc_engine.vy, liquidate() calculates the collateral payout including a 10% bonus:
@external
def liquidate(collateral: address, user: address, debt_to_cover: uint256):
...
token_amount_from_debt_covered: uint256 = self._get_token_amount_from_usd(
collateral, debt_to_cover
)
bonus_collateral: uint256 = (
token_amount_from_debt_covered * LIQUIDATION_BONUS
) // LIQUIDATION_PRECISION
@> self._redeem_collateral(
collateral,
token_amount_from_debt_covered + bonus_collateral,
user,
msg.sender,
)
self._burn_dsc(debt_to_cover, user, msg.sender)
In _redeem_collateral(), the calculated collateral amount is subtracted from the user's recorded balance:
@internal
def _redeem_collateral(
token_collateral_address: address,
amount_collateral: uint256,
_from: address,
_to: address,
):
@> self.user_to_token_address_to_amount_deposited[_from][
token_collateral_address
] -= amount_collateral
extcall IERC20(token_collateral_address).transfer(_to, amount_collateral)
If market prices drop rapidly such that:
The subtraction balance -= amount_collateral underflows. Under Vyper 0.4.0, unsigned integer underflows revert unconditionally.
Risk
Likelihood: High
Impact: High
Severity: High
Proof of Concept
def test_insolvent_position_cannot_be_liquidated(
some_user, liquidator, weth, eth_usd, wbtc, btc_usd, dsc
):
token_addresses = [weth, wbtc]
feed_addresses = [eth_usd, btc_usd]
dsce = dsc_engine.deploy(token_addresses, feed_addresses, dsc)
dsc.set_minter(dsce.address, True)
dsc.transfer_ownership(dsce)
user_collateral = to_wei(1, "ether")
user_mint = to_wei(1000, "ether")
with boa.env.prank(some_user):
weth.mint_amount(user_collateral)
weth.approve(dsce, user_collateral)
dsce.deposit_collateral_and_mint_dsc(weth, user_collateral, user_mint)
eth_usd.updateAnswer(1000 * 10**8)
with boa.env.prank(liquidator):
weth.mint_amount(to_wei(10, "ether"))
weth.approve(dsce, to_wei(10, "ether"))
dsce.deposit_collateral_and_mint_dsc(weth, to_wei(10, "ether"), user_mint)
dsc.approve(dsce, user_mint)
with boa.reverts():
dsce.liquidate(weth, some_user, user_mint)
Explanation: When collateral is insufficient to cover both the debt and the full 10% bonus, liquidate() underflows and reverts, preventing liquidation of insolvent positions.
Recommended Mitigation
Cap the redeemed collateral to the user's available deposited collateral balance:
@external
def liquidate(collateral: address, user: address, debt_to_cover: uint256):
...
token_amount_from_debt_covered: uint256 = self._get_token_amount_from_usd(
collateral, debt_to_cover
)
bonus_collateral: uint256 = (
token_amount_from_debt_covered * LIQUIDATION_BONUS
) // LIQUIDATION_PRECISION
+ user_balance: uint256 = self.user_to_token_address_to_amount_deposited[user][collateral]
+ total_collateral_to_redeem: uint256 = token_amount_from_debt_covered + bonus_collateral
+ if total_collateral_to_redeem > user_balance:
+ total_collateral_to_redeem = user_balance
self._redeem_collateral(
collateral,
- token_amount_from_debt_covered + bonus_collateral,
+ total_collateral_to_redeem,
user,
msg.sender,
)
Explanation: Capping redemption ensures liquidators can claim up to 100% of the debtor's remaining collateral without triggering an underflow revert.