Reproduced Exploit
Open Dollar — missing debt check lets users start a debt auction of non-existent debt
1. auctionDebt() checks debtAuctionBidSize > _unqueuedUnauctionedDebt(debtBalance) using the current (pre-settle) debt balance, reverting only if there isn't enough. 2. It then calls _settleDebt with that same pre-settle unqueued/
Chain
Other
Category
logic
Date
Oct 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n. Standalone Foundry PoC and full write-up: 29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/check-ordering · vuln/accounting/stale-precondition · vuln/token-dilution
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n_exp.sol.
Key info#
| Impact | HIGH — a debt auction starts and mints/sells protocol tokens to cover bad debt that no longer exists, diluting the protocol token for no reason |
| Protocol | Open Dollar — a RAI/Reflexer-style CDP stablecoin |
| Vulnerable code | AccountingEngine.auctionDebt — the insufficient-debt check runs against the pre-settle debt balance, then _settleDebt is called with that same pre-settle amount |
| Bug class | Stale precondition: a validity check is evaluated before a state-mutating call that can invalidate it, with no re-check afterward |
| Finding | Code4rena — Open Dollar, 2023-10 · #29348 (H-02) · reporter Falconhoof |
| Report | code4rena.com/reports/2023-10-opendollar |
| Source | AuditVault |
| Status | Audit finding — caught in review (fixed via PR #253), not exploited on-chain. Reproduced here as a standalone local PoC. |
| Compiler | ^0.8.24 (PoC) |
This is an audit finding, not a historical on-chain incident. The real
AccountingEngine sits inside a full RAI-style CDP/SAFE stack; the PoC keeps
the vulnerable check-then-settle sequence verbatim and reduces the
SAFEEngine, protocol token, and debt auction house to the minimum needed to
show the dilution mechanically.
TL;DR#
auctionDebt()checksdebtAuctionBidSize > _unqueuedUnauctionedDebt(debtBalance)using the current (pre-settle) debt balance, reverting only if there isn't enough.- It then calls
_settleDebtwith that same pre-settle unqueued/ unauctioned amount — using up the protocol's surplus coin to net off bad debt. _settleDebtcan consume the entire debt buffer the check just validated, butauctionDebtnever re-checks debt availability afterward.totalOnAuctionDebtis incremented and the debt auction starts anyway, at the fulldebtAuctionBidSize.- HARM: the debt auction mints/sells protocol tokens to "cover" bad debt that the function's own settle call already erased — a pure token dilution with no debt behind it.
The vulnerable code#
The check-then-settle sequence (verbatim, AccountingEngine.auctionDebt):
if (_params.debtAuctionBidSize > _unqueuedUnauctionedDebt(_debtBalance))
revert(); // (checked BEFORE settling — AccountingEngine.sol:181)
_settleDebt(_unqueuedUnauctionedDebt(_debtBalance)); // AccountingEngine.sol:183
// ... auction starts unconditionally at debtAuctionBidSize
The recommended fix (from the report):
Introduce the check
(_params.debtAuctionBidSize > _unqueuedUnauctionedDebt(_debtBalance))after calling_settleDebt, to ensure there is still enough bad debt once the settlement has run.
Root cause#
The function validates a precondition (enough unqueued/unauctioned bad debt)
and then immediately performs a state change (_settleDebt) that can
invalidate that very precondition, without re-validating afterward. This is a
classic check-then-act-on-stale-state bug: the check and the auction it gates
are separated by a state mutation that the check does not account for.
Preconditions#
- The
AccountingEnginemust have unqueued/unauctioned bad debt at or abovedebtAuctionBidSize(an ordinary operating condition — this is exactly the debt the auction mechanism exists to cover). - The
AccountingEnginemust have enough surplus system coin to let_settleDebtconsume that debt in the same call (also an ordinary operating condition, since surplus coin accrues from protocol fees). - No special permissions are required —
auctionDebt()is a permissionless, publicly callable function in the real protocol.
Attack walkthrough#
From output.txt:
- The
AccountingEngineis seeded with exactlydebtAuctionBidSize(100, inradunits) of real bad debt, plus matching surplus coin to cover it — the boundary case where the check just barely passes. - A caller invokes
auctionDebt()directly (no externalsettleDebtcall first). - The check passes: the pre-settle unqueued/unauctioned debt (100) is not
less than
debtAuctionBidSize(100). settleDebtruns with that same pre-settle amount (100), zeroing out theAccountingEngine's debt balance entirely.- HARM:
totalOnAuctionDebtis still increased by 100, and the debt auction starts anyway, minting 500 protocol tokens to the caller — tokens sold to "cover" debt that the function's own settle call already eliminated. A control test confirms that ifsettleDebtis called externally beforeauctionDebt(the finding's other test-case order), the check correctly sees the post-settle (zero) debt and reverts — isolating the bug to the missing re-check insideauctionDebtitself.
Diagrams#
Remediation#
Re-run the insufficient-debt check after _settleDebt completes, using
the post-settle debt balance, so the auction only starts when real bad debt
still remains to justify it.
How to reproduce#
cd ~/RustroverProjects/audits/evm-hack-registry/29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: all 3 tests PASS:
# test_exploit (Exploit-driven harm)
# test_auctionDebt_direct_mintsForZeroDebt (standalone rebuild, finding's "second test case")
# test_control_externalSettleFirst_reverts (control: finding's "first test case" — correctly reverts)
PoC source: test/29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n_exp.sol — the verbatim vulnerable check-then-settle sequence, plus the finding's two test-case orderings (direct call vs. externally-settled-first) as attack and control.
Note: the real
AccountingEngine's debt-queue mechanics (pushDebtToQueue/popDebtFromQueue) and rad-unit (1e45) fixed-point scaling are reduced-model simplifications (out of scope here — this PoC uses plain integer units); the reduction preserves the verbatim check-before-settle ordering and the exact boundary condition (debt == debtAuctionBidSize) that is the faithful part of the bug.
Reference: finding #29348 (H-02) by Falconhoof in the Code4rena Open Dollar review (Oct 2023) · curated by AuditVault
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n_exp (evm-hack-registry mirror).
- AuditVault finding: 29348-h-02-missing-debt-check-lets-users-start-a-debt-auction-of-n.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.