DxSale / DxLock Pre-Expiry Unlock Replay Near Miss (2023-06-28)

  • Name: DxSale / DxLock pre-expiry unlock replay near miss
  • Date: 2023-06-28
  • Protocol: DxSale / DxApps DxLock
  • Chain: BNB Chain
  • Category: duplicate claim / idempotency failure / cross-call replay / lock-release state-clearance bug / whitehat near miss
  • Loss: no realized theft; Decurity estimated roughly $5.2M of WBNB-denominated pool value was exposed in one BNB Chain locker instance, with additional Dx lock contracts on BNB Chain and other networks also sharing the same design risk
  • Summary: DxSale’s legacy liquidity-locker contract at 0xeb3a9c56d963b971d320f889be2fb8b59853e449 was supposed to let LP-token owners withdraw only once their lock expired. Instead, Decurity found that unlockToken(lockerId) could be replayed before expiry. The decompiled logic keyed each user lock as _unlockToken[msg.sender][lockerId], where field2 stored the locked LP amount, field3 the locktime, and field5 the LP-token address. The fatal mistake was that the function did not require block.timestamp >= locktime before transferring tokens out, and only zeroed the recorded amount when the lock had already expired. Before expiry, the same locker could therefore be unlocked again and again, each time transferring the same LP amount. Because LP tokens for one pool are fungible, an attacker only needed to create one small lock for a known pool, then replay unlockToken() enough times to drain other users’ locked LP tokens for that same pool from the shared locker balance.
  • Trigger / entry point:
    • The vulnerable entry point was the legacy DxSale / DxApps liquidity-locker contract on BNB Chain: 0xeb3a9c56d963b971d320f889be2fb8b59853e449.
    • Decurity’s decompilation identified an unlockToken(lockerId) path over _unlockToken[msg.sender][lockerId] storage.
    • The function recognized that the caller had a lock and knew the lock’s token address, amount, and locktime.
    • But it did not enforce block.timestamp >= locktime as a precondition for transfer, and only cleared the stored amount on the expired-lock branch.
  • Root cause:
    • DxLock broke the core one lock -> one unlock invariant.
    • The contract treated unlock as a transferable action before it treated it as a consumed entitlement.
    • State invalidation happened conditionally and too late: the recorded lock amount was zeroed only after the lock had already matured, leaving the same pre-expiry entitlement reusable across multiple calls.
    • Because the locker pooled fungible LP tokens from many projects using the same pair token, replaying one locker’s withdrawal right could cash out value deposited by unrelated lockers that referenced the same LP token contract.
  • Exploit path:
    1. The attacker acquires enough tokens to mint LP tokens for a pool that already has meaningful DxLock balances from other users.
    2. They add liquidity to that pool and receive the pool’s LP token; Decurity’s proof of concept used the BARMY-WBNB PancakeSwap pair.
    3. The attacker creates a new locker in DxLock for those LP tokens, becoming the recorded owner of one valid locker ID.
    4. They call unlockToken(lockerId) before the locktime has expired.
    5. The contract transfers field2 LP tokens out to the attacker but, because the lock is still pre-expiry, does not zero the stored amount.
    6. The attacker calls unlockToken(lockerId) again and again, reusing the same lock record to withdraw the same LP amount multiple times.
    7. Once enough LP tokens have been pulled from the shared locker contract, the attacker removes liquidity from PancakeSwap and exits with the underlying assets, draining value that belonged to other projects’ supposedly locked liquidity.
  • What the attacker needed to be true:
    • The target LP token needed existing DxLock balances from other users so replaying one locker’s withdrawal would cash out beyond the attacker’s own deposit.
    • unlockToken() needed to remain callable before locktime.
    • The function needed to leave the lock amount untouched on the pre-expiry path.
    • Unlock fees needed to stay low enough that repeated calls remained profitable; DxSale’s eventual mitigation was to raise those fees sharply rather than fix the underlying entitlement logic.
  • Technical anatomy worth remembering:
    • This was not a proof forgery or permission bypass. The attacker really did own one valid locker. The bug was that the same locker stayed live after each pre-expiry withdrawal.
    • The exploitable object was a fungible LP token, not a unique NFT-like lock receipt. That made cross-user drainage possible once one locker for a shared pair token became replayable.
    • The incident is a clean cross-call replay cousin to Mirror’s duplicate position IDs and Balancer’s duplicate Merkle claims: the same entitlement was reused through a different consumed-state failure.
    • DxSale’s own 2022 product update described LP lockers as contracts where tokens are unlocked at the exact timestamp and only the lock owner has access to the funds. The bug mattered precisely because the real contract semantics were weaker than that user-facing promise.
  • Representative contracts, pools, and artifacts:
    • Vulnerable BNB Chain locker contract: 0xeb3a9c56d963b971d320f889be2fb8b59853e449
    • Example pool used in Decurity’s proof of concept: PancakeSwap BARMY-WBNB pair 0xdfdc2291e2084A228BD07D69369770A37C76D7ff
    • Decompilation target called out by Decurity: unlockToken(lockerId) over _unlockToken[msg.sender][lockerId]
  • Response / mitigation:
    • Decurity reported the bug to DxSale / DxApps on 2023-06-29 after confirming the impact.
    • DxSale’s immediate mitigation was economic rather than structural: increase locking fees high enough that an external attacker could no longer profitably repeat the unlock path.
    • Decurity noted that this did not eliminate every risk. A project owner abusing their own locker could still exploit the replayable design despite the higher fee schedule.
    • Decurity’s disclosed timeline was: discovery on 2023-06-28 16:30 GMT, exploit PoC completed by 18:00, impact confirmed on 2023-06-29 11:00, war room created at 11:20, and fee-based mitigation deployed by 15:00.
  • Key transaction hashes:
    • No public theft transaction hashes are known because the issue was disclosed and mitigated before a live drain.
    • The key public reproduction artifact is Decurity’s proof-of-concept exploit against the BNB Chain locker contract.

