All incidents

OxODex Stale Withdrawal Drain

Share
Sep 11, 2023 21:10 UTCAttackLoss: 40 ETHPending manual check1 exploit txWindow: Atomic
Estimated Impact
40 ETH
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Sep 11, 2023 21:10 UTC → Sep 11, 2023 21:10 UTC

Exploit Transactions

TX 1Ethereum
0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec
Sep 11, 2023 21:10 UTCExplorer

Victim Addresses

0x3d18ad735f949febd59bbfcb5864ee0157607616Ethereum
0x7d92b7dee17bb0d458caff9d409d8b768906efc8Ethereum

Loss Breakdown

40ETH

Similar Incidents

Root Cause Analysis

OxODex Stale Withdrawal Drain

1. Incident Overview TL;DR

At Ethereum mainnet block 18115708, transaction 0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec drained the OxODexPool proxy 0x3d18ad735f949febd59bbfcb5864ee0157607616. An unprivileged attacker EOA 0xcf28e9b8aa557616bc24cc9557ffa7fa2c013d53 deployed helper contract 0x842f3a53f82a9641d527c0e8378cec659f99faf4, borrowed 11 WETH from Balancer, primed OxODexPool with a 10 ETH swap-mode withdrawal, then called swapOnWithdrawal again on fresh 0.1 ETH rings using wType = Direct. Those later calls still consumed the stale 10 ETH-scale _lastWithdrawal value and routed roughly 9.97 ETH per call through Uniswap, draining pooled ETH into attacker-controlled USDC and then back into ETH.

The root cause is a call-accounting bug in OxODexPool implementation 0x6128d5f7c64dab48a1c66f9d62eaefa1d5aa03ed. withdraw only refreshes _lastWithdrawal when withdrawalData.wType == Swap, but swapOnWithdrawal always reads _lastWithdrawal after withdraw and never verifies that the current withdrawal is actually swap-mode. A prior large swap withdrawal therefore seeds stale state that later direct withdrawals can reuse to spend far more ETH than the current call should authorize.

2. Key Background

OxODexPool uses two-member LSAG rings for deposits and withdrawals. The verified implementation fixes MAX_RING_PARTICIPANT = 2, and each deposit writes both supplied public keys into the current ring and immediately closes the ring once both slots are filled. That design means an attacker can permissionlessly create a valid ring using only its own keys and can then generate a matching signature for withdrawal without any privileged counterparty.

The pool exposes two withdrawal modes. Direct sends ETH to the recipient immediately. Swap does not send ETH directly; instead it stores the withdrawn amount in _lastWithdrawal so swapOnWithdrawal can route that ETH into Uniswap V2. Pool fees are determined by the OxODexFactory proxy 0x7d92b7dee17bb0d458caff9d409d8b768906efc8, which charged 90 bps protocol fee and 30 bps relayer fee in the incident pre-state, but those settings do not restrict who may create rings, withdraw, or trigger swaps.

3. Vulnerability Analysis & Root Cause Summary

The failure is not missing access control on a privileged function; it is stale mutable state reused across logically different withdrawal modes. OxODexPool stores swap input size in a single contract-wide variable, _lastWithdrawal, instead of deriving swap size from the current call. In withdraw, direct withdrawals pay the recipient immediately and leave _lastWithdrawal unchanged, while swap withdrawals overwrite _lastWithdrawal with the current amount minus relayer gas charge. swapOnWithdrawal then calls withdraw, unconditionally assigns uint amountIn = _lastWithdrawal, charges relayer fee on that stale value, and forwards the result into UniswapV2Router02::swapExactETHForTokens.

The broken invariant is: for every swapOnWithdrawal call, the ETH routed into Uniswap must be derived from the current withdrawal being processed, and a Direct withdrawal must never trigger an additional swap larger than its own withdrawal amount. The concrete breakpoint is the unconditional uint amountIn = _lastWithdrawal; statement inside swapOnWithdrawal after withdraw(...) completes. Once _lastWithdrawal has been primed by an earlier swap-mode withdrawal, later direct-mode calls can spend that old value from pool funds.

4. Detailed Root Cause Analysis

The verified OxODexPool source shows both why ring creation is permissionless and why stale withdrawal state survives across calls:

uint256 constant MAX_RING_PARTICIPANT = 2;

function deposit(uint _amount, uint256[4] memory _publicKey) external payable whenNotPaused {
    uint256 ringIndex = ringsNumber[_amount];
    Ring storage ring = rings[_amount][ringIndex];
    ...
    ring.publicKeys[participants] = [_publicKey[0], _publicKey[1]];
    ring.publicKeys[participants + 1] = [_publicKey[2], _publicKey[3]];
    ...
    if (participants >= MAX_RING_PARTICIPANT) {
        ring.ringHash = hashRing(_amount, ringIndex);
        ringsNumber[_amount] += 1;
    }
}

function withdraw(address payable recipient, WithdrawalData memory withdrawalData, uint256 relayerGasCharge) public {
    ...
    if (withdrawalData.wType == Types.WithdrawalType.Direct) {
        _sendFundsWithRelayerFee(withdrawalData.amount - relayerGasCharge, recipient);
    } else {
        _lastWithdrawal = withdrawalData.amount - relayerGasCharge;
    }
}

