All incidents

ASResearch Public Rebalance Drain

Share
May 31, 2023 05:54 UTCAttackLoss: 119,023.52 USDCPending manual check1 exploit txWindow: Atomic
Estimated Impact
119,023.52 USDC
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
May 31, 2023 05:54 UTC → May 31, 2023 05:54 UTC

Exploit Transactions

TX 1Ethereum
0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26
May 31, 2023 05:54 UTCExplorer

Victim Addresses

0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6DEthereum
0x9Ab872A34139015Da07EE905529a8842a6142971Ethereum
0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345CEthereum

Loss Breakdown

119,023.52USDC

Similar Incidents

Root Cause Analysis

ASResearch Public Rebalance Drain

1. Incident Overview TL;DR

In Ethereum mainnet transaction 0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26 at block 17376907, an unprivileged adversary used a flash-loaned USDC position to skew Curve yPool pricing, then called the public ASResearch rebalancer ExchangeBetweenPools at 0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D. That call forced the ASResearch USDC bank 0x9Ab872A34139015Da07EE905529a8842a6142971 to issue its full 119023523157 raw USDC balance to the rebalancer, which immediately sold it into Curve at an attacker-manipulated rate.

The root cause is an access-control and execution-price failure. ExchangeBetweenPools.doExchange(uint256) is publicly callable, and it spends bank funds through ERC20TokenBank.issue(address,uint256) because the bank trusts the rebalancer contract itself rather than the original caller. The same function then calls curve.exchange_underlying(1, 2, camount, 0), so the victim sale accepts any output amount and can be sandwiched by same-transaction price manipulation.

2. Key Background

ASResearch split treasury inventory across token banks. The exploited path moves value from the USDC bank into the USDT bank by swapping through Curve yPool.

  • ExchangeBetweenPools.from_bank() returns 0x9Ab872A34139015Da07EE905529a8842a6142971.
  • ExchangeBetweenPools.to_bank() returns 0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345C.
  • ExchangeBetweenPools.curve() returns Curve yPool 0x45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51.
  • ExchangeBetweenPools.minimum_amount() returns 100000000, so any call above 100 USDC can trigger the path.

The verified ERC20TokenBank source is published for the ASResearch USDT bank, and the exploited USDC bank has the same runtime codehash (0x9e5f14df291a98ad34f4a106be0cecf93b01df2587cae9a7101a0f0d953edd3f). The USDC bank therefore uses the same issue logic. On-chain, USDC_BANK.list() returns 0x7d8E92748F13E30F99386cd253F2ca7a0659321A, and TrustList.is_trusted(ExchangeBetweenPools) returns true.

Three background facts make the incident exploitable:

  • The rebalancer is public, so any EOA or helper contract can trigger it.
  • The bank authorizes the immediate caller contract, not the originating EOA.
  • Curve yPool pricing is manipulable inside one transaction when the attacker has temporary inventory from flash liquidity.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK case, not a benign arbitrage. The violated invariant is that only an authorized strategy with price protection should be able to move ASResearch bank assets, and a bank-funded rebalance must not execute at an attacker-controlled exchange rate. Instead, ASResearch delegated spend authority to ExchangeBetweenPools and exposed that authority through an unrestricted public function. The bank then honored issue(address(this), amount) because it checks whether msg.sender is trusted, not whether the external caller is authorized. Once the rebalancer received bank USDC, it approved Curve and executed exchange_underlying(..., 0), which removed any minimum-output protection. The attacker could therefore worsen the USDC to USDT price first, force the victim sale at the distorted rate, and then unwind the price move for profit. The exploit conditions were public and deterministic: sufficient victim inventory, the rebalancer remaining trusted by the bank, access to public flash liquidity, and the ability to move Curve yPool state before calling doExchange.

Collector-confirmed victim code excerpts:

function doExchange(uint256 amount) public returns(bool){
    require(amount >= minimum_amount, "invalid amount");
    require(amount <= ERC20TokenBankInterface(from_bank).balance(), "too much amount");

    ERC20TokenBankInterface(from_bank).issue(address(this), amount);

    uint256 camount = usdc.balanceOf(address(this));
    usdc.safeApprove(address(curve), camount);
    curve.exchange_underlying(1, 2, camount, 0);

    uint256 namount = usdt.balanceOf(address(this));
    usdt.safeTransfer(to_bank, namount);
    return true;
}
function issue(address _to, uint _amount)
  public
  is_trusted(msg.sender)
  returns (bool success){
    require(_amount <= balance(), "not enough tokens");
    (bool status,) = erc20_token_addr.call(
        abi.encodeWithSignature("transfer(address,uint256)", _to, _amount)
    );
    require(status, "call failed");
    emit issue_token(_to, _amount);
    return true;
}

4. Detailed Root Cause Analysis

The relevant ACT pre-state is Ethereum mainnet immediately before block 17376907. At that point the ASResearch USDC bank still held 119023523157 raw USDC, ExchangeBetweenPools was still on the USDC bank trust list, Curve yPool at 0x45F783... was live, and the Uniswap V3 DAI/USDC pool at 0x5777d92f... exposed enough public USDC liquidity for a flash loan.

The exploit transaction is fully visible in the collected trace. The attacker contract first borrowed 120000000000 raw USDC from the Uniswap V3 pool, then sold that USDC into Curve to worsen the victim's pending execution rate:

