All incidents

LockedDeal Overflow Drain

Share
Mar 15, 2023 03:16 UTCAttackLoss: 61,856,797.09 MNZ, 35,975,413.19 WOD +2 morePending manual check1 exploit txWindow: Atomic
Estimated Impact
61,856,797.09 MNZ, 35,975,413.19 WOD +2 more
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Mar 15, 2023 03:16 UTC → Mar 15, 2023 03:16 UTC

Exploit Transactions

TX 1BSC
0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5
Mar 15, 2023 03:16 UTCExplorer

Victim Addresses

0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54bBSC

Loss Breakdown

61,856,797.09MNZ
35,975,413.19WOD
29,032,275.69SIP
252,152,268.73ECIO

Similar Incidents

Root Cause Analysis

LockedDeal Overflow Drain

1. Incident Overview TL;DR

On BNB Chain block 26475404, transaction 0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5 let an unprivileged adversary drain LockedDeal at 0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b. The sender EOA 0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a called helper contract 0x058bae36467a9fc5e1045dbdffc2fd65b91c2203, bought minimal seed amounts of four whitelisted tokens, created overflowed mass pools, and immediately withdrew attacker-controlled pools backed by LockedDeal's preexisting custody balances.

The root cause is unchecked arithmetic in LockedDeal's Solidity 0.6 batch accounting. CreateMassPools transfers in only getArraySum(_StartAmount), but getArraySum uses raw sum = sum + _array[i] without SafeMath. By choosing _StartAmount = [type(uint256).max - X + 2, X], the attacker makes the batch sum wrap to 1 while still registering a second pool with amount X, where X is LockedDeal's full preexisting token balance. WithdrawToken then pays that recorded amount out of LockedDeal's live inventory, leaving only the newly deposited 1 token behind.

2. Key Background

LockedDeal is a custody-style timelock contract. It records ERC20 pools in AllPoolz, associates them with owners, and later releases pool.Amount when WithdrawToken is called after the unlock time.

Three protocol facts matter:

  • CreateMassPools(address,uint64[],uint256[],address[]) is supposed to preserve the accounting invariant that the sum of newly created pool amounts equals the amount of ERC20 tokens actually transferred into LockedDeal for that batch.
  • The contract is compiled with Solidity 0.6.x, so plain arithmetic is unchecked unless SafeMath is used explicitly.
  • WithdrawToken(uint256) does not reconcile a pool's recorded amount against what was actually deposited for that pool creation call; it only checks that the pool exists, is unlocked, and still has a positive amount.

That means the safety invariant for LockedDeal is:

For every successful CreateMassPools call,
sum(recorded new pool amounts) == actual ERC20 amount transferred into LockedDeal.

The exploit works because LockedDeal enforces the transfer on the wrapped batch sum, but stores the raw per-pool amounts unchanged.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class protocol bug, not an MEV-only opportunity. The vulnerable component is LockedDeal's own accounting path. CreateMassPools calls TransferInToken(_Token, msg.sender, getArraySum(_StartAmount)), so the number of tokens collected depends entirely on getArraySum. But getArraySum performs unchecked addition in Solidity 0.6, allowing attacker-chosen inputs to wrap modulo 2^256. CreatePool then stores each attacker-supplied _StartAmount[i] as the authoritative pool liability without verifying that the batch was fully funded. Finally, WithdrawToken transfers AllPoolz[_PoolId].Amount directly from LockedDeal's live token balance once the pool is unlocked. The result is a deterministic unfunded-liability bug: the attacker deposits one unit, records a second immediately withdrawable pool equal to LockedDeal's existing token inventory, and drains that inventory.

The security principles violated are straightforward:

  • Batch accounting must be overflow-safe before it is trusted to price custody transfers.
  • Recorded liabilities must never exceed assets actually received.
  • Same-transaction attacker-created accounting state must not be trusted by an immediate withdrawal path without reconciliation.

4. Detailed Root Cause Analysis

