Algo Ssstablecoinsss

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

oracle_lib returns the Chainlink answer without checking price > 0, so a fresh zero answer values that collateral at $0: healthy mixed-collateral positions are liquidated for the 10% bonus and single-asset positions become unliquidatable

Root + Impact

Description

  • All engine prices come from _stale_check_latest_round_data. The library rejects answers it cannot trust for staleness; its NatSpec prefers freezing the engine to running on stale data. The returned price feeds every collateral valuation (_get_usd_value) and every liquidation seizure (_get_token_amount_from_usd).

  • The function validates only round freshness and returns price unchecked (oracle_lib.vy:44-50). Nothing checks price > 0.

    • A negative answer reverts later, in the engine's convert(price, uint256).

    • A fresh answer of exactly 0 passes. _get_usd_value then values that collateral at 0 (dsc_engine.vy:314-316), while _get_token_amount_from_usd divides by zero and reverts (dsc_engine.vy:360-364).

    Instead of freezing, the engine keeps operating with one collateral priced at $0.

# src/oracle_lib.vy:44-50
assert updated_at != 0, "DSCEngine_StalePrice"
assert answered_in_round >= round_id, "DSCEngine_StalePrice"
​
seconds_since: uint256 = block.timestamp - updated_at
assert seconds_since <= TIMEOUT, "DSCEngine_StalePrice"
​
@> return (round_id, price, started_at, updated_at, answered_in_round)
​
# src/dsc_engine.vy:314-316 (_get_usd_value) - price 0 gives value 0, no revert
return (
@> (convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
) // PRECISION

Risk

Likelihood:

  • This occurs when a configured Chainlink feed reports a fresh answer of 0, through a feed malfunction or a misconfigured aggregator. That is rare. Whether the deployed ZKsync ETH/USD and BTC/USD aggregators can report 0 cannot be verified from the repo.

  • Once a zero answer is live, any liquidator can act on it without special access.

Impact:

  • Wrongful liquidation of a solvent position. A user holds 0.75 Y ($1,500) + 0.01 X ($1,000) against 1,000 DSC: HF 1.25 at real prices, and liquidating them reverts DSCEngine__HealthFactorOk. When feed X reads 0, their HF drops to 0.75. liquidate(Y, victim, 1,000 DSC) then succeeds: the liquidator receives 0.55 Y ($1,100) and the victim loses $100 of equity, the 10% bonus. This needs the healthy leg alone to be worth more than 110% of the debt being covered.

  • Stuck positions. A user holding only X gets HF 0 but cannot be liquidated at all: through X the call reverts on division by zero, and through Y it reverts on underflow. Bad positions build up while the rest of the engine keeps running.

Proof of Concept

The PoC deploys the real dsc_engine.vy, decentralized_stable_coin.vy and a standalone oracle_lib.vy, so the external stale_check_latest_round_data can be called directly. It uses the repo mocks, with both tokens 18-decimal. The zero answer is fresh (updated_at = block timestamp, answered_in_round = round_id), so every existing check passes. The circuit-breaker (minAnswer clamp) variant depends on the live aggregator's behaviour and is not claimed here.

Run from the repo root: .venv/Scripts/python poc/poc_AS-06.py

"""
PoC AS-06: oracle_lib._stale_check_latest_round_data never checks price > 0.
​
oracle_lib checks only updated_at != 0, answered_in_round >= round_id and the
72h age, then returns `price` unchecked. A fresh answer of exactly 0:
- passes every staleness check,
- converts cleanly in convert(price, uint256) (only negatives revert),
- makes _get_usd_value value that collateral at 0,
- makes _get_token_amount_from_usd divide by zero (revert).
​
Consequences shown here, with the real in-scope contracts:
1. a mixed-collateral user who is healthy at real prices (HF 1.25) becomes
liquidatable (HF 0.75) and a liquidator seizes the healthy collateral plus
the 10% bonus;
2. a single-asset user in the zero-priced token cannot be liquidated at all
(division by zero / underflow) - the engine neither freezes nor works.
​
Both collateral tokens are 18-decimal (src/mocks/mock_token.vy) so the
separate decimals finding (AS-01) does not interfere. Feeds are the repo's
MockV3Aggregator (8 decimals, like Chainlink USD feeds).
​
The circuit-breaker (minAnswer clamp) limb depends on how the deployed
Chainlink aggregator behaves and is NOT exercised here.
​
Run (from the repo root): .venv/Scripts/python poc/poc_AS-06.py
"""
import subprocess
import sys
​
# Windows non-UTF-8 locales (e.g. gbk) cannot read snekmate's UTF-8 sources with
# the default open() codec; re-run this script once in Python UTF-8 mode.
if not sys.flags.utf8_mode:
sys.exit(subprocess.call([sys.executable, "-X", "utf8", *sys.argv]))
​
import boa
​
ENGINE = "src/dsc_engine.vy"
ORACLE_LIB = "src/oracle_lib.vy"
DSC_SRC = "src/decentralized_stable_coin.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy" # 18 decimals
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
​
E18 = 10**18
Y_PRICE = 2_000 * 10**8 # token Y ("WETH"), $2,000
X_PRICE = 100_000 * 10**8 # token X ("BTC-like", 18-dec here), $100,000
TIMEOUT = 72 * 3600
​
​
def usd(x_wei):
return x_wei / E18
​
​
# ------------------------------------------------------------------ deploy
dsc = boa.load(DSC_SRC)
tok_x = boa.load(MOCK_TOKEN)
tok_y = boa.load(MOCK_TOKEN)
feed_x = boa.load(MOCK_FEED, 8, X_PRICE)
feed_y = boa.load(MOCK_FEED, 8, Y_PRICE)
engine = boa.load(
ENGINE, [tok_x.address, tok_y.address], [feed_x.address, feed_y.address], dsc.address
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
oracle = boa.load(ORACLE_LIB) # standalone copy, to call stale_check_latest_round_data directly
​
assert tok_x.decimals() == 18 and tok_y.decimals() == 18
assert engine.get_usd_value(tok_x.address, E18) == 100_000 * E18
assert engine.get_usd_value(tok_y.address, E18) == 2_000 * E18
​
​
def fund_and_deposit(who, token, amount):
with boa.env.prank(who):
token.mint_amount(amount)
token.approve(engine.address, amount)
engine.deposit_collateral(token.address, amount)
​
​
# ------------------------------------------------------------------ 0. baseline: what the oracle check DOES catch
print("== 0. baseline: oracle_lib checks ==")
probe = boa.load(MOCK_FEED, 8, X_PRICE)
# stale answer -> reverts (the by-design freeze)
boa.env.time_travel(seconds=TIMEOUT + 1)
with boa.reverts("DSCEngine_StalePrice"):
oracle.stale_check_latest_round_data(probe.address)
print("stale answer (age > 72h) -> reverts DSCEngine_StalePrice")
# negative answer -> reverts, but only at convert() in the engine, not in oracle_lib
probe.updateAnswer(-1)
assert oracle.stale_check_latest_round_data(probe.address)[1] == -1 # oracle_lib itself accepts it
feed_x.updateAnswer(-1)
with boa.reverts(): # convert(int256 -> uint256) clamps negatives
engine.get_usd_value(tok_x.address, E18)
print("negative answer (-1), fresh -> oracle_lib returns it unchecked; engine convert() reverts")
# zero answer -> accepted, fresh
probe.updateAnswer(0)
rd = oracle.stale_check_latest_round_data(probe.address)
print(f"zero answer, fresh -> oracle_lib returns {rd} (NO revert)")
assert rd[1] == 0
assert rd[3] == boa.env.evm.patch.timestamp # fresh: updated now
# the engine's own feeds were updated before the time travel; refresh them at real prices
feed_x.updateAnswer(X_PRICE)
feed_y.updateAnswer(Y_PRICE)
​
# ------------------------------------------------------------------ 1. positions at real prices
print("== 1. positions at real prices ==")
victim = boa.env.generate_address("victim") # mixed collateral
x_only = boa.env.generate_address("x_only") # single asset in X
liq = boa.env.generate_address("liquidator")
​
# victim: 0.75 Y ($1,500) + 0.01 X ($1,000) = $2,500 collateral, 1,000 DSC debt -> HF 1.25
fund_and_deposit(victim, tok_y, 75 * E18 // 100)
fund_and_deposit(victim, tok_x, E18 // 100)
with boa.env.prank(victim):
engine.mint_dsc(1_000 * E18)
hf_victim_real = engine.health_factor(victim)
print(f"victim: 0.75 Y + 0.01 X = ${usd(engine.get_account_collateral_value(victim)):,.0f} collateral, 1,000 DSC, HF={hf_victim_real / E18}")
assert hf_victim_real == 125 * E18 // 100
​
# x_only: 0.02 X ($2,000), 500 DSC debt -> HF 2.0
fund_and_deposit(x_only, tok_x, 2 * E18 // 100)
with boa.env.prank(x_only):
engine.mint_dsc(500 * E18)
assert engine.health_factor(x_only) == 2 * E18
print(f"x_only: 0.02 X = $2,000 collateral, 500 DSC, HF={engine.health_factor(x_only) / E18}")
​
# liquidator: 10 Y ($20,000), mints 1,000 DSC to repay with
fund_and_deposit(liq, tok_y, 10 * E18)
with boa.env.prank(liq):
engine.mint_dsc(1_000 * E18)
dsc.approve(engine.address, 1_000 * E18)
​
# baseline: at real prices the victim cannot be liquidated
with boa.env.prank(liq):
with boa.reverts("DSCEngine__HealthFactorOk"):
engine.liquidate(tok_y.address, victim, 1_000 * E18)
print("baseline: liquidate(Y, victim, 1000 DSC) at real prices -> reverts DSCEngine__HealthFactorOk")
​
# ------------------------------------------------------------------ 2. feed X reports a fresh 0
print("== 2. feed X reports a fresh answer of 0 ==")
feed_x.updateAnswer(0)
rid, ans, started, updated, air = feed_x.latestRoundData()
assert ans == 0 and updated == boa.env.evm.patch.timestamp and air >= rid
assert engine.get_usd_value(tok_x.address, E18) == 0 # no revert, valued at 0
print(f"get_usd_value(X, 1e18) = {engine.get_usd_value(tok_x.address, E18)} (no revert; staleness checks all pass)")
with boa.reverts(): # Vyper 0.4.0 safe_div: division by zero reverts
engine.get_token_amount_from_usd(tok_x.address, 1_000 * E18)
print("get_token_amount_from_usd(X, $1000) -> reverts (division by zero)")
​
hf_victim_zero = engine.health_factor(victim)
print(f"victim HF now {hf_victim_zero / E18} (X leg counts $0; collateral ${usd(engine.get_account_collateral_value(victim)):,.0f})")
assert hf_victim_zero == 75 * E18 // 100
assert hf_victim_zero < E18
​
# ------------------------------------------------------------------ 3. wrongful liquidation of the healthy mixed position
print("== 3. liquidator seizes Y + 10% bonus from a position healthy at real prices ==")
liq_y_before = tok_y.balanceOf(liq)
victim_y_before = engine.get_collateral_balance_of_user(victim, tok_y.address)
with boa.env.prank(liq):
engine.liquidate(tok_y.address, victim, 1_000 * E18)
seized = tok_y.balanceOf(liq) - liq_y_before
victim_y_after = engine.get_collateral_balance_of_user(victim, tok_y.address)
print(f"liquidator burned 1,000 DSC, received {seized / E18} Y = ${usd(seized * 2_000):,.0f}")
assert seized == 55 * E18 // 100 # 0.5 Y for the debt + 0.05 Y bonus
assert victim_y_before - victim_y_after == seized
assert engine.user_to_dsc_minted(victim) == 0
assert dsc.balanceOf(liq) == 0
​
# restore the real X price and measure the victim's loss
feed_x.updateAnswer(X_PRICE)
equity_before = 2_500 - 1_000 # $ at real prices, before liquidation
equity_after = usd(engine.get_account_collateral_value(victim)) - 0 # debt now 0
print(f"victim equity at real prices: before ${equity_before:,.0f}, after ${equity_after:,.0f} -> lost ${equity_before - equity_after:,.0f} (the 10% bonus)")
assert engine.get_account_collateral_value(victim) == 1_400 * E18
assert equity_before - equity_after == 100
​
# ------------------------------------------------------------------ 4. single-asset X position cannot be liquidated at all
print("== 4. single-asset X position: unliquidatable while X reads 0 ==")
feed_x.updateAnswer(0)
assert engine.health_factor(x_only) == 0
print(f"x_only HF = {engine.health_factor(x_only)} (< 1e18, liquidatable in principle)")
with boa.env.prank(liq):
engine.mint_dsc(500 * E18)
dsc.approve(engine.address, 500 * E18)
with boa.reverts(): # division by zero in _get_token_amount_from_usd
engine.liquidate(tok_x.address, x_only, 500 * E18)
with boa.reverts(): # x_only has no Y: _redeem_collateral underflows
engine.liquidate(tok_y.address, x_only, 500 * E18)
print("liquidate(X, x_only, 500) -> reverts (division by zero)")
print("liquidate(Y, x_only, 500) -> reverts (no Y collateral, underflow)")
with boa.env.prank(x_only):
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.mint_dsc(1)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.redeem_collateral(tok_x.address, 1)
print("x_only mint_dsc(1) / redeem_collateral(X, 1) -> revert DSCEngine__BreaksHealthFactor")
​
print("PASS: AS-06 proven - a fresh zero answer passes oracle_lib, values X at 0, "
"lets a healthy (HF 1.25 at real prices) mixed position be liquidated for the 10% bonus, "
"and makes X-only positions unliquidatable (div-by-zero) instead of freezing the engine")

The script ends with: PASS: AS-06 proven - a fresh zero answer passes oracle_lib, values X at 0, lets a healthy (HF 1.25 at real prices) mixed position be liquidated for the 10% bonus, and makes X-only positions unliquidatable (div-by-zero) instead of freezing the engine

Recommended Mitigation

Reject non-positive answers in the library, so the engine freezes on them the same way it freezes on stale data.

--- a/src/oracle_lib.vy
+++ b/src/oracle_lib.vy
@@ def _stale_check_latest_round_data @@
assert updated_at != 0, "DSCEngine_StalePrice"
assert answered_in_round >= round_id, "DSCEngine_StalePrice"
+ assert price > 0, "DSCEngine_InvalidPrice"
seconds_since: uint256 = block.timestamp - updated_at
assert seconds_since <= TIMEOUT, "DSCEngine_StalePrice"
Updates

Lead Judging Commences

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