All incidents

BatchSwap Counterpart Reentrancy

Share
Dec 16, 2023 15:22 UTCAttackLoss: 5 CloneXPending manual check1 exploit txWindow: Atomic
Estimated Impact
5 CloneX
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Dec 16, 2023 15:22 UTC → Dec 16, 2023 15:22 UTC

Exploit Transactions

TX 1Ethereum
0xec7523660f8b66d9e4a5931d97ad8b30acc679c973b20038ba4c15d4336b393d
Dec 16, 2023 15:22 UTCExplorer

Victim Addresses

0xc310e760778ecbca4c65b6c559874757a4c4ece0Ethereum
0x23938954bc875bb8309aef15e2dead54884b73dbEthereum

Loss Breakdown

5CloneX

Similar Incidents

Root Cause Analysis

BatchSwap Counterpart Reentrancy

1. Incident Overview TL;DR

On Ethereum mainnet block 18799488, attacker EOA 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d called helper contract 0x871f28e58f2a0906e4a56a82aec7f005b411f5c5, which used BatchSwap at 0xc310e760778ecbca4c65b6c559874757a4c4ece0 to steal five CloneX NFTs from holder 0x23938954bc875bb8309aef15e2dead54884b73db. The stolen tokenIds were 6670, 6650, 4843, 5432, and 9870, all from CloneX at 0x49cf6f5d44e70224e2e23fdcdd2c053f30ada28b.

The root cause is a reentrancy-enabled identity swap inside BatchSwap::closeSwapIntent(address,uint256). BatchSwap checks addressTwo == msg.sender, then performs an external ERC721 transfer that can invoke onERC721Received, while BatchSwap::editCounterPart(uint256,address payable) still lets the swap creator rewrite addressTwo. When execution resumes, BatchSwap reuses the mutated addressTwo field for later transfers and ends up pulling approved victim NFTs instead of settling only against the authenticated closer.

2. Key Background

BatchSwap stores each swap under swapList[creator][swapMatch[swapId]]. In that structure, addressOne is the swap creator and addressTwo is the current counterparty. closeSwapIntent is supposed to settle the swap after confirming that the caller is the intended counterparty.

The verified createSwapIntent implementation is important because it fixes addressOne = msg.sender when the swap is created. That means the attacker-controlled helper remains the authorized swap creator throughout the later callback window and can keep using creator-only functions such as editCounterPart.

BatchSwap supports arbitrary whitelisted ERC20, ERC721, ERC1155, and custom bridge assets. For ERC721 assets it uses safeTransferFrom, which means a contract recipient can synchronously reenter BatchSwap through onERC721Received.

The exploit pre-state was fully public at block 18799487:

  • BatchSwap had whitelisted CloneX and Uniswap V3 NonfungiblePositionManager (0xc36442b4a4522e871399cd717abdd847ab11fe88).
  • The victim holder had approved BatchSwap as an operator for CloneX.
  • The victim holder owned CloneX tokenIds 6670, 6650, 4843, 5432, and 9870.

Those conditions are confirmed in the collected pre-state checks and make the opportunity permissionless for any user who can deploy an ERC721-receiving helper contract and pay BatchSwap's public fees.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class ACT issue caused by mutable settlement identity crossing an external call. BatchSwap authenticates addressTwo once, but it does not snapshot that identity into immutable local state before making ERC721 transfers that can invoke callbacks. At the same time, editCounterPart remains callable by the swap creator during the same settlement. That combination breaks the invariant that the authenticated counterparty must stay fixed for the entire closeSwapIntent execution.

The verified BatchSwap source shows the vulnerable pattern directly:

function closeSwapIntent(address _swapCreator, uint256 _swapId) payable public whenNotPaused {
    require(swapList[_swapCreator][swapMatch[_swapId]].addressTwo == msg.sender, "You're not the interested counterpart");
    swapList[_swapCreator][swapMatch[_swapId]].addressTwo = msg.sender;

    ERC721Interface(...).safeTransferFrom(..., swapList[_swapCreator][swapMatch[_swapId]].addressTwo, ..., data);
    ...
    ERC721Interface(...).safeTransferFrom(
        swapList[_swapCreator][swapMatch[_swapId]].addressTwo,
        swapList[_swapCreator][swapMatch[_swapId]].addressOne,
        ...,
        data
    );
}

function editCounterPart(uint256 _swapId, address payable _counterPart) public {
    require(msg.sender == swapList[msg.sender][swapMatch[_swapId]].addressOne, "Message sender must be the swap creator");
    swapList[msg.sender][swapMatch[_swapId]].addressTwo = _counterPart;
}

