LockedDeal Overflow Drain
Exploit Transactions
0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5Victim Addresses
0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54bBSCLoss Breakdown
Similar Incidents
PearlFi NLAMM unchecked multiplication overflow enables underpriced mint-and-drain
39%NeverFallToken LP Drain
33%CS Pair Balance Burn Drain
32%Cellframe Migration Drain
32%SellToken Arbitrary-Pair LP Drain
32%StakingDYNA Reward Backdating Drain
31%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
158972and158973 - SIP pools
158974and158975 - ECIO pools
158976and158977
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
Xof that token. - The attacker can source at least
1smallest 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:
- The EOA
0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2acalls helper0x058bae36467a9fc5e1045dbdffc2fd65b91c2203with0.0004BNB. - The helper buys small seed balances of MNZ, WOD, SIP, and ECIO through PancakeSwap so the transfer-in check can be satisfied with a
1token deposit for each asset. - For each token, the helper approves LockedDeal and calls
CreateMassPoolswith two attacker-owned entries:- a wraparound complement amount, and
- a second amount equal to LockedDeal's full preexisting balance of that token.
- Because both pools are created with current-timestamp unlock times, the helper immediately calls
WithdrawTokenon the second pool. - 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,18decimals - WOD:
"35975413186725149349550000"raw units,18decimals - SIP:
"29032275688743400000000000"raw units,18decimals - ECIO:
"252152268734854541525400000"raw units,18decimals
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
- Exploit transaction:
0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5 - Victim contract: LockedDeal
0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b - Adversary EOA:
0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a - Adversary helper:
0x058bae36467a9fc5e1045dbdffc2fd65b91c2203 - Verified LockedDeal source:
https://bscscan.com/address/0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b#code - Seed transaction metadata:
/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/metadata.json - Seed transaction trace:
/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/trace.cast.log - Seed balance diff:
/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/balance_diff.json