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:
- Pair reserves are at (R_STO, R_WBNB) based on last
sync() - Attacker calls
STO.transfer(pair, amount) - Inside
_update:_executePendingSellBurn()fires – burnspendingBurnFromSelltokens from the pair and callssync(). Pair reserves drop to (R_STO - burned, R_WBNB). - Inside
_update:afterTaxSTO tokens are transferred to the pair. Pair’s actual STO balance = (R_STO - burned + afterTax). - 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… - …BUT the
pendingBurnFromSellaccumulated in step 4 will burnafterTaxfrom 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:
| Step | Normal (single sell) | Attack (40 repeated sells) |
|---|---|---|
| Sell 1 | Transfer STO to pair, get WBNB. pendingBurnFromSell set. | Same as normal. |
| Sell 2 | Burns 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 N | N/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
- Attacker EOA (
0x622d...) deploys main contract, which creates a sub-contract (0xc2b3...). - Sub-contract calls
work()which flash-loans 360,894 WBNB from Moolah (0x8f73...). - Inside the flash loan callback, sub-contract sets up approvals and sends 0.0001 WBNB to the STO/WBNB pair as initial liquidity padding.
- 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. - Sub-contract claims dividends from the STO DividendTracker (
0x5261...), receiving ~461 STO. - Sub-contract removes liquidity from the pair via PancakeSwap Router, receiving STO and WBNB.
- Sub-contract sends excess WBNB to the pair (to restore initial WBNB balance), then calls
STO.approve()andSTO.initializeLiquidity(1 STO)with 1 wei – this mints tiny LP tokens and establishes the attacker as a participant. - Sub-contract swaps its remaining WBNB balance for STO via PancakeSwap Router (
swapExactTokensForTokensSupportingFeeOnTransferTokens). - Sub-contract executes 40 sell cycles: in each cycle, it calls
getAmountsOut()to calculate expected WBNB output, thenSTO.transfer(pair, amount)(triggers_executePendingSellBurn+sync), thenpair.swap()to extract WBNB. Each cycle’s burn reduces pair STO reserves, making the next swap more profitable. - 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):
| Address | Asset | Net 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...) | WBNB | 0 (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()=0x322e9f04flashLoan(address,uint256,bytes)=0xe0232b42onMoolahFlashLoan(uint256,bytes)=0x13a1a562initializeLiquidity(uint256)=0x2298e8d3sync()=0xfff6cae9swap(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_executePendingSellBurnduring sell transfers; first sell has no pending burn so no sync)getReserves()calls: 46transfer()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)