4.1 Code-Level Breakpoint

The verified LockedDeal source shows the exact breakpoint. In the batch path, the contract collects getArraySum(_StartAmount) and then stores each raw _StartAmount[i] as a pool:

function CreateMassPools(
    address _Token,
    uint64[] calldata _FinishTime,
    uint256[] calldata _StartAmount,
    address[] calldata _Owner
) external isGreaterThanZero(_Owner.length) isBelowLimit(_Owner.length) returns(uint256, uint256) {
    require(_Owner.length == _FinishTime.length, "Date Array Invalid");
    require(_Owner.length == _StartAmount.length, "Amount Array Invalid");
    TransferInToken(_Token, msg.sender, getArraySum(_StartAmount));
    uint256 firstPoolId = Index;
    for(uint i=0 ; i < _Owner.length; i++){
        CreatePool(_Token, _FinishTime[i], _StartAmount[i], _Owner[i]);
    }
    uint256 lastPoolId = SafeMath.sub(Index, 1);
    return (firstPoolId, lastPoolId);
}

function getArraySum(uint256[] calldata _array) internal pure returns(uint256) {
    uint256 sum = 0;
    for(uint i=0 ; i<_array.length ; i++){
        sum = sum + _array[i];
    }
    return sum;
}

The withdrawal path later trusts the stored pool amount:

function WithdrawToken(uint256 _PoolId) public returns (bool) {
    if (
        _PoolId < Index &&
        AllPoolz[_PoolId].UnlockTime <= now &&
        AllPoolz[_PoolId].Amount > 0
    ) {
        TransferToken(
            AllPoolz[_PoolId].Token,
            AllPoolz[_PoolId].Owner,
            AllPoolz[_PoolId].Amount
        );
        AllPoolz[_PoolId].Amount = 0;
        return true;
    }
    return false;
}

These two snippets fully explain the exploit. The invariant breakpoint is the unchecked sum = sum + _array[i] in getArraySum, combined with CreatePool storing the raw amounts and WithdrawToken paying them out later.

4.2 Exploit Construction

Let X be LockedDeal's existing balance of some whitelisted token T. The attacker chooses:

_StartAmount[0] = type(uint256).max - X + 2
_StartAmount[1] = X

In Solidity 0.6, the batch sum wraps:

(type(uint256).max - X + 2) + X == 1 mod 2^256

So TransferInToken collects only 1 smallest unit of T, but the contract still records two pools:

  • Pool A: amount type(uint256).max - X + 2
  • Pool B: amount X

The attacker also sets both finish times to the current block timestamp. That makes Pool B immediately withdrawable in the same transaction.

4.3 On-Chain Evidence

The seed trace for the exploit transaction shows the MNZ leg concretely. LockedDeal receives exactly 1 token, then creates two pools, then pays out the full preexisting balance:

0x8BfAA473...::CreateMassPools(
  BEP20Token: [0x861f1E1397daD68289e8f6a09a2ebb567f1B895C],
  [1678850162, 1678850162],
  [115792089237316195423570985008687907853269984665640502182660492372007802789937,
   61856797091635905326850000],
  [0x058baE36..., 0x058baE36...]
)
emit TransferIn(: 1, : 0x058baE36..., : BEP20Token: [0x861f1E13...])
emit NewPoolCreated(: 158970, : BEP20Token: [0x861f1E13...], : 1678850162,
  : 115792089237316195423570985008687907853269984665640502182660492372007802789937,
  : 0x058baE36...)
emit NewPoolCreated(: 158971, : BEP20Token: [0x861f1E13...], : 1678850162,
  : 61856797091635905326850000, : 0x058baE36...)
0x8BfAA473...::WithdrawToken(158971)
emit TransferOut(: 61856797091635905326850000, : 0x058baE36..., : BEP20Token: [0x861f1E13...])

The same trace pattern repeats for WOD, SIP, and ECIO:

  • WOD pools 158972 and 158973
  • SIP pools 158974 and 158975
  • ECIO pools 158976 and 158977

