All incidents

WILL/USDT expired short overfunding exploit

Share
Jun 27, 2024 14:16 UTCAttackLoss: 45,429.49 USDTManually checked3 exploit txWindow: 1m 16s
Estimated Impact
45,429.49 USDT
Label
Attack
Exploit Tx
3
Addresses
1
Attack Window
1m 16s
Jun 27, 2024 14:16 UTC → Jun 27, 2024 14:17 UTC

Exploit Transactions

TX 1BSC
0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d
Jun 27, 2024 14:16 UTCExplorer
TX 2BSC
0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9
Jun 27, 2024 14:16 UTCExplorer
TX 3BSC
0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d
Jun 27, 2024 14:17 UTCExplorer

Victim Addresses

0x566777ed780dbbe17c130ae97b9fbc0a3ab829dfBSC

Loss Breakdown

45,429.49USDT

Similar Incidents

Root Cause Analysis

WILL/USDT expired short overfunding exploit

1. Incident Overview TL;DR

An 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.

2. Key Background

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:

  • Pulls usdtAmount + margin USDT from the caller.
  • Uses the WILL/USDT AMM pair at 0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55 (via PancakeSwap router) to sell WILL for USDT.
  • Stores per-order state in sellOrders[orderId] (fields include usdtShorted, margin, tokenAmount, priceAtTimeOfSale, user, isActive, openTime, closeTime).
  • Tracks active orders in 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.

3. Vulnerability Analysis & Root Cause Summary

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:

  • For every expired short position, the total USDT spent by the protocol to close the position (including any fees or penalties) must not exceed the initial USDT notional 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:

  • In 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.

4. Detailed Root Cause Analysis

Vulnerable components

  • Victim leveraged trading contract trading at 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df:
    • Public function placeSellOrder(uint256 usdtAmount, uint256 margin, uint256 minUsdtReceived) opens leveraged shorts by borrowing USDT, selling WILL into the AMM pair, and recording a SellOrder struct.
    • Public function updateExpiredOrders() loops over activeOrders starting from lastProcessedOrderId up to a batch limit batchSize. For each expired short it:
      • Reads SellOrder from sellOrders[orderId].
      • Computes totalPosition = sellOrders[orderId].usdtShorted.
      • Computes additionalFunds = totalPosition * 80 / 100.
      • Accumulates total += totalPosition + additionalFunds.
      • Clears activeOrders[orderId] and advances currentOrderId and lastProcessedOrderId.
      • After the loop, writes expiredNotClosedUSDT = total.
    • Public function settleExpiredPositions(uint256 minTokensToReceive):
      • Requires expiredNotClosedUSDT > 0 and IERC20(USDT).balanceOf(address(this)) >= expiredNotClosedUSDT.
      • Approves PancakeSwap router and calls swapExactTokensForTokensSupportingFeeOnTransferTokens(expiredNotClosedUSDT, minTokensToReceive, [USDT, token], address(this), deadline).
      • Resets expiredNotClosedUSDT = 0 after the swap.
  • WILL/USDT AMM pair at 0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55:
    • Standard PancakeSwap V2-style pair holding WILL and USDT reserves that are manipulated by the victim contract’s selling and buying flows.
  • Adversary-owned orchestrator contract at 0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1:
    • Decompiled source shows require(tx.origin == owner) gating for selectors 0x7f14cf11 (short-opening entrypoint), 0x1b51e969 (expired-settlement entrypoint), 0xfa09e630 (withdrawAll(address)), and the self-destruct function.
    • Internally wires these entrypoints to the trading contract and the AMM, but does not grant any special privileges on the trading contract itself.

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:

  • It adds an extra additionalFunds = usdtShorted * 80 / 100 per expired order into expiredNotClosedUSDT without burning a corresponding liability or consuming margin.
  • It writes only the aggregated total into 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

  • Pre-state tracer for 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.
  • Pre-state and post-state data for 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).
    • USDT and WILL balances shifting from trading and the AMM pair to the orchestrator, in amounts matching the overfunded USDT spend implied by the 80% uplift.
  • Combined balance_diff.json for the two seed transactions shows:
    • The orchestrator 0x63b4de… ends with exactly 205153639715236281623315 USDT and zero WILL after tx 2.
    • Trading contract 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.

5. Adversary Flow Analysis

