MonoX Monoswap MONO Self-Swap Price-Overwrite / Single-Token-Pool Drain (2021-11-30)

  • Name: MonoX Monoswap MONO self-swap price-overwrite / single-token-pool drain
  • Date: 2021-11-30
  • Protocol: MonoX / Monoswap
  • Chain: Ethereum / Polygon
  • Category: oracle manipulation / internal price overwrite / same-asset swap validation failure / unauthorized liquidity eviction / single-token-pool drain
  • Loss: about 34M depending on token pricing and inclusion set
  • Summary: MonoX’s single-token AMM priced each listed asset against internal vCash accounting, so its own quote slot was already reserve-spending truth for sibling pools. The decisive bug was that Monoswap allowed tokenIn == tokenOut in swapExactTokenForToken() and swapTokenForExactToken(). In swapIn() / swapOut(), MonoX computed a lower sell-side tokenInPrice and a higher buy-side tokenOutPrice, then called _updateTokenInfo() twice. When both sides were MONO, the second write overwrote the first and ratcheted MONO’s internal price upward. The attacker repeated MONO -> MONO self-swaps to push MONO’s internal price to absurd levels, then swapped tiny amounts of that attacker-priced MONO for real WETH, WBTC, MATIC/WMATIC, USDC, USDT, LINK, GHST, DUCK, MIM, and IMX from other MonoX pools. A separate authorization bug in removeLiquidity() also let the attacker evict existing MONO LP positions from arbitrary addresses first, thinning the MONO pool and making the internal-price-overwrite leg cheaper.
  • Trigger / entry point:
    • swapExactTokenForToken() and swapTokenForExactToken() forwarded directly into swapIn() / swapOut() without rejecting tokenIn == tokenOut.
    • _getNewPrice() computed different prices for the sell leg and buy leg of the same transaction, and _updateTokenInfo() wrote both sequentially.
    • Because the second write used the higher buy-side price, a MONO -> MONO swap became a price-pump primitive instead of a no-op.
    • Independently, removeLiquidity() / _removeLiquidityHelper() burned LP balances for the caller-supplied to address rather than for msg.sender, letting anyone flush other users out of the MONO pool and concentrate the remaining liquidity.
  • Root cause:
    • MonoX treated same token on both sides of the swap as if it were an ordinary trade instead of a degenerate case that should revert.
    • Its pricing logic assumed tokenIn and tokenOut were distinct economic objects, so the protocol never defended against one sequential state update overwriting the same asset’s prior price write in the same call.
    • That made MonoX’s own internal pool price a manipulable solvency surface rather than a trustworthy exchange quote.
    • The unauthorized-liquidity-removal bug was a separate but important setup flaw: _removeLiquidity() computed liquidityIn from the caller-supplied to address’s LP balance, and _removeLiquidityHelper() later called monoXPool.burn(to, pool.pid, liquidityIn). That meant the attacker could point to at someone else’s MONO LP position, burn that victim balance, and thin the setup pool before running the price-overwrite loop.
    • In short: MonoX collapsed sell-side state, buy-side state, and asset identity into the same mutable price slot, then let that freshly published internal mark buy real reserves elsewhere in the protocol with no separate quarantine or consumer-side skepticism.
  • Exploit path:
    1. On Ethereum and Polygon, the attacker routed the exploit through contracts 0xf079d7911c13369e7fd85607970036d2883afcfd and 0x119914de3ae03256fd58b66cd6b8c6a12c70cfb2, with proceeds ending at 0x8f6a86f3ab015f4d03ddb13abb02710e6d7ab31b.
    2. The attacker first acquired an initial MONO position by swapping a small amount of WETH for MONO.
    3. Using the separate removeLiquidity() authorization flaw, the attacker forced several existing MONO LP positions to exit, thinning the MONO pool and making later price manipulation cheaper.
    4. The attacker then added MONO liquidity under their own control.
    5. Next, the attacker called swapExactTokenForToken(MONO, MONO, ...) repeatedly. Each self-swap computed both a sell-side and buy-side MONO price, then _updateTokenInfo() overwrote MONO’s pool price with the higher buy-side value.
    6. Repeating that loop ratcheted MONO’s internal vCash price far above any realizable market price.
    7. With MONO now massively overvalued inside MonoX, the attacker called swapTokenForExactToken(MONO, asset, ...) across other pools, needing only tiny MONO inputs to extract large amounts of real assets.
    8. The attacker repeated the same pattern on Polygon and Ethereum within about 17 minutes, draining most of the valuable non-MONO inventory from MonoX’s single-token pools.
    9. The stolen assets were consolidated into 0x8f6a86f3ab015f4d03ddb13abb02710e6d7ab31b, and some ETH was later sent through Tornado Cash.
  • What the attacker needed to be true:
    • MonoX needed to allow tokenIn == tokenOut on live swap paths.
    • The protocol needed to compute and persist distinct buy-side and sell-side prices for what was actually the same asset instance.
    • Other MonoX pools needed to trust the manipulated MONO price immediately when quoting swaps out of real reserve assets.
    • The MONO pool had to be thin enough, or cheaply made thin enough, that the self-swap loop could move the internal price dramatically.
    • Exit pools containing WETH, WBTC, stablecoins, and other liquid tokens needed to hold enough inventory that the fake MONO mark could be cashed out before intervention.
  • Technical anatomy worth remembering:
    • MonoX was not broken by an external oracle. It broke because the protocol’s own price-update machinery let one asset impersonate both sides of a trade and then treated the final write as truthful market state.
    • The exploit was not just price manipulation in the generic sense. The sharp bug was a same-asset aliasing failure: one state machine assumed two economically distinct legs, but both legs actually addressed the same price slot.
    • The removeLiquidity() flaw did not directly pay the attacker; it improved the economics of the later drain by ejecting third-party LPs from the MONO pool.
    • MonoX’s single-token-pool design made the cash-out particularly clean: once MONO’s internal vCash mark was corrupted, every other listed token pool that treated MONO as a valid swap input became a reserve source.
    • This is a useful reminder that internal AMM quotes are oracle surfaces whenever other protocol paths or sibling pools will honor them for withdrawals of unrelated reserve assets.
  • Representative transactions and addresses:
    • Ethereum Monoswap contract: 0xC36a7887786389405EA8DA0B87602Ae3902B88A1
    • Polygon Monoswap contract: 0x3826367A5563eCE9C164eFf9701146d96cC70AD9
    • Ethereum attack contract: 0xf079d7911c13369e7fd85607970036d2883afcfd
    • Polygon attack contract: 0x119914de3ae03256fd58b66cd6b8c6a12c70cfb2
    • Consolidation / stolen-funds address on both chains: 0x8f6a86f3ab015f4d03ddb13abb02710e6d7ab31b
    • Ethereum exploit transaction: 0x9f14d093a2349de08f02fc0fb018dadb449351d0cdb7d0738ff69cc6fef5f299
    • Polygon exploit transaction: 0x5a03b9c03eedcb9ec6e70c6841eaa4976a732d050a6218969e39483bb3004d5d
    • Example forcibly exited MONO LP addresses highlighted by SlowMist and later reproductions: 0x7B9aa6ED8B514C86bA819B99897b69b608293fFC, 0x81D98c8fdA0410ee3e9D7586cB949cD19FA4cf38, 0xab5167e8cC36A3a91Fd2d75C6147140cd1837355
  • Response / mitigation:
    • MonoX paused the protocol after the exploit and published a postmortem acknowledging that the sold and bought token could wrongly be the same asset.
    • The incident pushed attention onto both the swap validation bug and the weaker surrounding control surfaces that let the attacker optimize the setup leg.
    • Public discussion focused on how multiple audits had still missed a very simple but catastrophic same-asset validation failure in a novel single-token-liquidity design.

