Calculated from recorded token losses using historical USD prices at the incident time.
0x8c93d6e5d6b3ec7478b4195123a696dbc82a3441be090e048fe4b33a242ef09d0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21BSCAt BSC block 10090725, an unprivileged adversary exploited SurgeToken in transaction 0x8c93d6e5d6b3ec7478b4195123a696dbc82a3441be090e048fe4b33a242ef09d. The attacker used helper contract 0x5f2e29762fda6d33fd705644d8d2846582bf68d3 to flash-borrow 10000 WBNB from PancakePair 0x0ed7e52944161450477ee417de9cd3a859b14fd0, unwrap into BNB, repeatedly buy and sell SURGE against the victim treasury, repay 10030 WBNB, and keep 297.897796173333552624 BNB gross profit. Balance-diff evidence shows the victim contract 0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21 lost 327.897796173333552624 BNB while the pair earned the 30 WBNB fee and the attacker EOA ended up 297.895854838333552624 BNB higher net of gas.
The root cause is a deterministic accounting flaw combined with cross-function reentrancy. purchase() increases SurgeToken's BNB reserves by the full deposit but mints only 94% of the proportional token amount, while sell() sends BNB to the seller before reducing the seller balance and _totalSupply. Because the contract receive() path calls and is reachable during the callback, the attacker can rebuy against stale pre-burn state and ratchet the internal price upward on every loop.
purchase()sell()SurgeToken is a treasury-backed token whose on-chain price is defined directly by the contract as address(this).balance / _totalSupply. There is no external AMM setting SURGE price; users buy by sending BNB to the contract and sell by calling sell(uint256). The verified source on BscScan shows spreadDivisor = 94, sellFee = 94, and transferFee = 98, so buys, sells, and transfers all intentionally alter supply relative to notional value.
The exploit only needed public components. The attacker sourced temporary liquidity from the Pancake WBNB pair, used the canonical WBNB contract at 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c, and called public SurgeToken entrypoints. No admin key, allowlist, privileged oracle, or hidden order flow was required.
The bug is an application-level attack in SurgeToken's own reserve and supply accounting. In purchase(address buyer, uint256 bnbAmount), the contract computes the proportional mint amount from _totalSupply * bnbAmount / prevBNBAmount, then multiplies it by spreadDivisor / 100, so the treasury receives 100% of the BNB while supply increases by only 94% of the corresponding amount. In sell(uint256 tokenAmount), the contract computes amountBNB = tokenAmount * sellFee / 100 * calculatePrice() and performs payable(seller).call{value: amountBNB, gas: 40000}("") before subtracting tokenAmount from the seller balance and before reducing _totalSupply.
That ordering breaks the intended invariant that buy and sell flows should not let the same capital acquire a larger claim on the treasury without external value inflow. During the sell callback, the seller still owns the old token balance and _totalSupply still includes the tokens that are about to be burned. Because receive() immediately routes incoming BNB into purchase(), the attacker can reenter the mint path against stale state and mint a larger position than should be possible after the pending burn. Repeating that loop deterministically raises calculatePrice() and lets the attacker redeem progressively more BNB each time.
The relevant victim code, as published in the verified SurgeToken source, is:
function purchase(address buyer, uint256 bnbAmount) internal returns (bool) {
require(bnbAmount <= address(this).balance, "purchase not included in balance");
uint256 prevBNBAmount = address(this).balance - bnbAmount;
prevBNBAmount = prevBNBAmount == 0 ? address(this).balance : prevBNBAmount;
uint256 nShouldPurchase = _totalSupply * bnbAmount / prevBNBAmount;
uint256 tokensToSend = nShouldPurchase * spreadDivisor / 10**2;
mint(buyer, tokensToSend);
emit Transfer(address(this), buyer, tokensToSend);
return true;
}
function sell(uint256 tokenAmount) public nonReentrant returns (bool) {
address seller = msg.sender;
require(_balances[seller] >= tokenAmount, "cannot sell above token amount");
uint256 tokensToSwap = tokenAmount * sellFee / 10**2;
uint256 amountBNB = tokensToSwap * calculatePrice();
(bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}("");
if (successful) {
_balances[seller] = _balances[seller].sub(tokenAmount);
_totalSupply = _totalSupply.sub(tokenAmount);
} else {
revert();
}
emit Transfer(seller, address(this), tokenAmount);
return true;
}
function calculatePrice() public view returns (uint256) {
return address(this).balance / _totalSupply;
}
receive() external payable {
purchase(msg.sender, msg.value);
}
This code establishes the invariant violation directly. On buys, reserves increase faster than supply; on sells, reserves decrease before supply decreases. The attacker exploits the gap between those state transitions.
The trace confirms the exact nested execution path. After the pair transfers 10000 WBNB to the helper and the helper unwraps it, the helper buys SURGE with 10000 BNB. The helper then calls sell() four times, and during the first three payouts SurgeToken calls back into the helper, which immediately sends the received BNB back to SurgeToken through its payable receive() path. The trace records the payout ladder:
SurgeToken::sell(...) -> helper transfer 9346330934876995646045 wei
SurgeToken::sell(...) -> helper transfer 10148712010923246269691 wei
SurgeToken::sell(...) -> helper transfer 10313293221259630768530 wei
SurgeToken::sell(...) -> helper transfer 10327897796173333552624 wei
Immediately after the final payout, the helper wraps the remaining BNB back into WBNB, repays 10030 WBNB to the pair, and forwards 297897796173333552624 wei to the attacker EOA. The balance-diff artifact independently confirms the end state:
{
"victim_bnb_delta": "-327897796173333552624",
"pair_fee_delta": "30000000000000000000",
"attacker_eoa_net_delta": "297895854838333552624"
}
The flash swap is therefore only financing. The actual exploit predicate is the combination of asymmetric mint/burn accounting with a callback window that allows purchase() to execute before sell() finishes applying effects.
The adversary flow is a single adversary-crafted transaction from EOA 0x59c686272e6f11dc8701a162f938fb085d940ad3 into helper contract 0x5f2e29762fda6d33fd705644d8d2846582bf68d3.
swap(0, 10000 ether, address(this), data) and receives 10000 WBNB in the flash-swap callback.receive() -> purchase().sell(), the helper receive() function immediately routes the full payout back into SurgeToken, reentering purchase() while the outer sell() still has stale balance and supply state.sell() without another rebuy, wraps all remaining BNB into WBNB, repays 10030 WBNB to the pair, and transfers the residual BNB profit to the EOA.The trace excerpt below captures the nested shape of the attack:
helper -> SurgeToken::sell(...)
SurgeToken -> helper transfer 9346330934876995646045
helper -> SurgeToken::fallback{value: 9346330934876995646045}()
helper -> SurgeToken::sell(...)
SurgeToken -> helper transfer 10148712010923246269691
helper -> SurgeToken::fallback{value: 10148712010923246269691}()
helper -> SurgeToken::sell(...)
SurgeToken -> helper transfer 10313293221259630768530
helper -> SurgeToken::fallback{value: 10313293221259630768530}()
helper -> SurgeToken::sell(...)
SurgeToken -> helper transfer 10327897796173333552624
That execution is permissionless and reproducible from public chain state at block 10090724, so the incident is correctly classified as ACT.
SurgeToken lost 327.897796173333552624 BNB from its treasury in the exploit transaction. Of that amount, 30 WBNB became the flash-swap fee paid back to PancakePair and 297.897796173333552624 BNB remained with the attacker helper before it was forwarded to the attacker EOA. The net EOA increase is slightly lower because the seed transaction also paid 0.001941335 BNB in gas.
The affected victim is the public SurgeToken contract at 0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21. The loss is directly measurable from the collected pre/post-state balance diff and does not depend on speculative valuation.
0x8c93d6e5d6b3ec7478b4195123a696dbc82a3441be090e048fe4b33a242ef09d0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21 on BscScan0x0ed7e52944161450477ee417de9cd3a859b14fd00xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095ctrace.cast.log, and balance_diff.json under the seed artifact directory