function swapOnWithdrawal(..., WithdrawalData memory withdrawalData) external {
    withdraw(recipient, withdrawalData, relayerGasCharge);
    uint amountIn = _lastWithdrawal;
    ...
    router.swapExactETHForTokens{value: amountIn}(...);
}

That code yields the full exploit sequence. First, the attacker can create its own two-member ring because one deposit fills both ring slots. Second, a swap-mode withdrawal with amount = 10 ether sets _lastWithdrawal to the current withdrawal amount minus fees. Third, a later direct-mode withdrawal does not clear or update _lastWithdrawal, yet swapOnWithdrawal still uses the stale value to perform a Uniswap swap from pool ETH.

The collected execution trace shows the bug in action. The first critical call is a priming withdrawal:

OxODexPool::swapOnWithdrawal(... WithdrawalData({ amount: 10000000000000000000, ringIndex: 83, ..., wType: 1 }))
...
UniswapV2Router02::swapExactETHForTokens{value: 9970000000000000000}(...)

Immediately afterward, the attacker deposits a fresh 0.1 ETH ring and calls the same entrypoint with wType: 0 (Direct):

OxODexPool::swapOnWithdrawal(... WithdrawalData({ amount: 100000000000000000, ringIndex: 591, ..., wType: 0 }))
...
0x842f3A53F82a9641d527c0e8378cEc659f99FAF4::fallback{value: 100000000000000000}()
...
UniswapV2Router02::swapExactETHForTokens{value: 9970000000000000000}(...)

Those trace lines prove the current withdrawal only paid 0.1 ETH directly to the attacker helper while still causing another 9.97 ETH swap from the pool. This is the exact stale-state failure described by the source code. The collected balance diff independently confirms the pool native balance dropped from 39.9964 ETH to 0, while the attacker EOA rose from 1.38456 ETH to 40.774783326896694235 ETH, a net increase of 39.390223326896694235 ETH after gas.

5. Adversary Flow Analysis

The attacker executed the exploit in a single transaction.

  1. The attacker EOA deployed helper contract 0x842f3a53f82a9641d527c0e8378cec659f99faf4 and initiated a Balancer flash loan for 11 WETH.
  2. The helper unwrapped the borrowed WETH to ETH, deposited 10 ETH into OxODexPool, and withdrew via swapOnWithdrawal with wType = Swap, which primed _lastWithdrawal and converted 9.97 ETH into USDC.
  3. The helper then created fresh 0.1 ETH rings and repeatedly called swapOnWithdrawal with wType = Direct. Each call paid the nominal 0.1 ETH direct withdrawal, but because _lastWithdrawal still held the old 10 ETH-scale value, each call also triggered another 9.97 ETH Uniswap swap funded by the pool.
  4. After accumulating USDC, the helper approved Uniswap, swapped USDC back to ETH, wrapped enough ETH back to WETH to repay Balancer, and transferred the remaining ETH to the originating EOA.

This sequence is ACT-valid. No privileged address, private key compromise, or special off-chain side channel was needed. The attacker only used permissionless on-chain actions: deploying a helper, borrowing public flash liquidity, creating self-controlled rings, producing valid LSAG signatures for those rings, and calling public pool and AMM functions.

6. Impact & Losses

The measurable loss was the full native ETH balance of the victim pool proxy. The collected balance diff records:

  • OxODexPool proxy 0x3d18ad735f949febd59bbfcb5864ee0157607616: 39.9964 ETH before, 0 ETH after, delta -39.9964 ETH.
  • Attacker EOA 0xcf28e9b8aa557616bc24cc9557ffa7fa2c013d53: delta +39.390223326896694235 ETH after gas.

The difference between pool depletion and attacker net profit corresponds to gas plus OxODex’s standard fee flows during exploit-driven deposits and withdrawals. The relayer address 0x000097d4a261d7ad074089ca08efa2b136aa6d38 and treasurer 0xdf5888f30a4a99bd23913ae002d5af4dbf0502b4 both received their configured fee payments, but those flows do not change the root cause: pooled ETH was drained because direct withdrawals were allowed to reuse stale swap state.

7. References

  • Incident transaction: https://etherscan.io/tx/0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec
  • Victim pool proxy: https://etherscan.io/address/0x3d18ad735f949febd59bbfcb5864ee0157607616
  • Verified OxODexPool implementation: https://etherscan.io/address/0x6128d5f7c64dab48a1c66f9d62eaefa1d5aa03ed#code
  • Collector trace artifact: /workspace/session/artifacts/collector/seed/1/0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec/trace.cast.log
  • Collector balance diff artifact: /workspace/session/artifacts/collector/seed/1/0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec/balance_diff.json
  • Collector metadata artifact: /workspace/session/artifacts/collector/seed/1/0x00b375f8e90fc54c1345b33c686977ebec26877e2c8cac165429927a6c9bdbec/metadata.json