Three internal-price-authority promotions this incident collapsed

1. A same-asset MONO -> MONO path could still author a fresh local price

  • MonoX’s own postmortem preserved the first dangerous fact in plain language: the sold and bought token could be the same, and in the live exploit that token was MONO.
  • SlowMist’s reconstruction kept the sharper mechanics worth preserving: swapExactTokenForToken() let the attacker loop MONO -> MONO about 55 times, while _getNewPrice() still computed distinct sell-side and buy-side numbers and _updateTokenInfo() still wrote both.
  • At that stage the attacker had not yet emptied sibling reserves. The first unhealthy promotion was only a user-authored MONO self-swap can still publish a fresher, higher internal MONO price.

2. MonoX still promoted that self-authored MONO write into canonical protocol market truth

  • The decisive second promotion was that MonoX’s single-token-pool design did not treat the just-written MONO mark as suspicious local state or as a quote needing quarantine.
  • Instead, sibling pools using MonoX’s vCash accounting honored the overwritten MONO price as the protocol’s own canonical answer for how much purchasing power MONO now had against other listed assets.
  • This is the sharper design split worth preserving: the bug was not only tokenOutPrice overwrote tokenInPrice, but that overwritten local slot still counted as money-good market truth for the rest of Monoswap.

3. That published MONO truth still inherited immediate reserve-release authority across deeper pools

  • Once the inflated MONO mark was live, the attacker no longer needed to keep proving anything about the MONO pool itself. Tiny amounts of attacker-priced MONO could immediately buy out WETH, WBTC, MATIC / WMATIC, USDC, USDT, LINK, GHST, DUCK, MIM, and IMX from other pools.
  • The unauthorized removeLiquidity() path mattered here as an exploit-economics helper: by burning third-party MONO LP balances first, the attacker made the price-authoring pool thinner and the later reserve-release step cheaper.
  • The reusable healthy-side lesson is therefore three-part, not one-part: a same-asset path can still write a local price, that local price still becomes canonical protocol swap truth, and that accepted truth still deserves immediate authority over unrelated reserve inventory.