Adversary-related cluster accounts

  • EOA 0xb6911dee6a5b1c65ad1ac11a99aec09c2cf83c0e (BNB Chain 56):
    • Sender of all three exploit-related transactions.
    • Deployer and destroyer of orchestrator 0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1.
    • Final recipient of 205153639715236281623315 USDT via withdrawAll(USDT).
  • Orchestrator contract 0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1 (BNB Chain 56):
    • Decompiled source shows require(tx.origin == owner) gating exploit entrypoints and withdrawAll.
    • Serves as intermediary holder of USDT profit between the settlement tx and the withdrawal tx.

Victim-related accounts

  • WILL leveraged trading contract trading at 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df (verified source collected).
  • WILL/USDT AMM pair at 0x1aaa8e1fd2f4137bbf83bd40d08746ce2862ed55, representing WILL/USDT LP capital.

Lifecycle stages and transactions

  1. Adversary priming and orchestrator setup (Tx 0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d, block 39979797, BNB Chain 56)

    • The attacker EOA calls the orchestrator with selector 0x7f14cf11 and the WILL token address.
    • The orchestrator configures APPROVE flows and routes calls into 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.
    • This stage sets up the state for an overfunded expired short, ensuring later updateExpiredOrders() will see an expired order with substantial usdtShorted.
  2. Inflating expiredNotClosedUSDT and settling expired shorts (Tx 0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9, block 39979801, BNB Chain 56)

    • The same attacker EOA calls the orchestrator with selector 0x1b51e969.
    • Decompilation shows this entrypoint calling 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).
      • WILL/USDT AMM pair USDT balance drops from 105120900137170629892524 to 28318751131889814072080 (delta -76802149005280815820444).
      • The orchestrator’s USDT balance increases from 0 to 205153639715236281623315.
      • The orchestrator’s WILL balance returns to zero.
    • These diffs are exactly consistent with 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.
  3. Profit realization to attacker EOA (Tx 0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d, block 39979822, BNB Chain 56)

    • The attacker EOA calls withdrawAll(address _token) on the orchestrator with the USDT token address.
    • Decompiled code for withdrawAll:
      • Enforces require(tx.origin == owner).
      • Reads the orchestrator’s entire token balance via an ERC-20 balanceOf.
      • Calls the token’s transfer to send the full balance to owner (the attacker EOA).
    • balance_diff_prestate_tracer.json for this tx shows:
      • At BEP20USDT mapping key corresponding to the orchestrator, USDT balance decreases by 205153639715236281623315.
      • At the mapping key corresponding to the EOA, USDT balance increases by the same amount.
    • After this transfer, the orchestrator holds no USDT, and the attacker EOA holds the entire 205153639715236281623315 USDT.

Profit computation and ACT nature

  • Summing the adversary cluster’s USDT across all three txs:
    • Before tx 1, the orchestrator holds 159724152910328277211793 USDT.
    • After tx 2 and tx 3, the EOA holds 205153639715236281623315 USDT and the orchestrator holds zero.
    • Net delta: 205153639715236281623315 - 159724152910328277211793 = 45429486804908004411522 USDT.
  • Summing 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.
  • All calls are standard, permissionless transactions to public functions on 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.

Selected trace-based evidence snippet

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.

6. Impact & Losses

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.

7. References

  • [1] Seed tx 0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d metadata and balance diffs: artifacts/root_cause/seed/56/0xc12ccc3bdaf3f0ec1efa09d089a0c1dbad05519e1eb0fa6475ffcc6317cbde4d
  • [2] Seed tx 0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9 metadata and balance diffs: artifacts/root_cause/seed/56/0xefe58a14fc0022872262678b358aaae64a26fe2389d09093eb14752ea99415e9
  • [3] withdrawAll(USDT) tx 0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d prestate tracer and balance diff: artifacts/root_cause/data_collector/iter_3/tx/56/0x4a40421fc7849c0f4b27331d4ccff3a6721a362c97e6a94dcfba0e480a4e155d
  • [4] Trading contract 0x566777ed780dbbe17c130ae97b9fbc0a3ab829df verified source and layout: artifacts/root_cause/data_collector/iter_2/contract/56/0x566777ed780dbbe17c130ae97b9fbc0a3ab829df
  • [5] Orchestrator 0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1 decompiled source: artifacts/root_cause/data_collector/iter_1/contract/56/0x63b4de190c35f900bb7adf1a13d66fb1f0d624a1/decompile