The code-level breakpoint is the ERC721 safeTransferFrom inside closeSwapIntent. Once that call reaches an attacker-controlled receiver, the helper can call editCounterPart and replace addressTwo with an unrelated approved holder before BatchSwap reaches the next transfer that still consults storage.

4. Detailed Root Cause Analysis

The seed transaction is 0xec7523660f8b66d9e4a5931d97ad8b30acc679c973b20038ba4c15d4336b393d. In it, the attacker EOA calls helper 0x871f28e58f2a0906e4a56a82aec7f005b411f5c5, which pays the public BatchSwap fees, approves BatchSwap for a Uniswap V3 position NFT, and opens five swap intents.

Each malicious intent is opened with the helper as the initial addressTwo, so the helper satisfies BatchSwap's upfront counterparty check before settlement starts. Because createSwapIntent also records the helper as addressOne, the same contract is still authorized to call editCounterPart when the ERC721 callback fires.

The on-chain trace shows the exact reentrant pivot:

BatchSwap::closeSwapIntent(0x871f28..., 10351)
  NonfungiblePositionManager::safeTransferFrom(0x871f28..., 0x871f28..., 625712, ...)
    0x871f28...::onERC721Received(...)
      BatchSwap::editCounterPart(10351, 0x23938954bc875bb8309aef15e2dead54884b73db)
  CloneX::safeTransferFrom(0x23938954..., 0x871f28..., 6670, 0x)

The same trace pattern repeats for swapIds 10352 through 10355, pulling tokenIds 6650, 4843, 5432, and 9870 from the same victim. After each pull, the helper forwards the NFT to attacker EOA 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d.

The critical invariant is:

After `closeSwapIntent` authenticates `addressTwo`, every transfer in that settlement must use the same authenticated counterparty.

BatchSwap violates that invariant because it keeps reading swapList[_swapCreator][swapMatch[_swapId]].addressTwo from storage after the callback window. The attack does not require privileged keys, private orderflow, or hidden state. It only requires:

  • A public victim approval to BatchSwap for a whitelisted ERC721.
  • A whitelisted bait ERC721 that can trigger onERC721Received.
  • Enough ETH to pay BatchSwap's public fee schedule.

The balance diff independently confirms the exploit result: victim 0x23938954bc875bb8309aef15e2dead54884b73db goes from 5 to 0 CloneX, while attacker 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d goes from 0 to 5.

5. Adversary Flow Analysis

The attacker flow is a single adversary-crafted Ethereum transaction with three repeated stages:

  1. The helper creates a swap intent where it is both the creator and the initially authenticated counterparty.
  2. During closeSwapIntent, BatchSwap performs an ERC721 transfer that calls the helper's onERC721Received, and the helper immediately calls editCounterPart to replace addressTwo with victim 0x23938954bc875bb8309aef15e2dead54884b73db.
  3. BatchSwap resumes, pulls the victim's approved CloneX NFT into the helper, and the helper forwards the NFT to attacker EOA 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d.

The trace proves the adversary cluster:

  • 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d signs the seed transaction and ends with all five stolen NFTs.
  • 0x871f28e58f2a0906e4a56a82aec7f005b411f5c5 is the helper contract that creates the swap intents, executes the callback, mutates addressTwo, temporarily receives the stolen NFTs, and forwards them.

This is ACT because every prerequisite was visible in the public pre-state and every required call path was permissionless.

6. Impact & Losses

The direct loss is five CloneX NFTs taken from holder 0x23938954bc875bb8309aef15e2dead54884b73db in one transaction. The incident is best measured with a non-monetary ownership oracle because the decisive post-state is unauthorized NFT ownership transfer, not a token-denominated AMM or lending profit figure.

Loss summary:

  • CloneX: 5 tokens (decimal 0)
  • Victim holder: 0x23938954bc875bb8309aef15e2dead54884b73db
  • Exploit transaction: 0xec7523660f8b66d9e4a5931d97ad8b30acc679c973b20038ba4c15d4336b393d

7. References

  • Seed transaction trace for 0xec7523660f8b66d9e4a5931d97ad8b30acc679c973b20038ba4c15d4336b393d
  • Seed balance diff for the same transaction
  • Auditor pre-state checks at block 18799487
  • Verified BatchSwap source on Etherscan for 0xc310e760778ecbca4c65b6c559874757a4c4ece0
  • Verified CloneX source artifact for 0x49cf6f5d44e70224e2e23fdcdd2c053f30ada28b