Pattern tags

  • protocol-authored-market-state-oracle-failure
  • state-publication-authority-failure
  • oracle-manipulation
  • MonoX’s dangerous oracle was not an imported feed; the protocol’s own vCash price slot for MONO became the attacker-written mark that sibling pools later honored for reserve withdrawals.
  • The sharp bug was same-asset aliasing across sequential buy-side and sell-side price writes. A path that should have been a no-op instead let one asset occupy both semantic roles and overwrite its own earlier state with the more favorable later write.
  • The drain only scaled because publication and spend were fused: once the internal MONO mark moved, other pools immediately treated that fresh local quote as money-good swap truth.

Why the exploit worked

  • MonoX’s own postmortem said the core bug was that the sold and bought token [could] be the same; in the live exploit that token was MONO. That already tells the main story: a path the protocol modeled as sell one asset, buy another asset was allowed to collapse into MONO -> MONO, even though the pricing code still behaved as if two economically distinct legs existed.
  • Public code review and reproductions show the sharper failure inside swapIn() / swapOut(): MonoX computed a lower sell-side price for tokenIn, a higher buy-side price for tokenOut, and then persisted both through _updateTokenInfo(). When tokenIn == tokenOut, the second write did not confirm the first one; it overwrote the same price slot with the more favorable buy-side mark.
  • Because MonoX’s single-token-pool architecture already used that internal vCash mark as sibling-pool quote truth, the attacker did not need a separate oracle compromise. Repeated MONO -> MONO self-swaps were enough to print a much higher internal MONO valuation, and tiny amounts of attacker-priced MONO could then buy out WETH, WBTC, stablecoins, and other liquid inventory from unrelated pools.
  • The separate removeLiquidity() authorization flaw made the setup leg cheaper by letting the attacker evict third-party MONO LP positions first. That did not directly steal reserves, but it thinned the MONO pool so the later self-swap overwrite loop could move the internal price farther with less opposing depth.
  • The missing healthy-side control was therefore broader than reject same-token swaps. MonoX also lacked any quarantine, haircut, or reserve-spend boundary between internal quote just changed and other pools may now spend reserves against it.

Detection and audit pivots

  • Reject or special-case degenerate trade identities explicitly. Any AMM, RFQ, or router path that conceptually requires two distinct assets should assert tokenIn != tokenOut rather than assuming later math will harmlessly net to zero.
  • Trace whether two semantic roles can alias onto one storage slot. If pricing, fee, or reserve-update code writes sell-side state and buy-side state sequentially, test the alias case where both writes target the same asset and the second update silently overwrites the first.
  • Treat protocol-authored quotes as oracle surfaces. If a local mark can be changed by user flow and sibling pools, collateral engines, or redemption paths will honor it immediately, then the quote writer is already inside the solvency boundary even if no third-party feed exists.
  • Audit setup-path authorization with the same suspicion as the final payout rail. MonoX’s unauthorized liquidity-removal bug mattered because pre-thinning the MONO pool reduced the cost of the real exploit, so this helper bug only changes pool composition was already a dangerous conclusion.
  • Add blast-radius controls beneath internal price publication: per-asset swap caps, same-block throttles, delayed activation of freshly written marks, or independent sanity checks against realizable external depth. Once a local quote can authorize withdrawals of deeper-liquidity assets, reserve spend should not depend on one just-written number alone.

