All incidents

AST liquidity-tracking flaw burns AST reserves and yields BNB profit

Share
Jan 21, 2025 16:00 UTCAttackLoss: 94.17 BNB, 6.69 ASTManually checked1 exploit txWindow: Atomic

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 by 0x56f7..., 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 that user has added liquidity, increasing lastBalance[user], even if the LP tokens were minted to someone else or no LP tokens were minted at all to user.
  • checkLiquidityRm(user) then compares the actual LP balance of user against this inferred lastBalance[user] and treats any drop as liquidity removal, emitting LiquidityRemoved and enabling _burn of 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_usdt increases by 30,000,000,000,000,000,000,000,000 (3e25) between pre- and post-state in ast_storage_prepost_summary.json, matching the large USDT-only injection.
  • lastBalance[0xaa0cee...] is 0 throughout blocks 45964000–45964639 (pool_usdt_lastBalance_timeseries_45964000-45964639.json), then becomes positive during the exploit transaction and returns to 0 by the end of the transaction, as shown in the trace’s storage changes.
  • _totalSupply decreases by exactly 6.6883500045944535 AST (in wei) during the exploit transaction, matching the _burn associated with the fake liquidity removal.

These steps violate the intended invariant:

  • Invariant: For any user U, AST burns and LiquidityRemoved(U, amt) must only occur when U’s actual LP token balance decreases and the AST/USDT reserves change in a way consistent with genuine liquidity removal by U.
  • Breakpoint: checkLiquidityAdd(0xaa0cee...) credits lastBalance[0xaa0cee...] based solely on global USDT and lp.totalSupply() without any LP tokens being minted to 0xaa0cee..., and checkLiquidityRm(0xaa0cee...) later interprets an AST transfer from the pair to 0xaa0cee... as liquidity removal, emitting LiquidityRemoved(0xaa0cee..., 46669047324966) and burning 6.6883500045944535 AST from the pair while PancakePair::balanceOf(0xaa0cee...) remains 0.

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:

  1. Orchestrator deployment and funding

    • At block 45964336, EOA 0x56f7... deploys orchestrator proxy 0xaa0cee... (tx 0x3b6061...).
    • At block 45964522, the same EOA funds the orchestrator with 9 BNB (tx 0x6b1ebc...).
    • The txlist under artifacts/root_cause/data_collector/iter_3/address/56/0xaa0cee.../txlist_45960000-45970000.json shows no other privileged operations; the orchestrator acts as a standard strategy contract for the attacker.
  2. Exploit transaction execution

    • At block 45964640, EOA 0x56f7... sends tx 0x80dd93... to 0xaa0cee... with selector 0x1dbc4eeb. The orchestrator delegatecalls implementation 0xaae196..., which sequences calls to helper 0xc8b9..., AST, the AST/USDT pair, profit module 0x3669..., 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 set lastBalance[0xaa0cee...] > 0 and update pool_usdt, even though 0xaa0cee... holds no LP tokens.
    • The strategy then calls PancakePair::skim(0xaa0cee...), which pulls out excess USDT and triggers another AST transfer from the pair to 0xaa0cee.... This AST transfer from the pair invokes checkLiquidityRm(0xaa0cee...), which sees currentBalance < previousBalance (0 vs positive lastBalance) and emits LiquidityRemoved(0xaa0cee..., 46669047324966), burns 6.6883500045944535 AST from the pair, and resets lastBalance[0xaa0cee...] to 0.
    • 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 router 0x10ED... to swap along AST/USDT and WBNB/BNB routes, extracting profit into BNB that is unwrapped and returned to the 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.120724695396350352 BNB to 99.319695011690298688 BNB, for a raw delta of 94.198970316293948336 BNB; after subtracting gas fees of 0.029015 BNB, the net profit is 94.169955316293948336 BNB.
  • WBNB (0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c) shows a corresponding negative delta of -94.227985316293948336 BNB-equivalent, indicating that value left the WBNB contract and ultimately accrued to the attacker.
  • AST’s total supply decreases by 6.6883500045944535 tokens (as seen in _totalSupply pre/post in ast_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 Sync and Swap events for tx 0x80dd93... in reserves_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.json and balance_diff.json for tx 0x80dd93....
  • AST token source and storage layout: artifacts/root_cause/seed/56/0xc10e03.../src/sol1/ASTToken.sol and ast_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.json and reserves_lp_timeseries.json under pair_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, and ast_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.