V3Utils Arbitrary Call Drain
Exploit Transactions
0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5Victim Addresses
0x531110418d8591c92e9cbbfc722db8ffb604fafdEthereumLoss Breakdown
Similar Incidents
Spectra Router KYBERSWAP arbitrary call drains SdCrvCompounder
40%Public SwapGuard envelope enabled arbitrary transferFrom drain of CoW Settlement DAI allowance
37%Dexible selfSwap allowance drain
37%LiFi GasZipFacet / LibSwap arbitrary USDT transferFrom
35%Vortex approveToken Drain
35%NOON Pool Drain via Public transfer
35%Root Cause Analysis
V3Utils Arbitrary Call Drain
1. Incident Overview TL;DR
On Ethereum mainnet block 16653390, transaction 0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5 used the public V3Utils.swap() entrypoint at 0x531110418d8591c92e9cbbfc722db8ffb604fafd to drain pre-approved user funds. The attacker received 19805.581627 USDC and 4106.316699 USDT, for a deterministic nominal stablecoin gain of 23911.898326, while paying gas separately in ETH.
The root cause is that V3Utils trusts attacker-supplied token contracts and attacker-supplied swapData. A fake ERC20 can satisfy V3Utils's transfer accounting in _prepareAdd(), and _swap() then approves an attacker-chosen allowance target and executes an attacker-chosen low-level call. In the observed exploit, that call was repurposed into direct USDC.transferFrom and USDT.transferFrom calls against wallets that had already approved V3Utils.
2. Key Background
V3Utils is an ownerless helper contract intended to assist Uniswap V3 position workflows. Its public swap() function accepts tokenIn, tokenOut, amountIn, minAmountOut, recipient, and an opaque swapData blob that is decoded inside the contract and forwarded to an external router.
The incident did not require privileged keys, private orderflow, or any state not already public on Ethereum. At pre-state block 16653389, the drained holders already had positive balances and non-zero allowances to V3Utils:
- Holder
0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1bhad19305581627USDC, allowance38315581627, plus4106316699USDT with matching allowance. - Holder
0x4107a0a4a50ac2c4cc8c5a3954bc01ff134506b2had500000000USDC and allowance500000000.
Seven blocks before the drain, the attacker EOA 0x38f887a0fe01b9e4960d5c727519408fa7f32f70 deployed helper contract 0xd346f652a56d149d585b5447851928f42f61fb27 in transaction 0xe3141167472a7c05d625f1b15c2c63f833bc5ac2022af2a3407d74f172db917f. That helper was later used as the fake tokenIn and tokenOut in every malicious V3Utils.swap() call.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an arbitrary-call spend primitive hidden behind swap-style accounting. V3Utils.swap() first calls _prepareAdd() on attacker-selected token addresses, then calls _swap() with attacker-selected swapData, and finally returns any reported output or leftovers to the specified recipient. The contract assumes that tokenIn and tokenOut are honest ERC20s and that the decoded router call actually performs a legitimate swap.
That assumption is false. _prepareAdd() verifies receipt by comparing token.balanceOf(address(this)) before and after transferFrom, so a malicious ERC20-like helper can fabricate the exact balance delta V3Utils expects. _swap() then decodes (swapRouter, allowanceTarget, data) from fully attacker-controlled bytes, approves allowanceTarget, and executes swapRouter.call(data) without validating the router target, calldata semantics, or the authenticity of tokenIn and tokenOut.
The resulting invariant violation is straightforward: swap() must only spend the asset it actually received from the caller, but V3Utils can instead spend any third-party ERC20 whose holders approved V3Utils. Because the postcondition measures balance deltas only on the fake helper token, the real drain of USDC and USDT is invisible to V3Utils's own accounting and the call succeeds with amountOutMin = 0.
Relevant verified V3Utils code:
function swap(SwapParams calldata params) external payable returns (uint256 amountOut) {
_prepareAdd(params.tokenIn, IERC20(address(0)), IERC20(address(0)), params.amountIn, 0, 0);
uint amountInDelta;
(amountInDelta, amountOut) =
_swap(params.tokenIn, params.tokenOut, params.amountIn, params.minAmountOut, params.swapData);
uint leftOver = params.amountIn - amountInDelta;
if (leftOver > 0) {
_transferToken(params.recipient, params.tokenIn, leftOver, params.unwrap);
}
}
function _swap(IERC20 tokenIn, IERC20 tokenOut, uint amountIn, uint amountOutMin, bytes memory swapData)
internal
returns (uint amountInDelta, uint256 amountOutDelta)
{
uint balanceInBefore = tokenIn.balanceOf(address(this));
uint balanceOutBefore = tokenOut.balanceOf(address(this));
(address swapRouter, address allowanceTarget, bytes memory data) =
abi.decode(swapData, (address, address, bytes));
tokenIn.approve(allowanceTarget, amountIn);
(bool success,) = swapRouter.call(data);
if (!success) revert SwapFailed();
tokenIn.approve(allowanceTarget, 0);
amountInDelta = balanceInBefore - tokenIn.balanceOf(address(this));
amountOutDelta = tokenOut.balanceOf(address(this)) - balanceOutBefore;
if (amountOutDelta < amountOutMin) revert SlippageError();
}
4. Detailed Root Cause Analysis
The exploit path is the composition of swap() at lines 239-255, _prepareAdd() at lines 346-400, and _swap() at lines 531-560 of the verified V3Utils source.
Step 1: the attacker chooses a fake helper ERC20 as both tokenIn and tokenOut. In _prepareAdd(), V3Utils checks receipt by comparing balanceOf(address(this)) before and after transferFrom. A malicious helper can implement both functions however it likes, so it can make V3Utils believe exactly 1 token was received even though no valuable asset entered the contract.
Step 2: V3Utils decodes attacker-controlled swapData into a router address, an allowance target, and raw calldata. Nothing in _swap() restricts the router to a known DEX, checks that the calldata corresponds to a swap, or ties the target call to the supposed tokenIn and tokenOut.
Step 3: the attacker sets swapRouter to the real token contract and data to transferFrom(victim, attacker, amount). Because the victims had already approved V3Utils, the raw external call succeeds and the token contract treats V3Utils as the spender. The call therefore transfers victim USDC or USDT directly to the attacker EOA.
Step 4: V3Utils validates only the fake helper's balances after the raw call. Since the helper remains attacker-controlled, _swap() computes amountInDelta = 0 and amountOutDelta = 0, emits a Swap event on the fake token pair, and returns successfully because minAmountOut was set to 0.
The trace of the seed exploit shows exactly that sequence:
0x531110418d8591C92e9cBBFC722Db8FFb604FAFD::swap(
(0xD346f652A56D149d585b5447851928f42f61Fb27, 0xD346f652A56D149d585b5447851928f42f61Fb27, 1, 0, ...)
)
...
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::transferFrom(
0x067D0F9089743271058D4Bf2a1a29f4E9C6fdd1b,
0x38F887a0FE01b9e4960D5C727519408fA7f32F70,
19305581627
)
...
emit Swap(: 0xD346f652A56D149d585b5447851928f42f61Fb27, : 0xD346f652A56D149d585b5447851928f42f61Fb27, : 0, : 0)
The same pattern repeats for the second USDC victim and for USDT in the same transaction. The receipt-level balance changes confirm the exact drain amounts:
[
{"token":"USDC","from":"0x067d...","to":"0x38f887...","amount":"19305581627"},
{"token":"USDC","from":"0x4107...","to":"0x38f887...","amount":"500000000"},
{"token":"USDT","from":"0x067d...","to":"0x38f887...","amount":"4106316699"}
]
5. Adversary Flow Analysis
The attacker flow is short and entirely permissionless:
- Deploy helper contract
0xd346f652a56d149d585b5447851928f42f61fb27from EOA0x38f887a0fe01b9e4960d5c727519408fa7f32f70. - Observe public approvals to V3Utils and identify holders whose balances are spendable via
min(balance, allowance). - Call the helper, which in turn calls
V3Utils.swap()three times using the helper as bothtokenInandtokenOut. - For each call, encode
swapDataso that V3Utils executes a real token contract'stransferFrom(victim, attacker, amount)while its own bookkeeping still references only the fake helper. - Receive the drained funds directly at the attacker EOA.
The exploit transaction consumed three publicly visible approvals in one block:
19305581627USDC from0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1b500000000USDC from0x4107a0a4a50ac2c4cc8c5a3954bc01ff134506b24106316699USDT from0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1b
At pre-state block 16653389, the attacker EOA held 0 USDC and 0 USDT. At post-state block 16653390, it held 19805581627 USDC and 4106316699 USDT. The nominal stablecoin profit therefore moved deterministically from 0.000000 to 23911.898326, while gas cost was paid separately as 8048114758530028 wei.
6. Impact & Losses
The direct measured loss in the observed transaction was:
- USDC:
19805581627raw units (19805.581627USDC) - USDT:
4106316699raw units (4106.316699USDT)
The broader impact is larger than the single receipt. Any ERC20 holder who had granted V3Utils allowance could be drained by an unprivileged actor using the same public-state method. The exploit does not require compromising a router, owning a victim account, or replaying attacker-specific infrastructure. It only requires an attacker-controlled helper contract plus on-chain inspection of balances and approvals.
7. References
- Exploit transaction:
0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5 - Helper deployment transaction:
0xe3141167472a7c05d625f1b15c2c63f833bc5ac2022af2a3407d74f172db917f - V3Utils contract:
0x531110418d8591c92e9cbbfc722db8ffb604fafd - Helper contract:
0xd346f652a56d149d585b5447851928f42f61fb27 - USDC token:
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 - USDT token:
0xdac17f958d2ee523a2206206994597c13d831ec7 - Verified V3Utils source:
https://etherscan.io/address/0x531110418d8591c92e9cbbfc722db8ffb604fafd#code - Seed transaction metadata:
/workspace/session/artifacts/collector/seed/1/0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5/metadata.json - Seed trace:
/workspace/session/artifacts/collector/seed/1/0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5/trace.cast.log - Seed balance diff:
/workspace/session/artifacts/collector/seed/1/0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5/balance_diff.json - On-chain allowance snapshot:
/workspace/session/artifacts/auditor/iter_0/onchain_state_checks.json - Deterministic profit baselines:
/workspace/session/artifacts/auditor/iter_1/profit_baselines.json