All incidents

AEST Pair Drain

Share
Dec 07, 2022 08:57 UTCAttackLoss: 61,608.04 USDTPending manual check1 exploit txWindow: Atomic
Estimated Impact
61,608.04 USDT
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Dec 07, 2022 08:57 UTC → Dec 07, 2022 08:57 UTC

Exploit Transactions

TX 1BSC
0xca4d0d24aa448329b7d4eb81be653224a59e7b081fc7a1c9aad59c5a38d0ae19
Dec 07, 2022 08:57 UTCExplorer

Victim Addresses

0xddc0cff76bcc0ee14c3e73af630c029fe020f907BSC
0x40ed17221b3b2d8455f4f1a05cac6b77c5f707e3BSC

Loss Breakdown

61,608.04USDT

Similar Incidents

Root Cause Analysis

AEST Pair Drain

1. Incident Overview TL;DR

On BSC block 23695905, transaction 0xca4d0d24aa448329b7d4eb81be653224a59e7b081fc7a1c9aad59c5a38d0ae19 drained the AEST/USDT Pancake pair at 0x40ed17221b3b2d8455f4f1a05cac6b77c5f707e3. The attacker EOA 0x286e09932b8d096cba3423d12965042736b8f850 called helper contract 0x3cdfbee6ec194e5258d2f9557e2e41015fc8b6e8, borrowed USDT from DODO pool 0x9ad32e3054268b849b84a8dbcc7c8f7c52e4e69a, manipulated the pair’s AEST accounting, and exited with 61608037844960494164175 raw USDT units.

The root cause is a token-side accounting bug in AEST at 0xddc0cff76bcc0ee14c3e73af630c029fe020f907. AEST treats pair -> pair self-skims as fee-bearing sells inside _transfer(), which inflates swapFeeTotal, and its public distributeFee() then transfers the accumulated fee directly out of the pair with no authorization or reserve-safety check.

2. Key Background

The victim pair is a PancakeSwap pair holding AEST and USDT. Pancake pairs track reserves separately from current token balances. The public skim(address) function transfers any balance surplus above the stored reserves to the chosen recipient without updating reserves, while sync() updates reserves to current balances.

That behavior is normally safe only if the token being skimmed does not reinterpret the maintenance transfer as an economically meaningful trade. AEST breaks that assumption. In the verified source, the pair address is marked in automatedMarketMakerPairs, and _transfer() routes any transfer whose to is the pair through sellTokenAndFees(), unless it is the special from == address(this) && to == uniswapV2Pair mint-style path.

The exploit also depends on public flash liquidity. The trace shows the attacker helper using DODO’s public flashLoan() to source the capital needed to buy AEST, create pair-side excess inventory, and unwind the manipulated state in one transaction.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class ACT vulnerability, not a privileged incident. The core defect is that AEST conflates AMM housekeeping transfers with real sells. In the verified code, _transfer() executes sellTokenAndFees(from, to, amount) whenever automatedMarketMakerPairs[to] is true, and there is no carveout for from == to == uniswapV2Pair. As a result, PancakePair.skim(pair) causes the pair to transfer AEST to itself, but AEST still burns 3% and increases swapFeeTotal by 1% as though an external seller paid a fee. The attacker can first seed the pair with excess AEST, then loop self-skims to manufacture a large fee liability with no corresponding inbound fee vault.

The second defect is distributeFee(). The function is public and executes direct super._transfer(uniswapV2Pair, wallet, ...) calls to five fee wallets. Because the accumulated swapFeeTotal was manufactured from pair self-skims, distributeFee() pulls real pair inventory out to third parties. Once the pair is drained and sync() republishes the distorted reserves, the attacker can sell AEST back into the undercollateralized pool and extract USDT.

4. Detailed Root Cause Analysis

The relevant AEST logic is visible in the verified source:

function _transfer(address from, address to, uint256 amount) internal override {
    if (from == address(this) && to == uniswapV2Pair) {
        super._transfer(from, to, amount);
    } else {
        if (automatedMarketMakerPairs[from]) {
            buyTokenAndFees(from, to, amount);
        } else if (automatedMarketMakerPairs[to]) {
            sellTokenAndFees(from, to, amount);
        } else {
            super._transfer(from, to, amount);
        }
    }
}

function sellTokenAndFees(address from, address to, uint256 amount) internal {
    uint256 burnAmount = amount.mul(3).div(100);
    uint256 otherAmount = amount.mul(1).div(100);
    amount = amount.sub(burnAmount);
    swapFeeTotal = swapFeeTotal.add(otherAmount);
    super._burn(from, burnAmount);
    super._transfer(from, to, amount);
}