Pattern tags

  • duplicate-claim-idempotency-failure
  • exit-right-consumed-state-binding
  • unlockToken(lockerId) moved fungible LP value before it extinguished the locker entitlement that authorized that value transfer
  • The consumed-state write only happened on the expired-lock branch, so the pre-expiry branch published a still-live same locker, same amount, same token payout right across later calls
  • Because DxLock pooled LP tokens by token contract rather than by isolated per-lock vault, replaying one attacker-owned locker could cash out other users’ balances for that same LP token

Why the exploit worked

  • Decurity’s decompilation preserves the decisive asymmetry: unlockToken(lockerId) looked up a real caller-owned locker and transferred its recorded LP amount, but only zeroed that amount when the contract believed the lock had already expired. Pre-expiry execution therefore paid first and left the same locker economically live.
  • That made this a strong entitlement survives the payout branch case rather than a permission bypass. The attacker did not need to spoof ownership or forge a proof; they only needed one legitimate locker ID whose recorded amount remained reusable across calls.
  • The pooled-custody design amplified the bug. DxLock did not isolate each locker’s LP tokens in a separate vault. It held fungible LP balances in one contract per token, so a replayable withdrawal right against BARMY-WBNB LP could eventually walk out LP tokens contributed by unrelated lockers using that same pair token.
  • The fee-based mitigation is worth preserving because it clarifies what the bug was not fixing. Raising unlock cost changed attacker economics, but it did not restore the broken invariant one lock -> one unlock or make repeated exercise of the same locker impossible by construction.
  • This is also a clean reminder that time checks and consumed-state checks are different invariants. Even if the intended product promise was unlock at expiry, the actual catastrophic bug was after one successful payout, the same locker still existed as a spendable entitlement.

Detection and audit pivots

  • Treat lock-release code like one-shot payout logic, not like a timestamp convenience wrapper. The reviewer should ask which exact locker state transition makes the second successful unlockToken(lockerId) worth zero.
  • Compare pre-expiry and post-expiry branches for entitlement finality, not just for access conditions. If one branch clears amount, burns shares, or deletes the record while another branch still transfers value first, the branch asymmetry is already a replay surface.
  • Model pooled fungible custody explicitly. If many users’ LP tokens for the same pair sit in one locker contract balance, replaying one user’s withdrawal right is not a local accounting bug anymore; it is a cross-user drain path against that shared token inventory.
  • Test same-call and cross-call re-execution directly: unlockToken(id) before expiry twice, unlockToken(id) before then after expiry, and repeat-until-balance-exhausted loops against a shared LP-token inventory.
  • Ask whether mitigations remove the replay primitive or merely raise its cost. Fees, per-call limits, or UI friction are not substitutes for consumed-state invalidation when the contract still recognizes the same entitlement as fresh.