Why this incident matters

  • MonoX is a clean case where a protocol’s own internal quote engine became the oracle and payout rail.
  • It preserves a sharper lesson than generic price manipulation: if the same asset can occupy both semantic roles in a trade, sequential accounting updates may no longer describe two legs of one exchange and can instead become a write-what-you-want pricing primitive.
  • It is also a strong design-side handoff into protocol-authored market-state oracle failure and internal-mark publication quarantine and reserve-spend separation. MonoX’s fatal move was not just printing a bad quote; it was letting one attacker-shaped internal quote write become immediate reserve authority for sibling pools.
  • It also shows how a seemingly secondary authorization bug can matter even when it is not the direct theft path, because thinning or reorganizing a pool can be what makes the real monetization loop economically viable.

Internal linkages

Control surface

  • The leverage sat in Monoswap’s same-asset swap handling, sequential _updateTokenInfo() writes, MONO pool depth, the sibling pools that honored MONO’s manipulated vCash value as immediate payout truth, and the unauthenticated liquidity-removal path that let the attacker reshape the setup pool first.
  • MonoX did not need an external feed failure to lose money. It let a user-authored trade path rewrite the protocol’s own exchange-rate truth and then spend that fabricated truth against real reserves.
  • The healthier control object would have separated local quote publication from reserve-spending authority, and would have failed closed once a degenerate same-asset path tried to make one token occupy both sides of the quote update.

Failure-prevention checklist

  • Reject tokenIn == tokenOut on swap paths whose accounting assumes two distinct assets, and keep an explicit invariant test for that condition in both exact-in and exact-out entrypoints.
  • Review every sequential state-write path for aliasing. If sell-side and buy-side updates can ever land on the same storage slot, fail closed instead of relying on write order to stay economically sane.
  • Treat internal quote slots like oracle outputs once sibling pools or other modules can spend reserves against them. Freshly written local marks should face delay, bounded activation, or independent sanity checks before they become payout authority.
  • Put conservative outflow caps under cross-pool reserve spending so one pathological internal quote cannot immediately drain the deepest inventory in a single loop.
  • Lock liquidity-removal, burn, and balance-debit helpers to the caller’s own position unless an explicit delegated authority path exists. helper only reshapes the pool is not a safe reason to relax ownership checks.
  • Monitor for internal quote moved sharply while reference-pool depth fell and for same-block transitions from quote updated to large reserve outflow, because that combination is the real MonoX-shaped solvency boundary.

Audit prompts

  • Can any trade, rebalance, or quote-update path collapse two semantic roles onto the same asset or storage slot?
  • If both sides of a pricing update alias to one token, what exact write order occurs, and does the second write silently overwrite the first with a more favorable state?
  • Which downstream pools or reserve modules honor this internal mark, and can they do so in the same transaction or block in which the mark was just manipulated?
  • Are there secondary authorization bugs that let an attacker thin, reorder, or otherwise cheapen the reference pool before the main monetization loop starts?
  • If the protocol’s internal quote moved 10x in one transaction, what concrete mechanism would stop that fresh mark from immediately unlocking reserve withdrawals of unrelated assets?

Lessons that should already be standard

  • Same-asset trade validation is not cosmetic input hygiene when the pricing engine writes state; it is a solvency control.
  • Internal quote engines become oracle systems the moment another pool, vault, or collateral path will spend against their output.
  • Publishing a local mark and spending reserves against it should be separate trust events, even when both happen inside one protocol.
  • Setup-path authorization bugs matter even when they are not the direct theft rail, because exploit economics often depend on first making the manipulation surface thinner, emptier, or easier to move.

Sources

Last reviewed

  • 2026-08-04 UTC