All incidents

V3Utils Arbitrary Call Drain

Share
Feb 18, 2023 05:16 UTCAttackLoss: 19,805.58 USDC, 4,106.32 USDTPending manual check1 exploit txWindow: Atomic
Estimated Impact
19,805.58 USDC, 4,106.32 USDT
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Feb 18, 2023 05:16 UTC → Feb 18, 2023 05:16 UTC

Exploit Transactions

TX 1Ethereum
0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5
Feb 18, 2023 05:16 UTCExplorer

Victim Addresses

0x531110418d8591c92e9cbbfc722db8ffb604fafdEthereum

Loss Breakdown

19,805.58USDC
4,106.32USDT

Similar Incidents

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 0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1b had 19305581627 USDC, allowance 38315581627, plus 4106316699 USDT with matching allowance.
  • Holder 0x4107a0a4a50ac2c4cc8c5a3954bc01ff134506b2 had 500000000 USDC and allowance 500000000.

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:

  1. Deploy helper contract 0xd346f652a56d149d585b5447851928f42f61fb27 from EOA 0x38f887a0fe01b9e4960d5c727519408fa7f32f70.
  2. Observe public approvals to V3Utils and identify holders whose balances are spendable via min(balance, allowance).
  3. Call the helper, which in turn calls V3Utils.swap() three times using the helper as both tokenIn and tokenOut.
  4. For each call, encode swapData so that V3Utils executes a real token contract's transferFrom(victim, attacker, amount) while its own bookkeeping still references only the fake helper.
  5. Receive the drained funds directly at the attacker EOA.

The exploit transaction consumed three publicly visible approvals in one block:

  • 19305581627 USDC from 0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1b
  • 500000000 USDC from 0x4107a0a4a50ac2c4cc8c5a3954bc01ff134506b2
  • 4106316699 USDT from 0x067d0f9089743271058d4bf2a1a29f4e9c6fdd1b

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: 19805581627 raw units (19805.581627 USDC)
  • USDT: 4106316699 raw units (4106.316699 USDT)

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