DODO V2 Crowdpooling Re-initialization / Counterfeit-Token Flash-Loan Drain (2021-03-09)
- Name: DODO V2 Crowdpooling re-initialization / counterfeit-token flash-loan drain
- Date: 2021-03-09
- Protocol: DODO V2 Crowdpooling
- Chain: Ethereum
- Category: unprotected initializer / re-initialization / token-identity rewrite / flash-loan return-check bypass / counterfeit-token substitution
- Loss: about 3.1M was returned, about 300K was paid as a special bounty, reducing net loss to roughly $200K
- Summary: DODO’s March 2021 incident was not primarily a trading-module failure. It was an initialization failure in V2 Crowdpooling. DODO’s own postmortem said the
init()function in the pool-creation flow could be called multiple times after post-audit code changes. That mattered because Crowdpooling setup was the root-of-trust ceremony for economic identity, not just deployment metadata: the setup path chose owner / maintainer, base-token and quote-token addresses, factory pointers, pricing parameters, and the initial_TOTAL_BASE_snapshot that later settlement logic trusted. Attackers used that replayable setup step to rewrite which token addresses the pool treated as real assets, then combined the mutable token identity with reserve-sync and flash-loan logic so attacker-minted counterfeit tokens could satisfy accounting that should have demanded the original pool assets back. Several V2 Crowdpools were drained, while DODO said V1 pools, non-Crowdpool V2 pools, user wallet approvals, and the trading module were unaffected. - Trigger / entry point:
- The affected surface was DODO V2 Crowdpooling rather than the broader DODO trading system.
- DODO’s official postmortem said “the
init()function in the pool creation contract could be called multiple times, leading to repeated initializations.” - That mattered because initialization was not just cosmetic deployment metadata; it established the token identities and accounting context the pool later trusted.
- Public reporting consistently says the affected pools included WSZO, WCRES, ETHA, FUSI, and vETH/WETH-related V2 Crowdpool deployments, while DODO emphasized that all V1 pools and non-Crowdpool V2 pools remained safe.
- Root cause:
- DODO treated initialization as if it were a harmless deployment step, but in practice it was a root-of-trust ceremony for live pool asset identity.
- DODO’s public Crowdpooling layout makes that explicit:
contracts/CrowdPooling/impl/CP.solusesinit()to set owner / maintainer,_BASE_TOKEN_,_QUOTE_TOKEN_,_POOL_FACTORY_, pricing parameters like_K_and_I_, and_TOTAL_BASE_ = _BASE_TOKEN_.balanceOf(address(this)). - Re-running
init()therefore did not just reset admin metadata; it let an attacker change which ERC-20 contracts the pool recognized as its real base / quote assets and which balances later accounting would snapshot as authoritative. - Downstream funding and settlement code in
contracts/CrowdPooling/impl/CPFunding.solthen consumed that mutable state directly:_getQuoteInput()and_sync()reasoned over the current_QUOTE_TOKEN_, while_transferBaseOut(),_transferQuoteOut(), andsettle()moved whichever tokens_BASE_TOKEN_/_QUOTE_TOKEN_currently named. - DODO said the exploitable replayability was introduced by post-audit code changes that simplified logic but missed a critical permission-management step.
- The deeper lesson is that a pool does not only need
who owns admin rightsto be immutable; it also needswhich assets count as repaymentto be immutable once liquidity is live.
- Exploit path:
- The attacker targeted funded DODO V2 Crowdpools that still exposed the replayable initialization path.
- The attacker created a counterfeit token contract under their own control.
- They used repeated
init()calls, together with a reserve-sync step described in public analyses, to reshape the pool’s accounting context around attacker-chosen token identity. - They then borrowed real assets out of the vulnerable pools through DODO’s flash-loan-capable pool logic.
- Instead of returning the original pool assets in economically equivalent form, they returned attacker-created counterfeit tokens “in lieu of the original tokens,” as DODO described it, and the pool’s repayment logic accepted that state.
- The exact micro-ordering of
init(), reservesync(), and flash-loan repayment is described somewhat differently across public writeups, but they converge on the same invariant break: repeated initialization let the attacker substitute counterfeit token identity into a repayment path that should have remained pinned to the original pool assets. - The attacker repeated the recipe across multiple vulnerable Crowdpools until DODO disabled the pool-creation portal and rescued remaining exposed funds.
- What the attacker needed to be true:
- Live funded DODO V2 Crowdpools had to remain re-initializable after deployment.
- Initialization had to control token identity or other state that the flash-loan / reserve checks later trusted.
- The attacker had to be able to mint or otherwise control a counterfeit token accepted after the re-initialization sequence.
- The targeted pools needed enough real quote / project-token liquidity for the counterfeit-substitution path to pay.
- DODO needed to discover the bug after at least some vulnerable pools had already been funded and exposed.
- Technical anatomy worth remembering:
- This is an initializer exploit, but not the familiar
rewrite owner and call emergencyWithdraw()variant. Here, repeated initialization rewrote what the pool believed its assets were. - The exploit is easy to misremember as a generic flash-loan drain. The flash loan was a monetization rail; the earlier security failure was mutable pool setup.
- In DODO’s public
contractV2tree, the key state lives incontracts/CrowdPooling/impl/CP.sol:init()writes owner / maintainer,_BASE_TOKEN_,_QUOTE_TOKEN_,_POOL_FACTORY_,_K_,_I_, and_TOTAL_BASE_. The supporting owner guard sits incontracts/lib/InitializableOwnable.solasinitOwner()/_INITIALIZED_, which is the sort of one-time boundary DODO said post-audit changes effectively broke in the live vulnerable path. - The payout rail lives in
contracts/CrowdPooling/impl/CPFunding.sol, where_getQuoteInput()computes quote inflow from the current_QUOTE_TOKEN_.balanceOf(address(this)) - _QUOTE_RESERVE_,_sync()refreshes_QUOTE_RESERVE_from that same token, andsettle()/_transferBaseOut()/_transferQuoteOut()move whichever assets the mutable base / quote slots currently reference. - That file layout is why the sharper invariant break is
token identity stayed mutable after funding, not merelyan init function was callable. Once setup could redefine the asset map, reserve accounting and flash-loan settlement no longer anchored to the originally funded pool inventory. - The incident is also a useful reminder that post-audit code changes can be more dangerous than an unaudited first draft, because they often preserve the appearance of an already-reviewed design while quietly deleting one-time or permission boundaries.
- DODO’s recovery story mattered too: the official postmortem said one real attacker was repeatedly frontrun by generalized MEV / arbitrage bots, which is why a large share of the initially drained funds was later returned or frozen rather than disappearing cleanly.
- This is an initializer exploit, but not the familiar
- Representative transactions and addresses:
- Example ETHA-USDT exploit transaction:
0x0b062361e16a2ea0942cc1b4462b6584208c8c864609ff73aaa640aaa2d92428 - Example WSZO-USDT exploit transaction:
0xff9b3b2cb09d149762fcffc56ef71362bec1ef6a7d68727155c2d68f395ac1e - Example vETH-WETH exploit transaction:
0x561f7ccb27b9928df33fa97c2fb99ea3750593e908f9f0f8baf22ec7ca0c5c4a - Public analyses also highlight attacks against DODO-USDT test / related pool contract
0x328410f276d4fe83fc78fa56ad32d9821a5e5c1cand WCRES-USDT contract0x910fd17b9bfc42a6eea822912f036ef5a080be8a. - DODO’s postmortem labeled the main exploit / frontrun actors only by suffixes such as Hippo (
0x368), Gazelle (0x355), and Leopard rather than fully naming every address in the narrative.
- Example ETHA-USDT exploit transaction:
- Response / mitigation:
- DODO said it identified remaining exposed pools within about 15 minutes and rescued roughly $80K still at risk.
- The frontend Crowdpooling creation portal was disabled while the incident response was underway.
- DODO said the initial exploit involved about 3.1M was returned, about 300K was paid as a special bounty.
- The protocol later reinforced the Crowdpooling line with additional audits, including Beosin and SlowMist reports referenced in DODO’s security materials, before re-enabling Crowdpooling.
Pattern tags
- unprotected-initializer-reinitialization
- Initializer replay reopened base / quote token identity after funding
- Counterfeit-token substitution turned a flash-loan repayment check into an asset-identity aliasing bug
- Post-audit deployment diff deleted a one-time trust-anchor boundary on a live funded pool type
Why the exploit worked
init()was not just a deploy-time metadata helper. In the publishedCP.sol, it wrote the owner, maintainer,_BASE_TOKEN_,_QUOTE_TOKEN_, factory / fee-model pointers, pricing parameters, and_TOTAL_BASE_snapshot that later pricing and settlement logic trusted.CPFunding.solthen consumed those mutable asset pointers directly._getQuoteInput()and_sync()both read the current_QUOTE_TOKEN_, while_transferBaseOut(),_transferQuoteOut(), andsettle()moved whichever ERC-20 contracts_BASE_TOKEN_and_QUOTE_TOKEN_currently named.- That meant replayable initialization did not merely reopen admin rights; it reopened the economic identity of the pool itself. Once the attacker could rename the live asset slots, the flash-loan repayment boundary no longer proved
the original assets came back, only that balances existed under attacker-chosen token addresses. - DODO’s own postmortem makes the rollout lesson explicit: the vulnerable path came from post-audit simplification changes, so the practical failure was not only
initializer lacked a guardbuta previously reviewed trust-anchor ceremony was quietly made replayable before launch. - The monetization rail was ordinary once the trust anchor broke: borrow real inventory out, rewrite the pool’s accepted asset identity, then return attacker-minted counterfeit tokens into the now-mutated reserve/accounting surface so the repayment check passed against the wrong object.
Detection and audit pivots
- Treat every initializer-set token slot as live balance-sheet authority, not as harmless metadata. If
init()chooses base / quote assets, reserve wrappers, factories, fee models, or pricing constants, ask what downstream settlement paths keep trusting those values after funding. - Diff post-audit deployment code with the same paranoia as unaudited launch code, especially around one-time guards, factory hooks, and setup helpers. DODO’s public explanation is a strong reminder that
audited oncesays little if rollout edits reopen the trust anchor. - Test initializer replay against economic invariants, not only ownership invariants. The question is not just
can an attacker become owner?, but alsocan they make a pool accept a different repayment asset, reserve token, or accounting denominator after user funds arrive?. - Trace flash-loan or reserve-sync checks back to the exact token addresses and balance snapshots they authenticate. If the repayment guard ultimately trusts mutable asset pointers or mutable reserve baselines, the flash-loan module is only as sound as that earlier state-binding.
- Monitor production for impossible setup activity: repeated initialization events or setup calls after funding, token-address changes on supposedly live pools, reserve snapshots that move immediately after reconfiguration, and any post-launch pool where deployment-time selectors are still reachable.
Failure-prevention checklist
- Treat initializer-set token addresses, factories, reserve baselines, and pricing parameters as live balance-sheet authority. If
init()chooses what counts as base, quote, repayment, or reserve truth, that selector needs the same one-time guarantees as admin ownership. - Freeze or migrate funded pool asset identity through an explicit lifecycle boundary. Once outside liquidity is live, base / quote slots should not be mutable through ordinary setup helpers, even if the helper originally existed only for deployment convenience.
- Test initializer replay against economic invariants, not just role invariants. A healthy suite should prove that no post-funding call can make flash-loan repayment, reserve sync, settlement, or withdrawal logic accept a different asset object than the one the pool originally custody-backed.
- Review post-audit rollout diffs with special attention to one-time guards, factory setup flows, and helper simplifications. If a launch edit reopens a constructor-like ceremony, treat that as a fresh critical review item rather than a minor cleanup.
- Monitor production for impossible setup activity: repeated initialization after funding, token-address changes on live pools, reserve snapshots taken immediately after reconfiguration, and flash-loan repayment events whose token identity no longer matches deployment expectations.
- Add fail-closed pause or quarantine paths for pool families whose setup integrity is in doubt. A system should be able to freeze creation or settlement on one pool type before replayable setup turns into portfolio-wide reserve substitution.
Audit prompts
- Which exact state written during pool initialization becomes money-good truth later: owner, maintainer, token identity, fee model, reserve baseline, pricing constants, or settlement helper addresses?
- Can any live funded pool still reach
init(),setup, or equivalent constructor-refactor logic through direct calls, factory callbacks, migration helpers, or alternate selectors? - If an attacker replayed setup after funding, which downstream checks would still anchor repayment to the original assets rather than to whatever token addresses the replayed initializer just installed?
- Do reserve-sync and flash-loan return checks authenticate
the original pool inventory came back, or onlysome balance exists at the currently configured token address? - What deployment or post-audit change-management step proves that one-time setup boundaries remained one-time in the exact code that went live?
- If setup ambiguity were detected in production, what operational control actually narrows loss first: pool-family pause, creation-portal disablement, settlement halt, or an explicit migration / rescue path?
Lessons that should already be standard
- Constructor-like setup is not harmless metadata when it chooses token identity, reserve truth, or repayment semantics. Replaying it means replaying the protocol’s definition of what counts as real money.
- Flash-loan return checks are only as strong as the asset object they authenticate. If setup can rewrite that object, the return check becomes a counterfeit-asset acceptance test.
- Post-audit simplifications deserve the same paranoia as unaudited launch code.
Small cleanup before shippingis a common way root-of-trust boundaries quietly disappear. - Funded pools should never be allowed to look fresh again. Once user or project liquidity is live, setup reachability is already a balance-sheet risk, not a deployment convenience.
- Asset-identity mutability is admin authority, even if it is expressed through pricing or pool-creation code rather than an obvious
ownerslot.
Why this incident matters
- DODO preserves an important variant of the initializer family: replayable setup can rewrite asset semantics, not just owner semantics.
- It also preserves a high-signal repayment lesson: flash-loan checks are only as strong as the identity and reserve state they are pinned to.
- More broadly, it is a good case for reviewing post-audit diffs with the same paranoia as original launch code, especially where setup, token lists, and one-time permissions define the pool’s economic truth.
Internal linkages
- Core recurring pattern: unprotected-initializer-reinitialization.
- Positive-design companion: live-balance-sheet-asset-identity-pinning — the sharper reusable design lesson is that once a pool is funded, base / quote asset identity should be pinned or changed only through an explicit migration boundary rather than a live mutable setup path.
- Straightforward ownership-reset cousin: daomaker-vesting-contracts-unauthenticated-init-emergencyexit-drain-2021-09-04 — DAO Maker used replayable
init()to rewrite who controlled locked token inventories, whereas DODO used replayable initialization to rewrite what the pool counted as the relevant asset identity. - Canonical constructor-to-runtime cousin: parity-multisig-wallet-initwallet-ownership-reset-2017-07-19 — Parity and DODO both show that once constructor-like state remains mutable in live runtime code, every downstream permission and accounting check inherits that mistake.
Control surface
-
The decisive leverage sat in the Crowdpooling setup ceremony itself: token-address initialization, reserve/accounting context, and the flash-loan repayment boundary that later trusted that mutable setup.
-
DODO did not mainly fail because flash loans are inherently dangerous. It failed because a live pool could still be told what its assets were.
-
Sources:
- https://blog.dodoex.io/dodo-pool-incident-postmortem-with-a-little-help-from-our-friends-327e66872d42
- https://blog.dodoex.io/dodo-recovers-funds-reduces-total-loss-to-200k-304aba695134
- https://www.quadrigainitiative.com/casestudy/dodofinancehack.php
- https://smartcontractshacking.com/hacks/dodo-amm-hack-2021
- https://docs.dodoex.io/en/home/security
- https://raw.githubusercontent.com/DODOEX/contractV2/main/contracts/CrowdPooling/impl/CP.sol
- https://raw.githubusercontent.com/DODOEX/contractV2/main/contracts/CrowdPooling/impl/CPFunding.sol
- https://raw.githubusercontent.com/DODOEX/contractV2/main/contracts/lib/InitializableOwnable.sol
-
Last reviewed: 2026-07-23 UTC