Failure-prevention checklist

  • Force every locker release path to consume the same canonical entitlement object before any LP tokens leave custody. unlock before expiry, unlock after expiry, owner transfer, migration, and any admin-assist branch should all converge on one shared consumed-state primitive.
  • Make second exercise == zero additional value a hard invariant for every locker ID. Repeating unlockToken(lockerId) under any timestamp branch should revert, return zero, or observe already-consumed state.
  • Separate time gating from entitlement finality. is it mature yet? and has this locker already been spent? should be distinct checks with distinct storage semantics.
  • Isolate pooled fungible custody where practical, or at minimum keep per-lock accounting so precise that one locker’s replay cannot cash out balances attributable to other lockers using the same LP token.
  • Derive withdrawal amounts from a canonical live record that is reduced or deleted atomically with payout, not from a branch-local amount that survives because cleanup only exists on one path.
  • Review fee-based or rate-based mitigations as blast-radius controls only. They do not fix replayable payout rights unless the underlying entitlement is actually extinguished.
  • Fuzz pre-expiry / post-expiry boundary conditions, repeated unlock attempts, ownership transfers, and partial-balance scenarios where one successful unlock should leave no reusable claim behind.
  • Monitor for impossible locker behavior in production: the same locker ID withdrawing more than once, one LP token inventory falling faster than the count of matured lockers can explain, or repeated pre-expiry unlock calls against the same owner and locker pair.

Audit prompts

  • What exact storage object proves that one locker still has a live withdrawal right, and where is that object reduced or deleted before LP tokens move?
  • Do pre-expiry, post-expiry, migration, transfer, vesting, and emergency branches all consume the same entitlement object, or does one branch merely transfer value and hope another branch cleans up later?
  • If the contract holds pooled fungible LP balances for many users of the same pair token, what prevents one replayable locker from draining other users’ inventory for that token?
  • Can the same locker ID be exercised twice across different timestamps or branch conditions because maturity logic and consumed-state logic were accidentally fused?
  • If unlock fees rise or per-call frictions change, does the protocol actually remove the replay primitive, or only make the first public exploit less profitable?
  • What telemetry or reconciliation would show that one locker ID has already moved more LP tokens than were ever legitimately attributed to it?
  • Could an attacker create one tiny lock for a high-value shared LP token and then iterate repeated withdrawals until the contract’s whole balance in that token is gone?
  • Where is the property tested that any successful release path makes all sibling release paths economically inert for that same locker record?

Lessons that should already be standard

  • A time lock is not a safety property unless the same path also extinguishes the withdrawal right after first successful use.
  • Can withdraw now and has not already withdrawn are different truths. Mature-state logic cannot substitute for consumed-state logic.
  • Pooled custody turns local replay bugs into cross-user drains. If one entitlement can withdraw from a shared fungible inventory more than once, everybody holding that token in the pool is exposed.
  • Fee hikes are containment, not correctness. Making replay unprofitable for some attackers does not restore the invariant that the same locker cannot be exercised twice.
  • Legacy custody contracts deserve the same invariant review as new launches. An old locker that still holds meaningful LP value is still production-critical, even if the product surface feels mature.
  • Release functions should be designed around second execution is worthless by construction, not around assumptions that users will only call them once.
  • Branch asymmetry is a standing smell in payout code. If one branch clears state and another merely transfers value, reviewers should assume replay risk until proved otherwise.
  • Shared-token lockers should be monitored like settlement systems: repeated payouts from one record or one record consuming more inventory than it ever owned should page operators immediately.

Why this incident matters

  • It preserves a mechanically distinct idempotency failure: not duplicate IDs in one batch, but same lock record remains reusable across separate calls because consumed state is only cleared on the wrong branch.
  • It also shows how pooled custody turns a local replay bug into a systemic drain path. Once one attacker-owned locker for a shared LP token was replayable, the contract’s entire balance in that token became fair game.
  • The mitigation lesson is also worth preserving: making a replay expensive is not the same thing as making it impossible.

Internal linkages

  • Core recurring pattern: duplicate-claim-idempotency-failure.
  • Positive-design companion: exit-right-consumed-state-binding — the sharper question is what one locker entitlement unlockToken(lockerId) should have consumed on every branch, instead of leaving the pre-expiry path economically live after value had already moved.
  • Adjacent trust-boundary pattern: governance-decay-remnant-authority-capture — different exploit family, but the broader operational lesson rhymes: legacy infrastructure that still custodies value does not become safe just because the team has mentally moved on.

Control surface