UniswapV3Pool::flash(..., amount1: 120000000000)
Vyper_contract::exchange_underlying(1, 2, 120000000000, 0)
emit TokenExchangeUnderlying(
  buyer: 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3,
  sold_id: 1,
  tokens_sold: 120000000000,
  bought_id: 2,
  tokens_bought: 71076010673
)

With Curve pricing displaced, the attacker invoked the public ASResearch rebalance path. The trace shows the exact trust and spend sequence:

ExchangeBetweenPools::doExchange(119023523157)
ERC20TokenBank::issue(ExchangeBetweenPools, 119023523157)
TrustList::is_trusted(ExchangeBetweenPools) -> true
emit Transfer(
  from: ERC20TokenBank: [0x9Ab872A34139015Da07EE905529a8842a6142971],
  to:   ExchangeBetweenPools: [0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D],
  value: 119023523157
)
Vyper_contract::exchange_underlying(1, 2, 119023523157, 0)
emit TokenExchangeUnderlying(
  buyer: ExchangeBetweenPools: [0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D],
  sold_id: 1,
  tokens_sold: 119023523157,
  bought_id: 2,
  tokens_bought: 47391213
)

That trace excerpt is the code-level breakpoint. The trusted rebalancer withdraws the full bank balance and sells it with min_dy = 0, so the bank-funded swap inherits the attacker-distorted Curve price. The rebalancer then forwards only 47391213 raw USDT to the ASResearch USDT bank, far below the value of the USDC inventory it consumed.

After the victim sale moved the pool in the opposite direction, the attacker unwound the initial price-manipulation leg:

Vyper_contract::exchange_underlying(2, 1, 71076010673, 0)
emit TokenExchangeUnderlying(
  buyer: 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3,
  sold_id: 2,
  tokens_sold: 71076010673,
  bought_id: 1,
  tokens_bought: 231512389841
)
UniswapV3Pool::flash(... paid1: 12000000)

The attacker repaid 120012000000 raw USDC to Uniswap V3 and retained 111500389841 raw USDC before later converting proceeds into ETH. This matches the success predicate in root_cause.json: the adversary started with zero USDC, paid a 12000000 raw USDC flash fee and 0.05359265021083305 ETH in gas, and still realized six-figure USDC profit.

The broken security principles are straightforward:

  • Trusted contracts must not expose unrestricted public methods that spend treasury inventory.
  • Protocol-funded swaps require caller authorization and minimum-output protection.
  • Rebalance flows that depend on instantaneous AMM state must defend against same-transaction price manipulation.

5. Adversary Flow Analysis

The adversary flow is a single-transaction, three-stage sequence.

  1. Flash funding and price move. The sender EOA 0xc0ffeebabe5d496b2dde509f9fa189c25cf29671 funds execution and triggers attacker contract 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3, which borrows 120000000000 raw USDC from Uniswap V3 and swaps it into Curve yPool for 71076010673 raw USDT.
  2. Victim-funded rebalance trigger. The attacker calls ExchangeBetweenPools.doExchange(119023523157), causing the ASResearch USDC bank to issue its entire balance to the trusted rebalancer. The rebalancer approves Curve, swaps the full amount with min_dy = 0, and transfers the resulting 47391213 raw USDT to the ASResearch USDT bank.
  3. Unwind and profit realization. The attacker swaps the previously acquired 71076010673 raw USDT back through Curve for 231512389841 raw USDC, repays the flash loan plus fee, and keeps 111500389841 raw USDC. The native balance diff shows the sender EOA later receiving 58470713463528725006 wei and secondary recipient 0xfeebabe6b0418ec13b30aadf129f5dcdd4f70cea receiving 1036355512292965848 wei from exploit proceeds.

The adversary-related accounts identified by the auditor are supported by the evidence:

  • 0xc0ffeebabe5d496b2dde509f9fa189c25cf29671: transaction sender and final ETH recipient.
  • 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3: attacker contract that receives the flash loan, executes both Curve swaps, and calls doExchange.
  • 0xfeebabe6b0418ec13b30aadf129f5dcdd4f70cea: downstream ETH payout recipient.

6. Impact & Losses

The direct victim loss is the full depletion of the ASResearch USDC bank:

  • Token: USDC
  • Loss: 119023523157 raw units
  • Decimals: 6
  • Human-readable loss: 119,023.523157 USDC

The bank's USDC balance went from 119023523157 to 0 in the incident transaction. The paired ASResearch USDT bank received only 47391213 raw USDT (47.391213 USDT) from the forced rebalance. The attacker retained 111500389841 raw USDC (111,500.389841 USDC) after repaying the flash loan and flash fee, then converted proceeds into ETH. This outcome is consistent with the collector balance diff and the transaction trace.

7. References

  • Incident transaction: 0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26
  • Collector transaction metadata: /workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/metadata.json
  • Collector trace: /workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/trace.cast.log
  • Collector balance diff: /workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/balance_diff.json
  • Verified ExchangeBetweenPools source: https://etherscan.io/address/0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D#code
  • Verified ERC20TokenBank source template: https://etherscan.io/address/0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345C#code
  • On-chain validator confirmations: from_bank(), to_bank(), curve(), minimum_amount(), list(), token(), is_trusted(address), and matching bank codehashes for 0x9Ab872... and 0x21A3db...