Mirror Protocol lock contract duplicate unlock (2021-10-08)
- Name: Mirror Protocol lock contract duplicate unlock
- Date: 2021-10-08
- Protocol: Mirror Protocol
- Chain: Terra Classic
- Category: balance-delta provenance failure / duplicate claim / idempotency failure / replayable unlock / lock-contract accounting injection
- Loss: about
$89.7MinUST/USTC, widely reported as roughly$90M - Summary: Mirror’s
mirror_lockcontract let one economic right be consumed many times. First, the attacker directly transferred large amounts ofUSTCinto the lock contract before opening short positions. Becauselock_position_funds_hookinferred a position’slocked_amountfrom the lock contract’s live balance delta, each prepared position could end up with a recorded locked amount around100,004 USTCeven though only about4 USTCcame from the short sale itself and the formal collateral deposit was just10 USTC. After the lock period expired, the attacker calledunlock_positions_funds()with the sameposition_idxrepeated hundreds of times. The function summed the same locked amount over and over, so one prepared position could release tens of millions ofUSTCin a single call. - Trigger / entry point:
- Mirror’s short-position flow routed sold proceeds into the
mirror_lockcontract and then invokedlock_position_funds_hook()to determine how much value belonged to the newly opened position. - In the onchain contract,
lock_position_funds_hook()computedposition_locked_amountascurrent_balance.checked_sub(locked_funds)?, so unsolicitedUSTCalready sitting in the contract could be misattributed to the new position. - Later,
unlock_positions_funds()accepted a caller-supplied array ofposition_idxvalues and, in the vulnerable version, effectively iterated that vector and summed every matchinglocked_amountwithout rejecting duplicates first.
- Mirror’s short-position flow routed sold proceeds into the
- Root cause:
- Mirror violated the
consume onceinvariant on locked positions: the batch unlock path treated repeated copies of the same position as distinct unlockable entitlements. - The blast radius was amplified by another accounting flaw:
lock_position_funds_hooktrusted ambient contract balance changes to size one position’s locked amount. - Together, those two mistakes created a clean two-stage drain: first inflate the amount attached to one lock record, then replay that same record hundreds of times inside the unlock batch.
- Mirror violated the
- Exploit path:
- The attacker sent
100,000 USTCdirectly to Mirror’s lock contract in a preparation transaction. - In the same preparation flow, they opened a short position with only
10 USTCof formal collateral andshort_paramsenabled, causing the mint contract to mint a tiny amount ofmETH, sell it into themETH-USTpair, and send about4.06582 USTCof swap proceeds into the lock contract. - When
lock_position_funds_hookran for position43186, it compared the lock contract’s live balance to tracked locked funds and concluded that this new position had roughly100,004 USTClocked, not merely the fewUSTCactually produced by the short sale. - The attacker repeated this setup pattern across many positions, using direct transfers from roughly
1,000to100,000 USTCto create oversizedlocked_amountrecords. - Once positions had passed the unlock delay, the attacker called
unlock_positions_funds()with duplicate IDs. In the representative exploit transaction, position43186appeared437times. - Because the contract did not check for duplicate
position_idxvalues, it summed the same roughly0.1M USTClocked amount for each repeated copy and released about43.7M USTCfrom that one prepared position in a single call. - The attacker repeated the same mechanism across other prepared positions until the cumulative drain reached about
$89.7M.
- The attacker sent
- What the attacker needed to be true:
- The lock contract had to treat unsolicited token transfers as part of the next position’s locked funds.
- The unlock path had to accept caller-supplied duplicate position IDs without deduplication or explicit rejection.
- The lock period had to expire before the replayable unlock could be exercised.
- The contract had to hold enough
USTCliquidity for inflated unlock accounting to settle into real withdrawals.
- Technical anatomy worth remembering:
- This was not just a
duplicate IDsbug in isolation. The duplicate-unlock primitive became protocol-scale because Mirror also sized per-position locked funds from ambient balance differences instead of a tighter accounting source. - The exploit is a good reminder that batch payout code and helper accounting code compose: a replay bug that would otherwise be moderate can become catastrophic when one claimable record can first be overfilled.
- The patch diff is a useful forensic artifact: Mirror replaced a simple
iter().map(...).sum()-style accumulation overunlockable_positionswith an explicit loop that trackedunlocked_positions, reverted onDuplicate position_idx, and only then accumulatedunlock_amount. - Community criticism later centered not only on the bug itself, but on the fact that Mirror developers quietly patched the issue before public disclosure that it had already been exploited in the wild.
- This was not just a
- Representative transactions and artifacts:
- Preparation transaction highlighted by BlockSec:
29C9CFBBC9562100A5DB19D705E440CE24768D3BDE399507FA1C2EC2424413C4 - Representative unlock transaction:
08DD2B70F6C2335D966342C20C1E495FD7A8872310B80BAF3450B942F79EBC1F - Patch commit:
56cc6946b9457293ede6aa0feb296ee1d16f6974(Short reward cap to 80% + bugfix (#98)) - Key affected module:
contracts/mirror_lock/src/contract.rs
- Preparation transaction highlighted by BlockSec:
- Response / mitigation:
- Mirror patched the lock contract in commit
56cc6946b9457293ede6aa0feb296ee1d16f6974, replacing the old aggregate-sum path inunlock_positions_funds()with an explicit duplicate check overunlockable_positionsthat aborts onDuplicate position_idx. - Reporting after the public discovery indicated the bug had been fixed quietly in May 2022 before the wider community realized the exploit had been active since October 2021.
- The narrower code fix stopped the replay path, but the incident also highlights the need to avoid inferring entitlement size from ambient token balances when unsolicited transfers are possible; the duplicate guard alone did not erase that broader accounting smell.
- Mirror patched the lock contract in commit
Three lock-and-exit promotions this incident collapsed
-
Ambient
USTCwas present inmirror_lock- The contract wallet really did hold the attacker’s prefunded
USTCplus the tiny short-sale proceeds. - That was only a raw balance fact. It did not yet prove that the newly opened short position had earned that whole amount as its own locked proceeds.
- The contract wallet really did hold the attacker’s prefunded
-
Mirror promoted that ambient balance into one position’s canonical
locked_amounttruthlock_position_funds_hook()treatedcurrent_balance - locked_fundsas if it were exact provenance for the just-opened position, so one position record inherited roughly100,004 USTCof locked value even though the short sale itself produced only about4.06582 USTCand the formal collateral deposit was just10 USTC.- This was the decisive accounting publication step: contract-level balance presence became position-level entitlement truth.
-
That published lock record still inherited live unlock authority, repeatedly
unlock_positions_funds()then accepted duplicateposition_idxvalues and summed the same oversized lock record over and over before releasing funds.- The protocol therefore promoted one attacker-shaped lock record into immediate cash-out authority many times, not once.
The reusable review split is: tokens were sitting in the lock contract, those tokens were published as this exact position's locked claim, and that published claim was then allowed to spend real USTC on every repeated exercise. Mirror collapsed all three.
Pattern tags
- duplicate-claim-idempotency-failure
- balance-delta-provenance-failure
lock_position_funds_hook()treated ambientUSTCalready sitting inmirror_lockas if it were the newly opened short position’s own locked proceeds, so one future unlock record could be preloaded with someone else’s prefunded balanceunlock_positions_funds()then let the sameposition_idxappear hundreds of times in one batch, turning one oversized lock record into repeated cash-out rights instead of one consumed entitlement- The exploit therefore composed two reusable accounting mistakes:
who does this balance delta belong to?failed first, andhow many times can this exit right be exercised?failed second
Why the exploit worked
- Mirror’s lock helper was already publishing ownership fiction before the replay started.
lock_position_funds_hook()sized one position’slocked_amountfromcurrent_balance - locked_funds, which only proved thatUSTCwas present in the lock contract, not that the just-opened short position had actually produced that much sale proceeds. Once the attacker parked100,000 USTCin the contract first, the next position inherited that ambient balance as if it were its own. - The later unlock bug then monetized that false record almost perfectly.
unlock_positions_funds()trusted the caller-suppliedpositions_idxbatch, accumulated each matchinglocked_amount, and only afterward transferred the aggregate amount. Repeating43186437times therefore turned one roughly100,004 USTCrecord into about43.7M USTCof payout in a single transaction. - The two issues mattered more together than alone. A duplicate-unlock bug against tiny short-sale proceeds would have been annoying but smaller; ambient-balance attribution without replay would have overpaid one exit once. Mirror made both mistakes in sequence, so one fabricated large entitlement could be exercised many times.
- The patch history is part of the preserved lesson. Mirror eventually replaced the blind summation path with explicit duplicate detection, but public reporting later argued that the protocol had already patched quietly months before broad disclosure that the exploit had been active. Review discipline failed at both the code and communication layers.
Detection and audit pivots
- Treat lock-accounting helpers like solvency code. If a contract derives one user’s
locked_amount, claim size, or reserve credit frombalanceAfter - balanceBefore, ask what stops prefunds, donations, or unrelated transfers from being attributed to that user. - For every batched exit path, force the reviewer question
what exact storage key makes the second exercise of this entitlement worth zero?In Mirror, the dangerous answer wasnone until after the batch sum had already grown. - Compare batch behavior to repeated single-item execution. If
[id, id, id]can pay more than three serial calls after the first one should have consumed the record, the exit path is publishing replayable value. - Trace the full composition, not just each bug in isolation. Mirror is a strong reminder to ask whether an upstream accounting helper can inflate the value attached to one entitlement before a downstream replay or unlock routine multiplies it.
- Review disclosure and patch timing as part of protocol safety. Quietly fixing an exploit path without promptly surfacing that it was live leaves users and downstream integrators pricing stale risk while the protocol already knows the trust boundary was broken.
Failure-prevention checklist
- Derive one position’s
locked_amountfrom a position-local receipt object, not from ambient contract balance deltas. Direct transfers, prefunds, fee rebates, or unrelated unlock returns should never be able to masquerade as the next short position’s own locked proceeds. - Quarantine unsolicited token inflows until an explicit operator or accounting path recognizes them.
tokens are sitting in the lock contractis not the same fact asthis position earned those tokens as locked value. - Make
unlock_positions_funds()economically equivalent to repeated single-position unlock execution against one shared consumed-state machine. A batch helper should not create a second replay story for the same lock record. - Reject duplicate
position_idxvalues before any unlock amount is accumulated. The entitlement set has to be canonicalized first; payout math comes second. - Mark lock records consumed, reduced, or otherwise made economically inert before later loop iterations or sibling helper paths can still observe them as live exit rights.
- Property-test the composition, not just each helper in isolation: prefund the contract, create a new short position, wait for unlock, and then fuzz
[id],[id, id], and mixed-order batches to ensure one inflated record cannot compound into protocol-scale loss. - Bound helper-path blast radius. Even if one lock record is mis-sized, per-position payout caps, queue-based releases, or manual review thresholds can stop one bad record from draining the full contract balance in one call.
- Treat disclosure and patch operations as part of the control surface. If a live exploit path is found, user-facing warning, pause, and remediation sequencing should move as deliberately as the code fix.
Audit prompts
- What exact evidence proves that one short position, and not ambient balance already in
mirror_lock, earned thelocked_amountbeing recorded right now? - Can unsolicited
USTCsitting in the contract beforelock_position_funds_hook()runs be attributed to the next position throughcurrent_balance - locked_fundsor any similar balance-difference shortcut? - If I submit
[position_idx, position_idx]tounlock_positions_funds(), where exactly does the second copy collapse to zero additional value? - Do batch unlock helpers and single-position unlock helpers consume the same entitlement object, or can one path still see a lock record as live after another path has already priced it into payout?
- Is consumed-state mutation happening before payout aggregation, or does the contract first build a large
unlock_amountand only later try to reason about uniqueness? - Could a single mis-sized lock record, once created, cash out more than a bounded per-position amount or more than one release window in a single transaction?
- What monitoring would have flagged
one position ID repeated hundreds of timesorone newly opened short suddenly owns ~100,000 USTC of locked valueas impossible state rather than business-as-usual activity? - If the team patches a replay path after exploitation has already begun, what exact process ensures users, integrators, and governance learn that the historical accounting surface was compromised?
Lessons that should already be standard
- Ambient balance is not provenance. A contract seeing tokens in its wallet does not prove who earned them or which accounting object they belong to.
- One-time exits need one-time state. If a lock record can appear twice in a batch and still add value twice, the protocol has not actually defined an exit entitlement strongly enough.
- Small helper assumptions compose into large losses. Mirror did not need an exotic oracle break or governance capture; one misattributed balance delta plus one replayable unlock loop was enough.
- Batch convenience code deserves treasury-grade review. Helper functions that
just sum a vectoroften become the real custody boundary once they can move pooled funds. - Silent fixes are not neutral operations when exploitation may already be live. Security posture includes how quickly a protocol warns users that a previously trusted accounting surface was already broken.
- The clean reviewer invariant is simple: one lock record should be attributable once, unlockable once, and economically worthless on every attempted second exercise.
Why this incident matters
- Mirror is a strong exemplar for the corpus because it preserves a mechanically distinct failure mode: batch unlock replay, not oracle manipulation, not reentrancy, and not governance abuse.
- It also shows how two seemingly modest issues can multiply: balance-difference accounting made one lock record huge, and missing idempotency let that record be cashed out repeatedly.
- It is also a good accounting-composition case study: the most dangerous bug was not any single line, but the fact that one helper published an oversized entitlement and another helper later treated that entitlement as replay-safe.
- For design review, it is a useful warning that
one-time releaseinvariants deserve the same rigor as borrow, liquidation, and swap logic.
Internal linkages
- Core recurring patterns: duplicate-claim-idempotency-failure and balance-delta-provenance-failure.
- Positive-design companion: exit-right-consumed-state-binding — the sharper question is what one lock entitlement
unlock_positions_funds()should have been forced to consume exactly once before any batch payout was accumulated. - Positive-design companion: reserve-backed-claim-truth-security-object — useful when the sharper question is what exact receipt object should have proved that one short position, and not ambient
USTCalready sitting inmirror_lock, had earned the right to record that locked amount in the first place. - Adjacent accounting lens: balance-delta-provenance-failure — Mirror is the ambient-balance-attribution cousin: unsolicited
USTCalready sitting inmirror_lockwas misassigned to the next short position becauselock_position_funds_hook()treated a live contract-balance delta as proof of that position’s own locked proceeds. - Routing shorthand inside that sibling pattern: Penpie is the reward-accounting / semantic-source-confusion cousin, Akropolis is the clean
one outer unwind remintcousin, Grim is therepeated checkpoint-reset remintsvault cousin, and Origin Dollar is theexplicit mint first, later rebasing reread of the same backingcousin. - Adjacent protocol-accounting lens: solvency-check-bypass.
Control surface
-
The leverage sat in Mirror’s
mirror_lockbalance-difference accounting, the caller-controlledpositions_idxarray supplied tounlock_positions_funds(), and the missing guarantee that one lock record could only be consumed once. -
Mirror did not fail because markets moved or oracles lied. It failed because the protocol let one stored entitlement become both oversized and replayable.
-
Sources:
- https://blocksec.com/blog/how-the-mirror-protocol-got-exploited
- https://blocksecteam.medium.com/how-the-mirror-protocol-is-exploited-33b5c1d48322
- https://rekt.news/mirror-rekt
- https://github.com/Mirror-Protocol/mirror-contracts/commit/56cc6946b9457293ede6aa0feb296ee1d16f6974
- https://raw.githubusercontent.com/Mirror-Protocol/mirror-contracts/main/contracts/mirror_lock/src/contract.rs
- https://www.forbesindia.com/article/crypto-made-easy/90-million-defi-hack-discovered-seven-months-after-the-theft/76869/1
- https://cryptoslate.com/more-bad-news-for-terra-as-fresh-allegations-emerge-about-mirror-protocol/
-
Last reviewed: 2026-08-03 UTC