ASResearch Public Rebalance Drain
Exploit Transactions
0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26Victim Addresses
0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6DEthereum0x9Ab872A34139015Da07EE905529a8842a6142971Ethereum0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345CEthereumLoss Breakdown
Similar Incidents
V3Utils Arbitrary Call Drain
32%NOON Pool Drain via Public transfer
32%Vortex approveToken Drain
32%Dexible selfSwap allowance drain
31%PumpToken removeLiquidityWhenKIncreases Uniswap LP Drain
31%WETH Drain via Unprotected 0xfa461e33 Callback on 0x03f9-62c0
30%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()returns0x9Ab872A34139015Da07EE905529a8842a6142971.ExchangeBetweenPools.to_bank()returns0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345C.ExchangeBetweenPools.curve()returns Curve yPool0x45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51.ExchangeBetweenPools.minimum_amount()returns100000000, 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.
- Flash funding and price move. The sender EOA
0xc0ffeebabe5d496b2dde509f9fa189c25cf29671funds execution and triggers attacker contract0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3, which borrows120000000000raw USDC from Uniswap V3 and swaps it into Curve yPool for71076010673raw USDT. - 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 withmin_dy = 0, and transfers the resulting47391213raw USDT to the ASResearch USDT bank. - Unwind and profit realization. The attacker swaps the previously acquired
71076010673raw USDT back through Curve for231512389841raw USDC, repays the flash loan plus fee, and keeps111500389841raw USDC. The native balance diff shows the sender EOA later receiving58470713463528725006wei and secondary recipient0xfeebabe6b0418ec13b30aadf129f5dcdd4f70ceareceiving1036355512292965848wei 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 callsdoExchange.0xfeebabe6b0418ec13b30aadf129f5dcdd4f70cea: downstream ETH payout recipient.
6. Impact & Losses
The direct victim loss is the full depletion of the ASResearch USDC bank:
- Token:
USDC - Loss:
119023523157raw 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
ExchangeBetweenPoolssource:https://etherscan.io/address/0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D#code - Verified
ERC20TokenBanksource 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 for0x9Ab872...and0x21A3db...