Reproduced Exploit
NFTTrader Exploit — Reentrancy via `editCounterPart()` During Swap Settlement
NFTTrader is a peer-to-peer NFT swap escrow. A user creates a swap intent listing the NFTs they offer (nftsOne) and the NFTs they expect from a counterpart (nftsTwo), and names a counterpart in addressTwo. When closeSwapIntent() is called by the counterpart, the contract moves nftsOne from the crea…
Loss
~$3M total across victims (per hacked.slowmist.io); this PoC drains 5 CloneX NFTs from one victim
Chain
Ethereum
Category
Reentrancy
Date
Dec 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-12-NFTTrader_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/NFTTrader_exp.sol.
Vulnerability classes: vuln/reentrancy/cross-function
One-line summary: A missing reentrancy guard in NFTTrader's
closeSwapIntent()lets the swap creator hijack theonERC721Receivedcallback (fired while settling their own NFT) to calleditCounterPart()and re-point the swap's counterpart to an arbitrary victim — causing the contract to pull the victim's pre-approved NFTs to the attacker for free.
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source (deployed name
BatchSwap): contracts_BatchSwap.sol.
Key info#
| Loss | ~$3M total across victims (per hacked.slowmist.io); this PoC drains 5 CloneX NFTs from one victim |
| Vulnerable contract | NFTTrader BatchSwap — 0xC310e760778ECBca4C65B6C559874757A4c4Ece0 |
| Victim (this PoC) | 0x23938954BC875bb8309AEF15e2Dead54884B73Db — had approved NFTTrader as CloneX operator |
| Asset stolen | CloneX NFTs (0x49cF6f5d44E70224e2E23fDcdd2C053F30aDA28B), tokenIds 6670, 6650, 4843, 5432, 9870 |
| Attacker EOA | 0xb1edf2a0ba8bc789cbc3dfbe519737cada034d2d |
| Attacker contract | 0x871f28e58f2a0906e4a56a82aec7f005b411f5c5 |
| Attack tx | 0xec7523660f8b66d9e4a5931d97ad8b30acc679c973b20038ba4c15d4336b393d |
| Chain / block / date | Ethereum mainnet / 18,799,414 (fork base) / Dec 16, 2023 |
| Compiler | Solidity v0.7.6, optimizer 200 runs |
| Bug class | Reentrancy — state mutation (editCounterPart) during an in-flight safeTransferFrom callback, combined with re-reading mutable swap state mid-settlement |
TL;DR#
NFTTrader is a peer-to-peer NFT swap escrow. A user creates a swap intent listing the NFTs they
offer (nftsOne) and the NFTs they expect from a counterpart (nftsTwo), and names a counterpart
in addressTwo. When closeSwapIntent() is called by the counterpart, the contract moves
nftsOne from the creator to the counterpart, then moves nftsTwo from the counterpart back to the
creator — a fair, atomic swap.
The settlement function has three fatal flaws that compose:
- No reentrancy guard.
closeSwapIntent()is notnonReentrant. - It re-reads
addressTwofrom storage in the second transfer loop instead of using a cached value (contracts_BatchSwap.sol:246-264). editCounterPart()can mutateaddressTwoat any time, by the creator, with no status check and no reentrancy guard (:346-349).
The attacker is both the creator (addressOne) and the counterpart (addressTwo) of their own
swap. They list one of their own NFTs (a freshly-minted Uniswap-V3 position) in nftsOne, and a
victim's NFT in nftsTwo. On closeSwapIntent():
- The first loop sends the attacker's own NFT from the contract... but in this attack the NFT in
nftsOneis actually one the attacker still owns and that NFTTrader pulls viasafeTransferFrom, which fires the attacker'sonERC721Received. - Inside that callback the attacker calls
editCounterPart(swapId, victim)— flipping the swap'saddressTwofrom attacker to victim. - The second loop then re-reads
addressTwo(now = victim) and executesCloneX.safeTransferFrom(victim → addressOne)— pulling the victim's CloneX NFT to the attacker.
Because the victim had previously set NFTTrader as an approved operator for CloneX, the transfer succeeds. The attacker pays nothing for the victim's NFT. Repeating the swap once per victim NFT drains all 5 CloneX tokens (victim balance 5 → 0, attacker 0 → 5).
Background — what NFTTrader does#
NFTTrader's BatchSwap contract (source) is an
escrow for two-sided NFT/ERC20 swaps:
createSwapIntent(:141-194) — the creator (addressOne = msg.sender) registers a swap, naming a counterpartaddressTwoand two asset lists:nftsOne(assets the creator escrows now) andnftsTwo(assets the counterpart will provide at close). Creator'snftsOneERC721s are pulled into the contract immediately.closeSwapIntent(:197-270) — callable byaddressTwo. Moves escrowednftsOnefrom the contract toaddressTwo, then movesnftsTwofromaddressTwotoaddressOne. This is the settlement.editCounterPart(:346-349) — lets the creator change who the counterpart is.whiteList— only whitelisted dapps (NFT contracts) can be swapped. Both CloneX (0x49cF…28B) and the Uniswap V3 Position NFT (0xC364…FE88) were whitelisted at the time of the attack (the trace shows both transfers passing thewhiteList[...]require checks).
The economic model assumes that once addressTwo calls close and provides their nftsTwo, the swap
is balanced: each side gives and receives. The exploit destroys that balance by changing who
addressTwo is between the "give" and the "receive."
The vulnerable code#
1. closeSwapIntent caches addressTwo, transfers nftsOne (callback!), then re-reads addressTwo#
function closeSwapIntent(address _swapCreator, uint256 _swapId) payable public whenNotPaused {
require(swapList[_swapCreator][swapMatch[_swapId]].status == swapStatus.Opened, "...");
require(swapList[_swapCreator][swapMatch[_swapId]].addressTwo == msg.sender, "...not the interested counterpart");
...
swapList[_swapCreator][swapMatch[_swapId]].addressTwo = msg.sender; // L216: addressTwo = attacker
swapList[_swapCreator][swapMatch[_swapId]].status = swapStatus.Closed; // L218
//From Owner 1 to Owner 2 (transfer nftsOne to addressTwo)
for(i=0; i<nftsOne[_swapId].length; i++) {
...
else if(nftsOne[_swapId][i].typeStd == ERC721) {
ERC721Interface(nftsOne[_swapId][i].dapp).safeTransferFrom(
address(this), swapList[...].addressTwo, nftsOne[_swapId][i].tokenId[0], ...); // L227-228 ⚠️ fires onERC721Received on attacker
}
...
}
...
//From Owner 2 to Owner 1 (transfer nftsTwo from addressTwo to addressOne)
for(i=0; i<nftsTwo[_swapId].length; i++) {
...
else if(nftsTwo[_swapId][i].typeStd == ERC721) {
ERC721Interface(nftsTwo[_swapId][i].dapp).safeTransferFrom(
swapList[...].addressTwo, // ⚠️ L252: RE-READ from storage — now == victim
swapList[...].addressOne,
nftsTwo[_swapId][i].tokenId[0], ...);
}
...
}
}
Lines 197-270. Two problems:
- L227-228: transferring
nftsOneviasafeTransferFrominvokesonERC721Receivedon the recipient (the attacker, who is the currentaddressTwo) — an external call before the second loop runs, with no reentrancy guard. - L252: the second loop reads
swapList[...].addressTwoagain from storage as thefromof the outgoing transfer. It is not a cached local, so a reentrant write toaddressTwotakes effect here.
2. editCounterPart — creator can re-point addressTwo at will, no guard, no status check#
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;
}
Lines 346-349. The only check is that
the caller is the swap creator (addressOne). There is:
- no
nonReentrantmodifier, - no
whenNotPaused, - no check that the swap is still
Opened(it can be edited even while/after closing), - no check that the swap is not currently being settled.
The attacker is the creator, so this require passes when called from inside the attacker's
onERC721Received callback.
3. The attacker's callback weaponizes both#
From the PoC (test/NFTTrader_exp.sol:184-193):
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
external returns (bytes4) {
// Flawed function. Lack of reentrancy protection
NFTTrader.editCounterPart(swapId, victim); // re-point addressTwo → victim, mid-settlement
return this.onERC721Received.selector;
}
Root cause — why it was possible#
The settlement of a swap is not atomic with respect to the swap's own parameters. Between moving
nftsOne out and moving nftsTwo in, control flow leaves the contract (via the ERC721
safeTransferFrom → onERC721Received hook on nftsOne), and during that window the swap's
counterpart address can be mutated by the creator.
Four design decisions compose into a critical bug:
- No reentrancy guard on
closeSwapIntent. AnonReentrantmodifier would have made the reentranteditCounterPartcall revert, killing the attack. addressTwois re-read from storage in the second transfer loop (L252) rather than captured into an immutable local at the top of the function. The first loop and the access-control check (L199) sawaddressTwo = attacker; the second loop sees whatever storage now holds.editCounterPartis callable mid-settlement. It enforces only "caller is creator," not "swap is open and not currently executing." Arequire(status == Opened)plus a reentrancy guard would block it.safeTransferFromof the creator's own NFT hands control to the creator. In a self-swap (attacker = creator = counterpart), thenftsOnerecipient is the attacker, so the attacker receives a callback during their own swap's settlement. (Even in a non-self swap, the standard ERC721 push hook is an attacker-controllable callback if the counterpart is a contract.)
The net effect: the attacker turns a balanced 1-for-1 swap (give my UniV3 NFT, get a CloneX NFT back
from the same counterpart) into a one-sided theft — the contract pulls the victim's CloneX NFT
because addressTwo was silently swapped to the victim after the access-control check already
passed. The victim's standing setApprovalForAll(NFTTrader, true) is what makes the pull succeed.
Preconditions#
- Victim has approved NFTTrader as an operator for the target NFT collection
(
CloneX.isApprovedForAll(victim, NFTTrader) == true). The PoC asserts this at test/NFTTrader_exp.sol:120. This is the standard approval a user grants to use the platform; it is the single resource the attack consumes. - Both NFT contracts are whitelisted in NFTTrader (
whiteList[CloneX]andwhiteList[UniV3PosNFT]are true). The trace shows bothwhiteList[...]requires passing. - The attacker controls a whitelisted, callback-bearing NFT to list in
nftsOne. AnysafeTransferFrom-based ERC721 works; the PoC mints a fresh Uniswap-V3 position NFT (tokenId 625712) with 0.001 ETH (test/NFTTrader_exp.sol:98-112). - Trivial capital. The attacker pays only swap fees (0.005 ETH per
createSwapIntent/closeSwapIntentin the PoC) plus ~0.001 ETH to mint the bait NFT. No flash loan required.
Step-by-step attack walkthrough (per victim NFT)#
The PoC loops this sequence 5 times, once per victim CloneX tokenId. The numbers below are for the first iteration (swapId 10348, CloneX tokenId 6670), taken directly from output.txt. Subsequent iterations are identical with the next tokenId.
| # | Action | On-chain effect (from trace) |
|---|---|---|
| 0 | Setup: mint UniV3 position NFT 625712 (0.001 ETH); setApprovalForAll(NFTTrader, true) for it | Attacker owns NFT 625712; NFTTrader approved to move it |
| 1 | createSwapIntent{value: 0.005 ETH} — addressTwo = attacker; nftsOne = [UniV3 625712], nftsTwo = [CloneX 6670] | Swap 10348 created, status Opened, addressTwo = attacker. UniV3 625712 stays with attacker (PoC lists it but contract doesn't pull it here) |
| 2 | closeSwapIntent{value: 0.005 ETH}(attacker, 10348) | Access check L199: addressTwo (attacker) == msg.sender (attacker) ✓. Sets addressTwo = attacker (L216), status Closed (L218) |
| 3 | → First loop transfers nftsOne[0] (UniV3 625712) via safeTransferFrom(attacker → attacker) | Self-transfer of the attacker's own NFT; fires onERC721Received on the attacker |
| 4 | → Reentrant editCounterPart(10348, victim) inside the callback | Storage write: swap 10348 addressTwo: attacker (0x7FA9…1496) → victim (0x2393…73Db) |
| 5 | → callback returns 0x150b7a02; control returns to closeSwapIntent | Second loop begins |
| 6 | → Second loop transfers nftsTwo[0] (CloneX 6670): CloneX.safeTransferFrom(addressTwo, addressOne, 6670) where addressTwo is re-read as victim (L252) | CloneX.safeTransferFrom(victim → attacker, 6670) — succeeds because victim approved NFTTrader. NFT 6670 moves victim → attacker |
| 7 | Repeat steps 1-6 for tokenIds 6650, 4843, 5432, 9870 (swapIds 10349-10352) | All 5 CloneX NFTs moved victim → attacker |
Final balances (from output.txt logs):
Victim CloneX balance before attack: 5
Exploiter CloneX balance before attack: 0
Victim CloneX balance after attack: 0
Exploiter CloneX balance after attack: 5
The trace confirms each editCounterPart storage write (e.g. the slot
0x003ee421…419d9 flips from the attacker address to the victim address) immediately followed by the
CloneX::safeTransferFrom(Victim → ContractTest, 6670).
Profit / loss accounting#
| Party | Out | In |
|---|---|---|
| Attacker | ~0.001 ETH (mint bait NFT) + 5 × 0.01 ETH swap fees ≈ 0.051 ETH | 5 CloneX NFTs (then ~$3M-class assets at the time, across all victims) |
| Victim | 5 CloneX NFTs (entire holdings) | nothing |
The attacker spent only gas + protocol fees and walked away with the victim's entire CloneX collection. No assets of the attacker's own were lost — the bait UniV3 NFT was only self-transferred.
Diagrams#
Sequence of one swap iteration#
Swap state evolution within closeSwapIntent#
Where the guard should have been#
Remediation#
- Add a reentrancy guard. Mark
closeSwapIntent,createSwapIntent,cancelSwapIntent, andeditCounterPartwith OpenZeppelin'snonReentrant. This alone defeats the attack: the reentranteditCounterPartcall would revert. - Cache the counterpart once and use the cached value. At the top of
closeSwapIntent, readaddress counterpart = swapList[...].addressTwo;into a local and use that local for both loops and all access checks. Never re-read mutable swap state from storage between the two transfer phases (:252 is the offending re-read). - Forbid mutation of an in-flight / closed swap.
editCounterPart(:346-349) mustrequire(swap.status == swapStatus.Opened)and benonReentrant, so it cannot be called during or after settlement. - Follow checks-effects-interactions and lock the swap during settlement. Set status to a
transient
Closingstate (or a boolean lock) before any external transfer, and reject any state edits while that lock is held. - Prefer pull/
transferFromsettlement or vet recipients. If callbacks must be tolerated, treat everysafeTransferFromas an untrusted external call and ensure no swap-defining state can change as a result of it.
NFTTrader's documented post-incident fix was to advise all users to revoke approvals to the vulnerable contract and to deploy a patched contract with reentrancy protection.
How to reproduce#
_shared/run_poc.sh 2023-12-NFTTrader_exp -vvvvv
- RPC: a mainnet archive endpoint is required (fork block 18,799,414, Dec 2023).
foundry.tomluses an Infura archive endpoint; pruned public RPCs fail withheader not found/missing trie node. - Result:
[PASS] testExploit().
Expected tail:
Ran 1 test for test/NFTTrader_exp.sol:ContractTest
[PASS] testExploit() (gas: 3094204)
Logs:
Victim CloneX balance before attack: 5
Exploiter CloneX balance before attack: 0
Victim CloneX balance after attack: 0
Exploiter CloneX balance after attack: 5
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: SlowMist Hacked — https://hacked.slowmist.io/ (NFTTrader, Ethereum, ~$3M). Analyses: @AnciliaInc, @SlowMist_Team, @0xArhat (linked in the PoC header).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-12-NFTTrader_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
NFTTrader_exp.sol.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "NFTTrader Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.