The balance diff confirms the post-state oracle exactly:

[
  {
    "token": "MNZ",
    "lockeddeal_before": "61856797091635905326850000",
    "lockeddeal_after": "1",
    "attacker_delta": "61856885947730825921957739"
  },
  {
    "token": "WOD",
    "lockeddeal_before": "35975413186725149349550000",
    "lockeddeal_after": "1",
    "attacker_delta": "35975489757544978020353101"
  },
  {
    "token": "SIP",
    "lockeddeal_before": "29032275688743400000000000",
    "lockeddeal_after": "1",
    "attacker_delta": "29032394845434095905131280"
  },
  {
    "token": "ECIO",
    "lockeddeal_before": "252152268734854541525400000",
    "lockeddeal_after": "1",
    "attacker_delta": "252153413174665343146142472"
  }
]

This is why the chosen success predicate is non-monetary. The deterministic harm is the unauthorized depletion of LockedDeal's custody balances to 1 token per affected asset. The retained fee note is still measurable: the transaction consumed 38786380000000000 wei of gas cost.

4.4 ACT Conditions

The exploit is permissionless and repeatable when the following conditions hold:

  • The target token passes isTokenValid, meaning it is whitelisted or token filtering is otherwise satisfied.
  • LockedDeal already holds a positive live balance X of that token.
  • The attacker can source at least 1 smallest unit of the token and approve LockedDeal.
  • The attacker sets pool unlock times to <= block.timestamp.

No private keys, privileged roles, or non-public orderflow are required.

5. Adversary Flow Analysis

The adversary flow is a single transaction with repeated per-token subroutines:

  1. The EOA 0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a calls helper 0x058bae36467a9fc5e1045dbdffc2fd65b91c2203 with 0.0004 BNB.
  2. The helper buys small seed balances of MNZ, WOD, SIP, and ECIO through PancakeSwap so the transfer-in check can be satisfied with a 1 token deposit for each asset.
  3. For each token, the helper approves LockedDeal and calls CreateMassPools with two attacker-owned entries:
    • a wraparound complement amount, and
    • a second amount equal to LockedDeal's full preexisting balance of that token.
  4. Because both pools are created with current-timestamp unlock times, the helper immediately calls WithdrawToken on the second pool.
  5. LockedDeal transfers its preexisting token inventory to the helper, and the helper forwards the drained balances to the EOA sender.

The adversary cluster identified from the transaction and balance flow is:

  • EOA 0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a
  • Helper contract 0x058bae36467a9fc5e1045dbdffc2fd65b91c2203

The victim is LockedDeal at 0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b.

6. Impact & Losses

LockedDeal lost live custody balances for four user-deposited assets in one transaction:

  • MNZ: "61856797091635905326850000" raw units, 18 decimals
  • WOD: "35975413186725149349550000" raw units, 18 decimals
  • SIP: "29032275688743400000000000" raw units, 18 decimals
  • ECIO: "252152268734854541525400000" raw units, 18 decimals

Post-exploit, LockedDeal's balance in each affected token was reduced to exactly 1, which is the attacker-supplied seed deposit left behind after withdrawal. The attacker EOA received the drained balances, and the transaction also incurred a measured gas cost of 38786380000000000 wei.

The broader impact is larger than the four observed drains. Any other whitelisted token already custodied by LockedDeal at the time of attack was exposed to the same arithmetic-overflow strategy.

7. References

  1. Exploit transaction: 0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5
  2. Victim contract: LockedDeal 0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b
  3. Adversary EOA: 0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a
  4. Adversary helper: 0x058bae36467a9fc5e1045dbdffc2fd65b91c2203
  5. Verified LockedDeal source: https://bscscan.com/address/0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b#code
  6. Seed transaction metadata: /workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/metadata.json
  7. Seed transaction trace: /workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/trace.cast.log
  8. Seed balance diff: /workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/balance_diff.json