AST liquidity-tracking flaw burns AST reserves and yields BNB profit
Exploit Transactions
0x80dd9362d211722b578af72d551f0a68e0dc1b1e077805353970b2f65e793927Victim Addresses
0xc10e0319337c7f83342424df72e73a70a29579b2BSC0x16b9a82891338f9ba80e2d6970fdda79d1eb0daeBSCLoss Breakdown
Similar Incidents
MorningStar releaseReward flaw drains MSC and yields WBNB profit
39%SlurpyCoin BuyOrSell flaw drains BNB via flash-loan swaps
36%Orion redeemAtomic exploit drains BNB and token reserves
36%YziLabs pool accounting flaw drains WBNB liquidity
34%AI IPC destroy-sync mechanism drains IPC-USDT pair USDT reserves
34%Mosca double-withdrawal exploit via helper on BNB
34%Root Cause Analysis
AST liquidity-tracking flaw burns AST reserves and yields BNB profit
1. Incident Overview TL;DR
On BSC at block 45964640, attacker EOA 0x56f77adc522bffebb3af0669564122933ab5ea4f sent a single adversary-crafted transaction to orchestrator proxy 0xaa0cee271f7c1a14cd0777283cb5741e46a2c732. Through a sequence of calls into helper 0xc8b9817eb65b7d7e85325f23a60d5839d14f9ce4, profit module 0x36696169c63e42cd08ce11f5deebbcebae652050, AST token 0xc10e0319337c7f83342424df72e73a70a29579b2, the AST/USDT Pancake pair 0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae, USDT, WBNB, and Pancake router 0x10ED43C718714eb63d5aA57B78B54704E256024E, the transaction injected USDT into the AST/USDT pair, triggered AST’s custom liquidity-tracking logic to burn AST from the pair, and routed the resulting advantage into BNB. The attacker’s BNB portfolio increased by 94.169955316293948336 BNB after gas, as measured from pre- to post-state in balance_diff.json.
The root cause is a design flaw in ASTToken’s liquidity-tracking mechanism: checkLiquidityAdd and checkLiquidityRm infer user liquidity additions and removals from global USDT balance and LP totalSupply, and update lastBalance[user] based on those global changes, without tying that state to the user’s actual LP token balance. This allows an attacker-controlled contract to (a) create a synthetic positive lastBalance[orchestrator] entry via a USDT-only injection into the AST/USDT pair and an AST transfer into the pair, and then (b) trigger checkLiquidityRm on a later AST transfer from the pair to the orchestrator, causing AST to burn 6.6883500045944535 AST from the pair while crediting 0 AST to the orchestrator and emitting LiquidityRemoved(0xaa0cee..., 46669047324966). The attacker then converts USDT flows into BNB, realizing an ACT profit.
2. Key Background
AST (0xc10e0319337c7f83342424df72e73a70a29579b2) is an ERC20-style token deployed on BSC that integrates directly with PancakeSwap. In its constructor, it sets the router to 0x10ED... on chainid 56, chooses USDT (0x55d398326f99059fF775485246999027B3197955) as the stable side, and creates an AST/USDT pair:
if(block.chainid == 56) {
uniswapV2Router = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
usdtAddress = 0x55d398326f99059fF775485246999027B3197955;
}
uniswapV2Pair = IPancakeFactory(IPancakeRouter(uniswapV2Router).factory())
.createPair(address(this), usdtAddress);
To track liquidity operations, AST maintains:
uint256 public pool_usdt;– a stored copy of the AST/USDT pair’s USDT balance.mapping(address => uint256) public lastBalance;– a per-address inferred LP “balance”.
AST overrides _afterTokenTransfer to watch for USDT balance changes on the pair and emit LiquidityAdded when they occur:
function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual {
amount;
if (from != address(0) && to != address(0)){
uint current = IERC20(usdtAddress).balanceOf(uniswapV2Pair);
if (current != pool_usdt){
pool_usdt = current;
emit LiquidityAdded(from, pool_usdt);
}
}
}
Within _transfer, AST calls checkLiquidityAdd and checkLiquidityRm on paths involving the AST/USDT pair:
if (to == uniswapV2Pair){
if (! checkLiquidityAdd(from)){
if (saleFeeRate > 0){
feeAmount = amount * saleFeeRate / 100;
_balances[sellfee] += feeAmount;
emit Transfer(from, sellfee, feeAmount);
}
}
}
if (from == uniswapV2Pair){
if (checkLiquidityRm(to)){
_burn(from, amount);
amount = 0;
} else {
if (buyFeeRate > 0){
feeAmount = amount * buyFeeRate / 100;
_balances[buyfee] += feeAmount;
emit Transfer(from, buyfee, feeAmount);
}
}
}
The AST/USDT pair 0x16b9a8... is a standard PancakePair with immutable factory, token0/token1, and reserves, and no privileged owner fields; all swaps and liquidity operations are permissionless. Reserves and Sync/Swap events for the exploit block are captured in reserves_lp_timeseries.json under artifacts/root_cause/data_collector/iter_3/pair_state/56/0x16b9a828.../.
The adversary-related cluster is:
- EOA
0x56f7...– sender of the exploit transaction and beneficiary of the BNB profit. - Orchestrator proxy
0xaa0cee...– deployed and funded by0x56f7..., receives the EOA call, and delegatecalls its implementation. - Delegate implementation
0xaae196...– target of the proxy’s delegatecall and host of the strategy logic.
Helper 0xc8b9... and profit module 0x3669... are general-purpose contracts (with local source trees under artifacts/root_cause/data_collector/iter_1/contract/.../source/) used by multiple EOAs; they route USDT into WBNB via Pancake pools and then unwrap WBNB to native BNB.
3. Vulnerability Analysis & Root Cause Summary
AST’s liquidity-tracking design attempts to infer per-user liquidity behavior from changes in the AST/USDT pair’s global USDT balance and total LP supply, instead of reading and enforcing the user’s actual LP token holdings when crediting lastBalance[user]. checkLiquidityAdd(user) treats an increase in the pair’s USDT balance as liquidity addition by user and updates both pool_usdt and lastBalance[user] using lp.totalSupply(), even if the LP tokens were minted to someone else or no LP tokens were minted to user. Later, when AST tokens move from the pair to user, checkLiquidityRm(user) compares the actual LP balance against lastBalance[user] and treats a drop as liquidity removal, emitting LiquidityRemoved and enabling _burn of AST when AST transfers originate from the pair.
In the exploit transaction, the attacker uses helper and orchestrator contracts to (1) send a large amount of USDT into the AST/USDT pair while driving an AST transfer from the orchestrator to the pair, causing checkLiquidityAdd(0xaa0cee...) to set lastBalance[0xaa0cee...] > 0 even though 0xaa0cee... holds zero actual LP tokens, and then (2) trigger an AST transfer from the pair back to 0xaa0cee..., causing checkLiquidityRm(0xaa0cee...) to treat the situation as a liquidity removal and burn AST from the pair while crediting 0 AST to 0xaa0cee.... This breaks the intended invariant that burns and LiquidityRemoved events for a user must correspond to genuine LP token reductions by that user, and it creates exploitable, permissionless arbitrage-style profit on the AST/USDT pair.
4. Detailed Root Cause Analysis
The key functions are checkLiquidityAdd and checkLiquidityRm in ASTToken.sol:
function checkLiquidityRm(address user) internal returns (bool) {
IERC20 lpToken = IERC20(uniswapV2Pair);
uint256 currentBalance = lpToken.balanceOf(user);
uint256 previousBalance = lastBalance[user];
if (currentBalance < previousBalance) {
emit LiquidityRemoved(user, previousBalance - currentBalance);
lastBalance[user] = currentBalance;
return true;
}
return false;
}
function checkLiquidityAdd(address user) internal returns (bool) {
IERC20 lpToken = IERC20(usdtAddress);
IERC20 lp = IERC20(uniswapV2Pair);
uint256 currentBalance = lpToken.balanceOf(uniswapV2Pair);
if (currentBalance > pool_usdt) {
emit LiquidityAdded(user, pool_usdt);
uint rate = (currentBalance - pool_usdt) * 1e10 / currentBalance;
pool_usdt = currentBalance;
lastBalance[user] += lp.totalSupply() * rate / 1e10;
return true;
}
return false;
}
Intuitively:
checkLiquidityAdd(user)looks only at the pair’s global USDT balance and totalSupply and infers thatuserhas added liquidity, increasinglastBalance[user], even if the LP tokens were minted to someone else or no LP tokens were minted at all touser.checkLiquidityRm(user)then compares the actual LP balance ofuseragainst this inferredlastBalance[user]and treats any drop as liquidity removal, emittingLiquidityRemovedand enabling_burnof AST when AST transfers originate from the pair.
The exploit transaction 0x80dd9362d211722b578af72d551f0a68e0dc1b1e077805353970b2f65e793927 at block 45964640 proceeds through the orchestrator and helper contracts to arrange the following critical on-chain sequence (excerpted from trace.cast.log under artifacts/root_cause/seed/56/0x80dd93.../trace.cast.log):
├─ BEP20USDT::transfer(0xAa0cee..., 30000000000000000000000000)
│ ...
│ ├─ BEP20USDT::transfer(PancakePair: [0x5ffEc8...], 100000000000000000)
│ │ ├─ BEP20USDT::transfer(0xAa0cee..., 100000000000000000)
│ │ │ ├─ BEP20USDT::transfer(0xAa0cee..., 30080145197852789656133646)
│ ...
│ ├─ AST::transfer(PancakePair: [0x5ffEc8...], 6688350004594453500)
│ │ ├─ BEP20USDT::balanceOf(PancakePair: [0x5ffEc8...]) [staticcall]
│ │ ├─ emit LiquidityAdded(user: 0xAa0cee..., amount: 30080145197852789777147003)
│ │ ├─ PancakePair::totalSupply() [staticcall]
│ │ ├─ emit Transfer(from: 0xAa0cee..., to: PancakePair: [0x5ffEc8...], value: 6688350004594453500)
│ │ ├─ storage:
│ │ │ @ pool_usdt (slot 5): ... → 0x00000000000000000000000000000000000000000018e1b7f1abfcf901aaa47b
│ │ │ @ lastBalance[0xaa0cee...] (slot 0xbacef8...): 0 → 0x00000000000000000000000000000000000000000000000000002a71fbfce126
│ ...
│ ├─ PancakePair::skim(0xAa0cee...)
│ │ ├─ BEP20USDT::transfer(0xAa0cee..., 100000000000000000)
│ │ ├─ AST::transfer(0xAa0cee..., 6688350004594453500)
│ │ │ ├─ PancakePair::balanceOf(0xAa0cee...) [staticcall] → 0
│ │ │ ├─ emit LiquidityRemoved(user: 0xAa0cee..., amount: 46669047324966)
│ │ │ ├─ emit Transfer(from: PancakePair: [0x5ffEc8...], to: 0x0000000000000000000000000000000000000000, value: 6688350004594453500)
│ │ │ ├─ emit Transfer(from: PancakePair: [0x5ffEc8...], to: 0xAa0cee..., value: 0)
│ │ │ ├─ BEP20USDT::balanceOf(PancakePair: [0x5ffEc8...]) [staticcall]
│ │ │ ├─ emit LiquidityAdded(user: PancakePair: [0x5ffEc8...], amount: 30080145197852789777147003)
│ │ │ ├─ storage:
│ │ │ │ @ lastBalance[0xaa0cee...] (slot 0xbacef8...): 0x...2a71fbfce126 → 0
│ │ │ │ @ _totalSupply (slot 6): 0x...9e6f7a8135d4866d4b3f → 0x...9e6f1daf6aeff9179f43
│ │ │ │ @ pool_usdt (slot 5): 0x...18e1b7f1abfcf901aaa47b → 0x...18e1b7f048b780a420a47b
From this trace and the AST storage summaries:
pool_usdtincreases by30,000,000,000,000,000,000,000,000(3e25) between pre- and post-state inast_storage_prepost_summary.json, matching the large USDT-only injection.lastBalance[0xaa0cee...]is0throughout blocks 45964000–45964639 (pool_usdt_lastBalance_timeseries_45964000-45964639.json), then becomes positive during the exploit transaction and returns to0by the end of the transaction, as shown in the trace’s storage changes._totalSupplydecreases by exactly6.6883500045944535AST (in wei) during the exploit transaction, matching the_burnassociated with the fake liquidity removal.
These steps violate the intended invariant:
- Invariant: For any user
U, AST burns andLiquidityRemoved(U, amt)must only occur whenU’s actual LP token balance decreases and the AST/USDT reserves change in a way consistent with genuine liquidity removal byU. - Breakpoint:
checkLiquidityAdd(0xaa0cee...)creditslastBalance[0xaa0cee...]based solely on global USDT andlp.totalSupply()without any LP tokens being minted to0xaa0cee..., andcheckLiquidityRm(0xaa0cee...)later interprets an AST transfer from the pair to0xaa0cee...as liquidity removal, emittingLiquidityRemoved(0xaa0cee..., 46669047324966)and burning6.6883500045944535AST from the pair whilePancakePair::balanceOf(0xaa0cee...)remains0.
Once this artificial burn and reserve distortion is in place, the adversary uses profit module 0x3669... and the Pancake router to route USDT gained from the AST/USDT pair into WBNB and then BNB. balance_diff.json for the seed transaction shows:
{
"native_balance_deltas": [
{
"address": "0x56f77adc522bffebb3af0669564122933ab5ea4f",
"before_wei": "5120724695396350352",
"after_wei": "99319695011690298688",
"delta_wei": "94198970316293948336"
},
{
"address": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"delta_wei": "-94227985316293948336"
}
]
}
After subtracting gas fees (29015000000000000 wei), the net profit in BNB is 94.169955316293948336 BNB, consistent with the success predicate recorded in root_cause.json.
5. Adversary Flow Analysis
The adversary lifecycle has two main stages:
-
Orchestrator deployment and funding
- At block 45964336, EOA
0x56f7...deploys orchestrator proxy0xaa0cee...(tx 0x3b6061...). - At block 45964522, the same EOA funds the orchestrator with
9BNB (tx 0x6b1ebc...). - The txlist under
artifacts/root_cause/data_collector/iter_3/address/56/0xaa0cee.../txlist_45960000-45970000.jsonshows no other privileged operations; the orchestrator acts as a standard strategy contract for the attacker.
- At block 45964336, EOA
-
Exploit transaction execution
- At block 45964640, EOA
0x56f7...sendstx 0x80dd93...to0xaa0cee...with selector0x1dbc4eeb. The orchestrator delegatecalls implementation0xaae196..., which sequences calls to helper0xc8b9..., AST, the AST/USDT pair, profit module0x3669..., Pancake router, WBNB, and back to the EOA. - Within helper/profit logic, a large amount of USDT is transferred into the AST/USDT pair, and AST is transferred from the orchestrator to the pair, causing
checkLiquidityAdd(0xaa0cee...)to setlastBalance[0xaa0cee...] > 0and updatepool_usdt, even though0xaa0cee...holds no LP tokens. - The strategy then calls
PancakePair::skim(0xaa0cee...), which pulls out excess USDT and triggers another AST transfer from the pair to0xaa0cee.... This AST transfer from the pair invokescheckLiquidityRm(0xaa0cee...), which seescurrentBalance < previousBalance(0 vs positivelastBalance) and emitsLiquidityRemoved(0xaa0cee..., 46669047324966), burns6.6883500045944535AST from the pair, and resetslastBalance[0xaa0cee...]to0. - A subsequent
PancakePair::sync()updates on-chain reserves to reflect the depleted AST and inflated USDT, establishing a mispriced AST/USDT pool state. - Finally, the strategy uses profit module
0x3669...and Pancake router0x10ED...to swap along AST/USDT and WBNB/BNB routes, extracting profit into BNB that is unwrapped and returned to the EOA.
- At block 45964640, EOA
All of these steps are composed of permissionless interactions with standard AMM contracts and tokens. Any unprivileged actor with sufficient BNB to pay gas and seed the necessary intermediate balances could deploy its own orchestrator implementation and reproduce the transaction sequence to realize the same ACT opportunity.
6. Impact & Losses
The primary economic impact is a transfer of value into the adversary’s portfolio, plus a permanent change in AST’s on-chain supply and the AST/USDT pool’s reserves:
- The attacker EOA’s native balance increases from
5.120724695396350352BNB to99.319695011690298688BNB, for a raw delta of94.198970316293948336BNB; after subtracting gas fees of0.029015BNB, the net profit is94.169955316293948336BNB. - WBNB (
0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c) shows a corresponding negative delta of-94.227985316293948336BNB-equivalent, indicating that value left the WBNB contract and ultimately accrued to the attacker. - AST’s total supply decreases by
6.6883500045944535tokens (as seen in_totalSupplypre/post inast_storage_prepost_summary.json), with the burn applied to the AST/USDT pair’s balance. - The AST/USDT pair’s reserves move from a balanced state before the exploit to a configuration with drastically reduced AST and increased USDT, as shown by the
SyncandSwapevents fortx 0x80dd93...inreserves_lp_timeseries.json, affecting AST’s market price on-chain.
These effects are localized to the AST/USDT ecosystem and WBNB/BNB pools but are fully realized on-chain and reproducible by any adversary exploiting the same vulnerability.
7. References
- Seed exploit transaction and balance differences:
artifacts/root_cause/seed/56/0x80dd93.../metadata.jsonandbalance_diff.jsonfortx 0x80dd93.... - AST token source and storage layout:
artifacts/root_cause/seed/56/0xc10e03.../src/sol1/ASTToken.solandast_storage_prepost_summary.json. - AST pool_usdt and lastBalance[attacker] timeseries before the exploit block:
artifacts/root_cause/data_collector/iter_4/storage/56/0xc10e03.../pool_usdt_lastBalance_timeseries_45964000-45964639.json. - AST/USDT pair contract and reserves timeseries:
artifacts/root_cause/data_collector/iter_3/contract/56/0x5ffec8.../source/etherscan_getsourcecode.jsonandreserves_lp_timeseries.jsonunderpair_state/56/0x16b9a8.... - Annotated exploit transaction call trace and AST storage diffs:
artifacts/root_cause/data_collector/iter_2/tx/56/0x80dd93.../annotated_call_trace.json,debug_trace_calltracer_raw.json, andast_storage_prepost_summary.json. - Helper and profit module contract sources: local source trees under
artifacts/root_cause/data_collector/iter_1/contract/56/0xc8b9.../source/and.../0x3669.../source/, used to understand how USDT and WBNB/BNB flows are routed.