function distributeFee() public {
    uint256 mokeyFeeTotal = swapFeeTotal.mul(2);
    super._transfer(uniswapV2Pair, monkeyWallet, mokeyFeeTotal);
    super._transfer(uniswapV2Pair, birdWallet, swapFeeTotal);
    super._transfer(uniswapV2Pair, foundationWallet, swapFeeTotal);
    super._transfer(uniswapV2Pair, technologyWallet, swapFeeTotal);
    super._transfer(uniswapV2Pair, marketingWallet, swapFeeTotal);
    swapFeeTotal = 0;
}

The safety invariant is straightforward: only legitimate economic trading flow should create fee liabilities, and no arbitrary external caller should be able to move pair inventory outside standard swap and liquidity paths. AEST breaks both parts of that invariant.

The trace of the incident transaction shows the exploit path clearly:

... DODO flashLoan(0, 836944312816628772067694, helper, ...)
... PancakeRouter::swapExactTokensForTokensSupportingFeeOnTransferTokens(
      100000000000000000000000, 0, [USDT, AEST], helper, ...
    )
... PancakePair::skim(pair)
... PancakePair::skim(pair)
... PancakePair::skim(pair)
... AEST::swapFeeTotal() -> 287941712182741379993961
... AEST::distributeFee()
... PancakePair::sync()
... PancakeRouter::swapExactTokensForTokensSupportingFeeOnTransferTokens(
      1397994775043681721471611, 0, [AEST, USDT], helper, ...
    )

At the end of the self-skim loop, the trace records AEST::swapFeeTotal() -> 287941712182741379993961. The subsequent distributeFee() emits transfers from the pair to the five fee wallets totaling 1727650273096448279963766 AEST, which is exactly 6 * swapFeeTotal. Immediately after that, sync() updates the pair reserves to the drained AEST balance, enabling the attacker’s exit swap to clear at a distorted price.

The balance diff confirms the state impact. The pair lost 2599771263199880212089390 AEST and 61608037844960494164175 USDT. The attacker EOA gained exactly 61608037844960494164175 raw USDT units, while the fee wallets received the drained AEST from distributeFee(). This matches the code-level mechanism and leaves no unresolved step.

5. Adversary Flow Analysis

The exploit is a single-transaction ACT sequence:

  1. The attacker EOA 0x286e...f850 calls helper contract 0x3cdf...b6e8.
  2. The helper borrows 836944312816628772067694 raw USDT units from the public DODO pool.
  3. It uses 100000000000000000000000 USDT to buy AEST through the Pancake router.
  4. It transfers part of the purchased AEST back to the pair, creating an AEST balance surplus above stored reserves.
  5. It repeatedly calls PancakePair.skim(pair). Because AEST interprets each self-transfer as a sell, every iteration burns pair inventory and increases swapFeeTotal.
  6. Once swapFeeTotal is large enough, it calls public AEST.distributeFee(), which sends AEST directly from the pair to the configured fee wallets.
  7. It calls PancakePair.sync() so the pair reserves now reflect the drained AEST balance.
  8. It sells the attacker-held AEST back into the manipulated pair for USDT.
  9. It repays the DODO flash-loan principal and transfers the remaining USDT to the attacker EOA.

This sequence is permissionless end to end. Every externalized primitive in the flow is public: flash loan, router swap, pair skim, pair sync, and fee distribution.

6. Impact & Losses

The measurable loss in the collected balance diff is 61608037844960494164175 raw USDT units extracted from the pair and received by the attacker EOA. The pair also lost 2599771263199880212089390 raw AEST units over the exploit path, including the forced fee distribution and token burns.

Affected components are:

  • AEST token contract 0xddc0cff76bcc0ee14c3e73af630c029fe020f907
  • AEST/USDT Pancake pair 0x40ed17221b3b2d8455f4f1a05cac6b77c5f707e3

The impact is not limited to the single observed transaction. As long as pair self-skims are fee-bearing and distributeFee() remains public and pair-funded, any unprivileged actor can recreate the same state transition on the public pre-state when sufficient liquidity exists.

7. References

  • Incident transaction: 0xca4d0d24aa448329b7d4eb81be653224a59e7b081fc7a1c9aad59c5a38d0ae19
  • Attacker EOA: 0x286e09932b8d096cba3423d12965042736b8f850
  • Attacker helper: 0x3cdfbee6ec194e5258d2f9557e2e41015fc8b6e8
  • Victim token: 0xddc0cff76bcc0ee14c3e73af630c029fe020f907
  • Victim pair: 0x40ed17221b3b2d8455f4f1a05cac6b77c5f707e3
  • DODO flash-loan pool: 0x9ad32e3054268b849b84a8dbcc7c8f7c52e4e69a
  • Verified AEST source: Contract.sol collected under the AEST seed artifact
  • Opcode-level execution trace: trace.cast.log collected for the incident transaction
  • State delta evidence: balance_diff.json collected for the incident transaction