Calculated from recorded token losses using historical USD prices at the incident time.
0x1d1cd964222d07f8d0e0a007b71cc42d4aaac66fa0ad9ded21b1d46b6b2d193c0xd26bf360df43c0c60f5db2800bed3a79b348bda0BSCOn BNB Chain block 86572245, transaction 0x1d1cd964222d07f8d0e0a007b71cc42d4aaac66fa0ad9ded21b1d46b6b2d193c exploited RSunTokenLocker at 0xd26bf360df43c0c60f5db2800bed3a79b348bda0 with a permissionless flash-swap-funded withdrawal loop. The attacker EOA 0x30388eacfe59f18e2d67a36a3a9064d7aaf702f0 deployed helper contract 0x64e41afc877613e0c19d34fa494a9506c9e5a8c3, borrowed BUSD from PancakeSwap pair 0x58f876857a02d6762e0101bb5c46a8c1ed44dc16, created a zero-duration non-vested lock owned by that helper, and then called withdrawTokens(11) twice in the same transaction.
The root cause is a storage invalidation bug in withdrawTokens(uint lockIndex): when a finished non-vested lock is fully paid, the function only removes the lock index from ownerToIndex[msg.sender] and never clears locks[lockIndex]. Because the lock record still shows the same owner and amount, the same helper can withdraw the same lock again and drain pooled contract balances belonging to other users. The seed balance diff shows the locker lost 1525208669240080563214 BUSD and the attacker EOA ended with 1521386091121684371326 BUSD, with pre-state pricing showing 1455.0737259163474615283890104271761802793421740371 BUSD of net profit after the 0.1 BNB lock fee and gas.
RSunTokenLocker is a pooled token locker. It stores locks in a locks array and uses ownerToIndex only as a lookup helper. The contract does not segregate assets per lock; all deposited tokens sit in the locker’s ERC-20 balance.
Three protocol facts make this exploit deterministic:
lockTokens(address tokenAdr, uint112 amount, uint48 duration, bool isVested, address owner_) is public, requires only the fixed 0.1 BNB fee, accepts arbitrary owner_, and does not reject duration = 0.withdrawTokens(uint lockIndex) pays the full lock.amount once block.timestamp >= lock.endTime.0x58f876857a02d6762e0101bb5c46a8c1ed44dc16 and repay it with fee in the callback.Immediately before the exploit transaction, the relevant public state was already sufficient for an unprivileged attacker:
RSunTokenLocker held 1525208669240080563214 BUSD.4354725203957034555373327 BUSD and 6665034853082277169801 WBNB, enough liquidity to flash-swap the same BUSD amount.100000000000000000 wei.The incident is an ATTACK-class ACT exploit against pooled-asset accounting. The bug is not in the flash-swap source and not in BUSD; it is inside RSunTokenLocker settlement logic.
The violated invariant is: once a non-vested lock has been fully redeemed, the locker must make that lock permanently non-withdrawable. In code terms, either the stored amount must become zero, the stored owner must no longer authorize the caller, or a consumed flag must be recorded before the transfer completes.
The vulnerable path is visible in the verified locker source excerpt:
function withdrawTokens(uint lockIndex) public {
Lock memory lock = locks[lockIndex];
require(lock.owner == msg.sender, "Only the owner can withdraw tokens");
if (!lock.isVested) {
require(block.timestamp >= lock.endTime, "Lock hasn't ended yet.");
}
uint timestampClamped = block.timestamp > lock.endTime ? lock.endTime : block.timestamp;
uint amount = lock.isVested ? lock.amount * (timestampClamped - lock.lastWithdrawn) / lock.duration : lock.amount;
if (lock.isVested && block.timestamp < lock.endTime) {
locks[lockIndex].lastWithdrawn = uint48(block.timestamp);
} else {
removeLockOwnership(lockIndex);
}
require(IBEP20(lock.tokenAdr).transfer(msg.sender, amount), "Transfer failed");
}
function removeLockOwnership(uint lockIndex) internal {
uint[] memory lockIdsOwner = ownerToIndex[msg.sender];
...
ownerToIndex[msg.sender][index] = lockIdsOwner[lockIdsOwner.length - 1];
ownerToIndex[msg.sender].pop();
}
That branch deletes only the helper index and leaves locks[lockIndex].owner, locks[lockIndex].amount, and the rest of the lock record intact. Because lockTokens also allows duration = 0, the attacker can create a non-vested lock that is immediately mature and drain it twice in one transaction. The locker’s pooled balance design makes the second withdrawal come from pre-existing contract funds, not from the attacker’s own deposited principal.
The exploit conditions are straightforward and public:
0.1 BNB fee and choose an attacker-controlled lock owner;duration = 0 is sufficient.The security principles violated are equally direct: one-time claims were not invalidated in persistent storage, auxiliary ownership indexes were used as if they were settlement state, and a user-controlled timing parameter that gates pooled-asset withdrawal was not validated.
The exploit starts from BNB Chain pre-state at block 86572244, the state immediately before the adversary transaction. At that point the locker already held 1525208669240080563214 BUSD, and the PancakeSwap pair had much more than that available for a callback-based flash swap. No privileged key, private orderflow, or attacker-owned legacy contract was required.
The first half of the bug is in lock creation. lockTokens stores:
lock.startTime = uint48(block.timestamp);
lock.duration = duration;
lock.endTime = lock.startTime + lock.duration;
lock.amount = uint112(amount);
lock.isVested = isVested;
lock.owner = owner_;
Because duration is caller-controlled and never constrained to be positive, a non-vested lock with duration = 0 is immediately redeemable at the same timestamp it is created. The exploit therefore does not need to wait across blocks.
The second half is the settlement bug in withdrawTokens. For non-vested locks, the payout is always the full lock.amount. When the lock is fully finished, the function does not mutate the stored lock at locks[lockIndex]; it only edits ownerToIndex[msg.sender]. The concrete breakpoint is the else { removeLockOwnership(lockIndex); } branch. That helper does not zero the amount, clear the owner, or set any “withdrawn” state. After one full payout, the same owner still satisfies the authorization check on the next call.
The seed transaction trace shows the exact on-chain realization:
0xd26bF360DF43C0C60f5Db2800BeD3A79b348BDA0::lockTokens{value: 100000000000000000}(
0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56,
1525208669240080563214,
0,
false,
0x64E41Afc877613E0c19d34FA494a9506C9e5a8C3
)
0xd26bF360DF43C0C60f5Db2800BeD3A79b348BDA0::withdrawTokens(11)
emit Transfer(from: 0xd26b..., to: 0x64E41..., value: 1525208669240080563214)
0xd26bF360DF43C0C60f5Db2800BeD3A79b348BDA0::withdrawTokens(11)
emit Transfer(from: 0xd26b..., to: 0x64E41..., value: 1525208669240080563214)
emit Transfer(from: 0x64E41..., to: 0x58F876..., value: 1529031247358476755102)
emit Transfer(from: 0x64E41..., to: 0x30388E..., value: 1521386091121684371326)
This trace establishes the full causal chain:
11 with duration = 0, isVested = false, and itself as owner.withdrawTokens(11) returned the helper’s own deposited BUSD.withdrawTokens(11) paid the same amount again because the lock record still authorized the helper and still stored the full amount.The balance diff independently confirms the economic effect:
{
"locker_busd_delta": "-1525208669240080563214",
"attacker_eoa_busd_delta": "1521386091121684371326",
"pair_busd_delta": "3822578118396191888",
"attacker_eoa_native_delta": "-101493023000000000"
}
The pre-state observations price the 0.101493023 BNB outlay at 66.312365205336909797610989572823819720657825962878 BUSD using the pre-transaction PancakeSwap spot ratio, leaving 1455.0737259163474615283890104271761802793421740371 BUSD of net profit in the report’s reference asset.
The adversary flow is a single transaction with one EOA and one freshly created helper contract.
Stage 1: Deploy attacker helper
56)0x1d1cd964222d07f8d0e0a007b71cc42d4aaac66fa0ad9ded21b1d46b6b2d193c0x30388eacfe59f18e2d67a36a3a9064d7aaf702f00x64e41afc877613e0c19d34fa494a9506c9e5a8c3The seed trace shows the EOA creating the helper inside the transaction and forwarding the exact 0.1 BNB lock fee into the attack path.
Stage 2: Borrow BUSD and create a bogus lock
0x58f876857a02d6762e0101bb5c46a8c1ed44dc161525208669240080563214 BUSDlockTokens(BUSD, borrowAmount, 0, false, helper)The attacker did not need a historical attacker contract or pre-seeded funds in BUSD. The only required principal was temporary, and the pair supplied it permissionlessly. The attacker-selected owner was the helper contract itself, ensuring that the same contract could execute both withdrawals.
Stage 3: Withdraw the same lock twice and settle
1529031247358476755102 BUSD.1521386091121684371326 BUSD.This is an ACT sequence because every ingredient is public and permissionless:
No access control, stolen private key, or attacker-only artifact explains the exploit.
The measured loss to the victim locker in the incident transaction was:
1525208669240080563214 raw units (1525.208669240080563214 BUSD at 18 decimals)The locker’s pre-existing BUSD balance was fully depleted to zero. The contract did retain the public 0.1 BNB lock fee, but that does not offset the principal loss. Because the accounting bug is token-agnostic and the locker uses pooled balances, any token balance resident in the contract was exposed to the same repeated-withdraw pattern.
The attacker’s realized outputs in the observed transaction were:
1521386091121684371326 BUSD to the attacker EOA after flash-swap settlement-101493023000000000 wei native delta for fee plus gas1455.0737259163474615283890104271761802793421740371 BUSD net profit after valuing the BNB outlay at the pre-transaction spot price0x1d1cd964222d07f8d0e0a007b71cc42d4aaac66fa0ad9ded21b1d46b6b2d193clockTokens, two withdrawTokens(11) calls, flash-swap repayment, and profit transferRSunTokenLocker source excerpt covering lockTokens, withdrawTokens, and removeLockOwnership0xd26bf360df43c0c60f5db2800bed3a79b348bda0