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.2Mof 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
0xeb3a9c56d963b971d320f889be2fb8b59853e449was supposed to let LP-token owners withdraw only once their lock expired. Instead, Decurity found thatunlockToken(lockerId)could be replayed before expiry. The decompiled logic keyed each user lock as_unlockToken[msg.sender][lockerId], wherefield2stored the locked LP amount,field3the locktime, andfield5the LP-token address. The fatal mistake was that the function did not requireblock.timestamp >= locktimebefore 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 replayunlockToken()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 >= locktimeas a precondition for transfer, and only cleared the stored amount on the expired-lock branch.
- The vulnerable entry point was the legacy DxSale / DxApps liquidity-locker contract on BNB Chain:
- Root cause:
- DxLock broke the core
one lock -> one unlockinvariant. - The contract treated
unlockas 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.
- DxLock broke the core
- Exploit path:
- The attacker acquires enough tokens to mint LP tokens for a pool that already has meaningful DxLock balances from other users.
- They add liquidity to that pool and receive the pool’s LP token; Decurity’s proof of concept used the
BARMY-WBNBPancakeSwap pair. - The attacker creates a new locker in DxLock for those LP tokens, becoming the recorded owner of one valid locker ID.
- They call
unlockToken(lockerId)before the locktime has expired. - The contract transfers
field2LP tokens out to the attacker but, because the lock is still pre-expiry, does not zero the stored amount. - The attacker calls
unlockToken(lockerId)again and again, reusing the same lock record to withdraw the same LP amount multiple times. - 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 beforelocktime.- 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 replaycousin 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 timestampandonly 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-WBNBpair0xdfdc2291e2084A228BD07D69369770A37C76D7ff - Decompilation target called out by Decurity:
unlockToken(lockerId)over_unlockToken[msg.sender][lockerId]
- Vulnerable BNB Chain locker contract:
- 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 tokenpayout 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 branchcase 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-WBNBLP 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 unlockor 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 wasafter 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 valuea hard invariant for every locker ID. RepeatingunlockToken(lockerId)under any timestamp branch should revert, return zero, or observe already-consumed state. - Separate time gating from entitlement finality.
is it mature yet?andhas 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 nowandhas not already withdrawnare 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, butsame 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
-
The decisive leverage sat in
unlockToken(lockerId)’s pre-expiry branch, the choice to clear claim state only after expiry, and the fact that DxLock custodied pooled fungible LP balances per token contract rather than isolating each user’s assets in a separate vault. -
The scary part was not merely
unlock happened early. It wasone still-live lock kept authorizing the same fungible LP withdrawal over and over again. -
Sources:
-
Last reviewed: 2026-07-21 UTC