Calculated from recorded token losses using historical USD prices at the incident time.
0x566777ed780dbbe17c130ae97b9fbc0a3ab829dfBSCAn adversary-controlled orchestrator contract on BNB Chain opened an oversized WILL/USDT short via a public leveraged trading contract, then triggered updateExpiredOrders() and settleExpiredPositions() to force the protocol to spend an inflated amount of USDT into the WILL/USDT AMM. A final withdrawAll(USDT) call moved the resulting USDT from the orchestrator to the attacker EOA, realising a net gain of exactly 45429486804908004411522 units of USDT at the expense of the trading contract’s users and WILL/USDT LPs.
The root cause is a logic bug in the victim trading contract’s expired-short accounting. updateExpiredOrders() overcounts each expired short by adding an extra 80% on top of usdtShorted when populating expiredNotClosedUSDT. settleExpiredPositions(uint256) then spends this inflated amount in a single PancakeSwap USDT→WILL swap, transferring excess USDT value from protocol stakeholders to whoever calls these public functions after opening expirable short positions.
The victim protocol is a leveraged short and staking contract (trading) deployed at 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df on BNB Chain (chainid 56). Users can open WILL/USDT short positions via placeSellOrder(uint256 usdtAmount, uint256 margin, uint256 minUsdtReceived). The function:
usdtAmount + margin USDT from the caller.0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55sellOrders[orderId] (fields include usdtShorted, margin, tokenAmount, priceAtTimeOfSale, user, isActive, openTime, closeTime).activeOrders[orderId] and increments nextSellOrderId.Expired shorts are intended to be handled in two steps:
updateExpiredOrders() scans activeOrders from lastProcessedOrderId in batches of size batchSize, finds orders where order.isActive and order.closeTime < block.timestamp, and aggregates their required USDT into expiredNotClosedUSDT while marking them inactive and advancing lastProcessedOrderId.settleExpiredPositions(uint256 minTokensToReceive) then uses the contract’s USDT balance, limited by expiredNotClosedUSDT, to buy back WILL on PancakeSwap (USDT→token path), resetting expiredNotClosedUSDT to zero afterward.An external adversary deployed an orchestrator contract at 0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1. Decompilation shows that its key entrypoints (selectors 0x7f14cf11, 0x1b51e969, 0xfa09e630, and the destroy function) are gated by require(tx.origin == owner), where owner is a stored address. Normal transaction history and the decompiled code show that this owner is the attacker EOA 0xb6911dee6a5b1c65ad1ac11a99aec09c2cf83c0e, which also deploys and later self-destructs the orchestrator.
This orchestrator is not privileged in the trading contract. It simply batches public calls into trading and the AMM: pre-funding and approvals, opening a large short via placeSellOrder, then later calling updateExpiredOrders() and settleExpiredPositions(uint256) in a single transaction, and finally withdrawing all USDT profit back to the attacker EOA.
The vulnerability class is incorrect settlement accounting for expired leveraged short positions. Conceptually, the protocol should only ever spend, per expired short, at most the outstanding borrowed notional (plus properly computed interest and fees) to close the position. Instead, updateExpiredOrders() systematically overfunds the internal USDT pool expiredNotClosedUSDT by adding an extra 80% of usdtShorted for each expired order.
Formally stated invariant:
usdtShorted provided by the short seller, so that protocol-wide USDT outflows for expired shorts are bounded by accumulated margin and fee reserves.Concrete breakpoint:
updateExpiredOrders(), for each expired orderId, the contract computes totalPosition = sellOrders[orderId].usdtShorted and additionalFunds = totalPosition * 80 / 100, then updates a running total as total += totalPosition + additionalFunds, deletes activeOrders[orderId], and finally sets expiredNotClosedUSDT = total. This overfunds expiredNotClosedUSDT by 80% of usdtShorted per order without adjusting any margin or liability fields. settleExpiredPositions(uint256) then spends the full expiredNotClosedUSDT amount via a PancakeSwap USDT→WILL swap, violating the value-conservation invariant and transferring excess USDT to the short side.Because updateExpiredOrders() and settleExpiredPositions(uint256) are public and rely only on on-chain state, any unprivileged actor who can open expirable shorts on the trading contract can reproduce this overfunded settlement pattern. The observed exploit shows precisely one such ACT opportunity being exercised with a single large WILL/USDT short and one settlement cycle, yielding a net USDT profit of 45429486804908004411522 units to the adversary cluster.
Vulnerable components
trading at 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df:
placeSellOrder(uint256 usdtAmount, uint256 margin, uint256 minUsdtReceived) opens leveraged shorts by borrowing USDT, selling WILL into the AMM pair, and recording a SellOrder struct.updateExpiredOrders() loops over activeOrders starting from lastProcessedOrderId up to a batch limit batchSize. For each expired short it:
SellOrder from sellOrders[orderId].totalPosition = sellOrders[orderId].usdtShorted.additionalFunds = totalPosition * 80 / 100.total += totalPosition + additionalFunds.activeOrders[orderId] and advances currentOrderId and lastProcessedOrderId.expiredNotClosedUSDT = total.settleExpiredPositions(uint256 minTokensToReceive):
expiredNotClosedUSDT > 0 and IERC20(USDT).balanceOf(address(this)) >= expiredNotClosedUSDT.swapExactTokensForTokensSupportingFeeOnTransferTokens(expiredNotClosedUSDT, minTokensToReceive, [USDT, token], address(this), deadline).expiredNotClosedUSDT = 0 after the swap.0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55:
0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1:
require(tx.origin == owner) gating for selectors 0x7f14cf11 (short-opening entrypoint), 0x1b51e969 (expired-settlement entrypoint), 0xfa09e630 (withdrawAll(address)), and the self-destruct function.How the bug manifests in code
In the verified trading source, the expired-short sweep logic is implemented as follows (simplified from the collected Contract.sol for readability):
uint256 public expiredNotClosedUSDT;
uint256 public lastProcessedOrderId;
uint256 public batchSize;
function updateExpiredOrders() public {
uint256 total = 0;
uint256 currentOrderId = lastProcessedOrderId;
uint256 processed = 0;
while (processed < batchSize && currentOrderId < nextSellOrderId) {
if (activeOrders[currentOrderId]) {
SellOrder storage order = sellOrders[currentOrderId];
if (order.isActive && order.closeTime < block.timestamp) {
uint256 totalPosition = order.usdtShorted;
uint256 additionalFunds = totalPosition * 80 / 100;
total += totalPosition + additionalFunds;
activeOrders[currentOrderId] = false;
}
}
currentOrderId++;
processed++;
}
expiredNotClosedUSDT = total;
lastProcessedOrderId = currentOrderId;
}
This implementation violates the stated invariant in two ways:
additionalFunds = usdtShorted * 80 / 100 per expired order into expiredNotClosedUSDT without burning a corresponding liability or consuming margin.expiredNotClosedUSDT and does not keep a precise mapping from expired orders to settlement amounts, so the subsequent settlement is a lump-sum USDT spend unconstrained by per-order accounting.The paired settlement function uses this inflated value directly:
function settleExpiredPositions(uint256 minTokensToReceive) public nonReentrant {
require(expiredNotClosedUSDT > 0, "No funds to settle positions");
uint256 usdtAvailable = IERC20(USDT).balanceOf(address(this));
require(usdtAvailable >= expiredNotClosedUSDT, "Insufficient USDT available");
address[] memory path = new address[](2);
path[0] = USDT;
path[1] = token;
IERC20(USDT).approve(address(pancakeSwapRouter), expiredNotClosedUSDT);
pancakeSwapRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
expiredNotClosedUSDT,
minTokensToReceive,
path,
address(this),
block.timestamp + 300
);
expiredNotClosedUSDT = 0;
}
Here, expiredNotClosedUSDT is treated as an authoritative lower bound on the amount of USDT to spend on buybacks. Because updateExpiredOrders() inflates it by 80% per expired order, settleExpiredPositions deterministically overspends USDT relative to the actual borrowed notional and any reasonable interest or fee schedule.
Evidence from traces and state diffs
0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d (block 39979797) shows nextSellOrderId at the trading contract increasing from 10 to 11 and new storage entries under sellOrders and activeOrders, consistent with a new short opened via placeSellOrder. The AMM pair’s reserve slots also update, reflecting a large WILL sale into USDT.0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9 (block 39979801) show:
activeOrders for the exploited order flipping from 1 to 0.lastProcessedOrderId updated, with expiredNotClosedUSDT transiently non-zero during execution but zero at the end of the tx (because settleExpiredPositions is called within the same transaction).balance_diff.json for the two seed transactions shows:
0x63b4de… ends with exactly 205153639715236281623315 USDT and zero WILL after tx 2.0x5667… has a net USDT gain of 13854514520029004365887 and a large WILL inventory, while the AMM pair loses a large amount of USDT and gains WILL.These observations match the expected effect of applying an inflated expiredNotClosedUSDT through a single USDT→WILL swap, with the orchestrator configured as the ultimate USDT recipient.
Adversary-related cluster accounts
0xb6911dee6a5b1c65ad1ac11a99aec09c2cf83c0e (BNB Chain 56):
0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1.205153639715236281623315 USDT via withdrawAll(USDT).0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1 (BNB Chain 56):
require(tx.origin == owner) gating exploit entrypoints and withdrawAll.Victim-related accounts
trading at 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df (verified source collected).0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55, representing WILL/USDT LP capital.Lifecycle stages and transactions
Adversary priming and orchestrator setup (Tx 0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d, block 39979797, BNB Chain 56)
0x7f14cf11 and the WILL token address.trading.placeSellOrder, opening a large WILL/USDT short from the adversary’s perspective.balance_diff.json shows the orchestrator sending 159724152910328277211793 USDT into the system, trading gaining 142206005229984470168758 USDT, and the AMM pair gaining 17518147680343807043035 USDT while losing WILL. The orchestrator receives a large amount of WILL (3284690997786476824236791 units), leaving the net cluster exposure as short USDT-long WILL relative to the AMM.updateExpiredOrders() will see an expired order with substantial usdtShorted.Inflating expiredNotClosedUSDT and settling expired shorts (Tx 0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9, block 39979801, BNB Chain 56)
0x1b51e969.trading.updateExpiredOrders() followed by a function that corresponds to settleExpiredPositions(uint256).prestate_tracer_full.json for this tx shows:
activeOrders slot for the exploited order flipping from 1 to 0.lastProcessedOrderId updated to 1.expiredNotClosedUSDT non-zero inside the call, then reset to 0 at the end, consistent with the two-step sweep+settle pattern.balance_diff.json shows:
trading USDT balance drops from 142246507916003663493149 to 13895017206048197690278 (delta -128351490709955465802871).105120900137170629892524 to 28318751131889814072080 (delta -76802149005280815820444).205153639715236281623315.expiredNotClosedUSDT having been set to usdtShorted + 0.8 * usdtShorted and then fully spent via settleExpiredPositions, dumping the inflated USDT amount into the AMM and returning the proceeds to the orchestrator.Profit realization to attacker EOA (Tx 0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d, block 39979822, BNB Chain 56)
withdrawAll(address _token) on the orchestrator with the USDT token address.withdrawAll:
require(tx.origin == owner).balanceOf.transfer to send the full balance to owner (the attacker EOA).balance_diff_prestate_tracer.json for this tx shows:
205153639715236281623315.205153639715236281623315 USDT.Profit computation and ACT nature
159724152910328277211793 USDT.205153639715236281623315 USDT and the orchestrator holds zero.205153639715236281623315 - 159724152910328277211793 = 45429486804908004411522 USDT.native_balance_deltas (BNB) for the attacker EOA across the three txs yields a total gas cost of 44831452914590600 wei (≈0.0448 BNB), negligible relative to the USDT profit.trading and a standard AMM, with the orchestrator merely enforcing attacker-only control via tx.origin == owner. Any other unprivileged actor can reproduce the strategy by deploying their own orchestrator (or equivalent batching contract), opening similarly structured shorts, and then calling updateExpiredOrders() and settleExpiredPositions(uint256) under the same on-chain conditions.To illustrate the on-chain value movements in the critical settlement tx, here is an excerpt from balance_diff.json for tx 0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9 (BNB Chain 56), focusing on USDT balances:
[
{
"token": "0x55d398326f99059ff775485246999027b3197955",
"holder": "0x566777ed780dbbe17c130ae97b9fbc0a3ab829df",
"before": "142246507916003663493149",
"after": "13895017206048197690278",
"delta": "-128351490709955465802871",
"balances_slot": "1",
"slot_key": "0x80a5e16cd7a6626c482f9b77b3cef351c7968037b145aeb5cecf3d8642c6fef8",
"contract_name": "BEP20USDT"
},
{
"token": "0x55d398326f99059ff775485246999027b3197955",
"holder": "0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55",
"before": "105120900137170629892524",
"after": "28318751131889814072080",
"delta": "-76802149005280815820444",
"balances_slot": "1",
"slot_key": "0xc82a3b67d2d8fcd4b7e14a8177520f300b53e2cfea7fb6fc350b7b4a96b11c9e",
"contract_name": "BEP20USDT"
},
{
"token": "0x55d398326f99059ff775485246999027b3197955",
"holder": "0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1",
"before": "0",
"after": "205153639715236281623315",
"delta": "205153639715236281623315",
"balances_slot": "1",
"slot_key": "0xb584b7ab01ee700baf86e0cb44f8db636a7f1af8b8ac3d09867684b6357532b6",
"contract_name": "BEP20USDT"
}
]
This snippet shows trading and the AMM losing USDT while the orchestrator gains exactly 205153639715236281623315 USDT, matching the inflated settlement amount derived from the victim contract’s bug.
Across exploit txs 1 and 2, balance diffs show trading contract 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df gaining 13854514520029004365887 units of USDT while the WILL/USDT AMM pair 0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55 loses 59284001324937008777409 units of USDT. The adversary cluster (EOA plus orchestrator) gains 45429486804908004411522 units of USDT, with the residual USDT remaining in trading.
This reflects a deterministic transfer of 45429486804908004411522 units of USDT from protocol stakeholders (trading users and WILL/USDT LPs) to the adversary cluster. An additional small loss in BNB gas fees equal to 44831452914590600 wei is paid by the attacker EOA, but this is negligible compared to the USDT profit and does not change the qualitative impact.
0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d metadata and balance diffs: artifacts/root_cause/seed/56/0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9 metadata and balance diffs: artifacts/root_cause/seed/56/0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9withdrawAll(USDT) tx 0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d prestate tracer and balance diff: artifacts/root_cause/data_collector/iter_3/tx/56/0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d0x566777ed780dbbe17c130ae97b9fbc0a3ab829df verified source and layout: artifacts/root_cause/data_collector/iter_2/contract/56/0x566777ed780dbbe17c130ae97b9fbc0a3ab829df0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1 decompiled source: artifacts/root_cause/data_collector/iter_1/contract/56/0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1/decompile