Reproduced Exploit
Revert Finance (V3Utils) Exploit — Unvalidated `swapData` Lets Anyone Route User-Allowance Tokens to an Attacker
1. Revert Finance's V3Utils is an ownerless, "stateless" helper that users grant ERC20 allowances to so it can compound/swap/withdraw on their Uniswap-V3 positions. Its public swap(SwapParams) is supposed to perform one token-for-token swap via an off-chain-built router calldata blob.
Loss
19,805.581627 USDC (raw 19,805,581,627, 6 decimals) drained from two users who had approved V3Utils; tx 0xdac…
Chain
Ethereum
Category
Centralization / Privilege
Date
Feb 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-02-RevertFinance_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/RevertFinance_exp.sol.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/access-control/missing-auth
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not all compile together, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: V3Utils.
Key info#
| Loss | 19,805.581627 USDC (raw 19,805,581,627, 6 decimals) drained from two users who had approved V3Utils; tx 0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5 |
| Vulnerable contract | V3Utils — 0x531110418d8591C92e9cBBFC722Db8FFb604FAFD (Revert Finance ownerless Uniswap-V3 helper) |
| Victim pool / vault | n/a — direct theft from two ERC20 holders who had set approve(V3Utils, …): 0x067D…dd1b and 0x4107…06b2 |
| Attacker EOA | not surfaced by this PoC (the live tx's from is the exploiter; the PoC drives the attack from the test contract itself) |
| Attacker contract | 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 (the address this PoC's ContractTest resolves to inside swapData) |
| Attack tx | 0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5 |
| Chain / block / date | Ethereum mainnet / block 16,653,389 / Feb 2023 |
| Compiler / optimizer | Solidity v0.8.15 (v0.8.15+commit.e14f2714), optimizer enabled, 200 runs (per _meta.json); PoC compiled under Solc 0.8.34, evm_version = cancun |
| Bug class | Trust boundary / arbitrary external call — caller-supplied swapData is address.call(...)-ed with no whitelist, while tokenIn.approve(allowanceTarget, amountIn) gives the arbitrary target an allowance over tokens V3Utils holds on behalf of users |
TL;DR#
-
Revert Finance's
V3Utilsis an ownerless, "stateless" helper that users grant ERC20 allowances to so it can compound/swap/withdraw on their Uniswap-V3 positions. Its publicswap(SwapParams)is supposed to perform one token-for-token swap via an off-chain-built router calldata blob. -
The swap is dispatched by the internal
_swaphelper, which decodesswapDatainto(swapRouter, allowanceTarget, data), then doestokenIn.approve(allowanceTarget, amountIn)followed byswapRouter.call(data)(src_V3Utils.sol:531-565). NeitherswapRouternorallowanceTargetis validated against any whitelist, anddatais opaque. -
The exploit's trick: the attacker sets
tokenInto a contract it controls (theContractTestaddress) whosetransferFrom/balanceOf/approveare stubs that always returntrue/1. So_prepareAdd'ssafeTransferFrom(tokenIn, msg.sender, this, amountIn)"succeeds" without the attacker actually depositing anything (src_V3Utils.sol:377-384). -
Inside
_swap,tokenIn.approve(allowanceTarget, amountIn)approves the attacker's own contract (allowanceTarget = ContractTest) — meaningless. But the attacker'sdatais a real USDCtransferFrom(victim, attackerRecipient, amount)calldata, andswapRouteris set to the real USDC token. Because V3Utils holds an existing USDC allowance from the victim (the victim approved V3Utils for their position management),USDC.transferFrom(victim, recipient, amount)succeeds and moves the victim's USDC straight to the attacker'srecipient. -
_swapmeasuresamountInDeltaandamountOutDeltaby re-readingtokenIn.balanceOf(V3Utils)/tokenOut.balanceOf(V3Utils)around the call (src_V3Utils.sol:533-555). BecausetokenIn==tokenOut== the attacker's stub (whosebalanceOfalways returns 1), the deltas come back as0, theamountOutMinslippage check passes, andswapreturnsamountOut = 0(src_V3Utils.sol:557-560). No leftover transfer happens. The theft is invisible to V3Utils's accounting. -
The PoC loops this over two victims. Victim 1 loses its full USDC balance (capped only by its balance, since its allowance exceeds it). Victim 2 loses only its allowance (which is smaller than its balance). Net attacker gain: 19,805.581627 USDC (output.txt:1678).
Background — what Revert Finance / V3Utils does#
V3Utils (source) is described in its own NatSpec as
"a completely ownerless/stateless contract — does not hold any ERC20 or NFTs." Users grant it
ERC20 allowances and ERC721 approvals so it can act as a transient operator for their Uniswap-V3
positions: it compounds fees, changes tick ranges, and — relevant here — performs token swaps.
The relevant entry point is swap(SwapParams calldata params):
struct SwapParams {
IERC20 tokenIn;
IERC20 tokenOut;
uint256 amountIn;
uint256 minAmountOut;
address recipient; // recipient of tokenOut and leftover tokenIn (if any leftover)
bytes swapData;
bool unwrap; // if tokenIn or tokenOut is WETH - unwrap
}
The design intent is that swapData is an opaque calldata blob produced off-chain by a 0x-style
API: abi.encode(swapRouter, allowanceTarget, data). The contract approves allowanceTarget to
spend up to amountIn of tokenIn, then forwards data to swapRouter. Everything hinges on the
caller being trusted to put a legitimate swap router into swapData — because the contract
holds user allowances, any address that can be reached through swapRouter.call(data) gets to
spend whatever the caller can legitimately move.
On-chain parameters at the fork block (16,653,389), read directly from the trace:
| Parameter | Value | Source |
|---|---|---|
| USDC token | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 (6 decimals, proxied to impl 0xa2327a938Febf5FEC13baCFb16Ae10EcBc4cbDCF) | output.txt:1674-1677 |
| Victim 1 address | 0x067D0F9089743271058D4Bf2a1a29f4E9C6fdd1b | output.txt:1582 |
| Victim 1 USDC balance | 19,305,581,627 (≈ 19,305.58 USDC) | output.txt:1584 |
| Victim 1 → V3Utils allowance | 38,315,581,627 (≈ 38,315.58 USDC) — larger than balance | output.txt:1588 |
| Victim 2 address | 0x4107A0A4a50AC2c4cc8C5a3954Bc01ff134506b2 | output.txt:1624 |
| Victim 2 USDC balance | 608,929,547 (≈ 608.93 USDC) | output.txt:1626 |
| Victim 2 → V3Utils allowance | 500,000,000 (500.00 USDC) — smaller than balance | output.txt:1630 |
Attacker contract (recipient / stub tokenIn) | 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 | output.txt:1590 |
The drain of each victim is therefore bounded by min(balance, allowance) — exactly the logic
the PoC encodes in its loop (test/RevertFinance_exp.sol:43-48).
The vulnerable code#
1. swap hands caller-controlled bytes to _swap#
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);
// send swapped amount of tokenOut
if (amountOut > 0) {
_transferToken(params.recipient, params.tokenOut, amountOut, params.unwrap);
}
// if not all was swapped - return leftovers of tokenIn
uint leftOver = params.amountIn - amountInDelta;
if (leftOver > 0) {
_transferToken(params.recipient, params.tokenIn, leftOver, params.unwrap);
}
}
params.tokenIn, params.tokenOut, params.swapData, and params.recipient are all caller-
controlled. There is no whitelist and no check that swapData actually encodes a swap on a real
DEX router.
2. _prepareAdd "pulls" tokenIn from the caller — but trusts the caller's tokenIn#
// get missing tokens (fails if not enough provided)
if (amount0 > amountAdded0) {
uint balanceBefore = token0.balanceOf(address(this));
SafeERC20.safeTransferFrom(token0, msg.sender, address(this), amount0 - amountAdded0);
uint balanceAfter = token0.balanceOf(address(this));
if (balanceAfter - balanceBefore != amount0 - amountAdded0) {
revert TransferError(); // reverts for fee-on-transfer tokens
}
}
Because token0 is params.tokenIn and the attacker passes its own malicious token (whose
balanceOf/transferFrom always report success), this safeTransferFrom "succeeds" with zero
real value transferred. The fee-on-transfer guard balanceAfter - balanceBefore != amount is
trivially satisfied by the stub returning 1 consistently.
3. _swap calls an arbitrary address with arbitrary calldata#
function _swap(IERC20 tokenIn, IERC20 tokenOut, uint amountIn, uint amountOutMin, bytes memory swapData)
internal returns (uint amountInDelta, uint256 amountOutDelta) {
if (amountIn > 0 && swapData.length > 0 && address(tokenOut) != address(0)) {
uint balanceInBefore = tokenIn.balanceOf(address(this));
uint balanceOutBefore = tokenOut.balanceOf(address(this));
// get router specific swap data
(address swapRouter, address allowanceTarget, bytes memory data) = abi.decode(swapData, (address, address, bytes));
// approve needed amount
tokenIn.approve(allowanceTarget, amountIn);
// execute swap
(bool success,) = swapRouter.call(data);
if (!success) {
revert SwapFailed();
}
// remove any remaining allowance
tokenIn.approve(allowanceTarget, 0);
uint balanceInAfter = tokenIn.balanceOf(address(this));
uint balanceOutAfter = tokenOut.balanceOf(address(this));
amountInDelta = balanceInBefore - balanceInAfter;
amountOutDelta = balanceOutAfter - balanceOutBefore;
// amountMin slippage check
if (amountOutDelta < amountOutMin) {
revert SlippageError();
}
// event for any swap with exact swapped value
emit Swap(address(tokenIn), address(tokenOut), amountInDelta, amountOutDelta);
}
}
This is the whole bug. swapRouter is the real USDC token, allowanceTarget is the attacker's
own contract (so the tokenIn.approve(allowanceTarget, …) is a no-op stub), and data is
USDC.transferFrom.selector || victim || recipient || amount. Because V3Utils is the msg.sender
to USDC and holds a live allowance from the victim, the transferFrom succeeds and the victim's
USDC is pushed to the attacker's recipient.
4. Balance-delta accounting is fooled by the stub tokenIn#
_swap measures "what came in / what came out" purely by re-reading
tokenIn.balanceOf(address(this)) and tokenOut.balanceOf(address(this)). The attacker sets
tokenIn == tokenOut == its stub, whose balanceOf(V3Utils) always returns 1. So
balanceInBefore == balanceInAfter == 1 and balanceOutBefore == balanceOutAfter == 1, giving
amountInDelta == amountOutDelta == 0. With minAmountOut = 0, the slippage check
(src_V3Utils.sol:557-560) passes, amountOut
returns 0, and swap does no _transferToken of tokenOut or leftover — leaving the stolen
USDC untouched in the attacker's account. The trace confirms this with emit Swap(…, 0, 0)
(output.txt:1620, output.txt:1666).
Root cause — why it was possible#
The vulnerability is a trust-boundary / arbitrary-external-call flaw, the same class as Dexible and LiFi: a "stateless" helper that holds user allowances forwards caller-supplied calldata to a caller-supplied target with no validation.
Concretely, three design choices compose into the loss:
-
swapDatais opaque and un-whitelisted.(swapRouter, allowanceTarget, data)are all attacker-controllable. The contract never asserts thatswapRouteris a known DEX router, nor thatdatacorresponds to a swap ontokenIn/tokenOut. This is the root defect. -
tokenInis caller-controlled AND the accounting primitive._swapusestokenIn.balanceOf(address(this))as its source of truth for both the input pull (_prepareAdd) and the post-call delta. By supplying a token whosebalanceOfalways returns a constant, the attacker defeats every balance-based invariant the contract relies on (fee-on-transfer check, delta accounting, leftover refund). The contract assumestokenInis an honest ERC20, buttokenInis just an address the caller picked. -
V3Utils holds live user allowances and acts as
msg.senderto external tokens. The whole product only works because usersapprove(V3Utils, …). That makes anyswapRouter.call(data)that hits a token the victims approved into a live theft primitive: USDC seesmsg.sender == V3Utils, the victim's allowance to V3Utils is non-zero, sotransferFromsucceeds.
The "stateless" NatSpec is technically true — V3Utils never intends to custody tokens — but it is misleading: statelessness does not mean "holds no privileges." V3Utils is the named spender on every victim's allowance, which is exactly the privileged position the attacker abuses.
Preconditions#
- A victim has
approve(V3Utils, amount > 0)outstanding on some ERC20 (true for any Revert user who has used the helper to manage a position). - The attacker can call
V3Utils.swap(...)(it is external, no access control). - The attacker can deploy a small "stub" ERC20-like contract exposing
balanceOf/transferFrom/approve/transferthat always return1/true. This is trivial and costs a single deploy. - No time bomb, no governance gate, no keeper. The bug is live and permissionless at the fork block.
Attack walkthrough (with on-chain numbers from the trace)#
The PoC loops the same primitive over two victims. For each victim it computes
transferAmount = min(victim USDC balance, victim→V3Utils allowance) and builds a swapData whose
data field is USDC.transferFrom(victim, attackerRecipient, transferAmount). Raw wei below;
human approximations in parentheses (USDC = 6 decimals).
| # | Step | Victim | Amount drained (raw wei) | ~USDC | Trace reference |
|---|---|---|---|---|---|
| 0 | Read victim 1 USDC balance | 0x067D…dd1b | 19,305,581,627 | 19,305.58 | output.txt:1584 |
| 0 | Read victim 1 → V3Utils allowance | same | 38,315,581,627 (> balance) | 38,315.58 | output.txt:1588 |
| 1 | transferAmount = min(balance, allowance) = balance | victim 1 | 19,305,581,627 | 19,305.581627 | swapData amount 0x47eb3cc3b decodes to 19,305,581,627 — output.txt:1590 |
| 1a | utils.swap(...) — _prepareAdd pulls stub tokenIn (no-op) | — | 1 (stub units) | — | output.txt:1593-1596 (ContractTest::transferFrom returns true) |
| 1b | _swap: swapRouter = USDC, allowanceTarget = ContractTest, data = USDC.transferFrom(victim1, ContractTest, 19,305,581,627) | victim 1 | moves 19,305,581,627 USDC | 19,305.581627 | emit Transfer(from: 0x067D…dd1b, to: ContractTest, value: 19,305,581,627) — output.txt:1607 |
| 1c | _swap accounting: amountInDelta = amountOutDelta = 0 (stub balanceOf constant) | — | 0 | 0 | emit Swap(ContractTest, ContractTest, 0, 0) — output.txt:1620 |
| 1d | swap returns amountOut = 0; no leftover transfer fires | — | — | — | return 0x0…0 — output.txt:1623 |
| 2 | Read victim 2 USDC balance | 0x4107…06b2 | 608,929,547 | 608.93 | output.txt:1626 |
| 2 | Read victim 2 → V3Utils allowance | same | 500,000,000 (< balance) | 500.00 | output.txt:1630 |
| 3 | transferAmount = min(balance, allowance) = allowance | victim 2 | 500,000,000 | 500.00 | swapData amount 0x1dcd6500 decodes to 500,000,000 — output.txt:1636 |
| 3a | utils.swap(...) — same primitive; data = USDC.transferFrom(victim2, ContractTest, 500,000,000) | victim 2 | moves 500,000,000 USDC | 500.00 | emit Transfer(from: 0x4107…06b2, to: ContractTest, value: 500,000,000) — output.txt:1653 |
| 3b | emit Swap(ContractTest, ContractTest, 0, 0) — theft invisible to accounting | — | 0 | 0 | output.txt:1666 |
| 4 | Final attacker USDC balance | ContractTest | 19,805,581,627 | 19,805.581627 | Attacker USDC balance after exploit: 19805.581627 — output.txt:1678 |
Note that victim 1 is drained down to its balance (its allowance was larger), while victim 2 is
drained only down to its allowance (its balance was larger). The PoC's
min(balance, allowance) loop mirrors exactly what a real attacker would script to harvest every
dollar reachable through V3Utils.
Profit / loss accounting (USDC, 6 decimals)#
| Direction | Victim | Amount (raw wei) | ~USDC |
|---|---|---|---|
| Drained | 0x067D0F90…dd1b | 19,305,581,627 | 19,305.581627 |
| Drained | 0x4107A0A4…06b2 | 500,000,000 | 500.000000 |
| Total drained | 19,805,581,627 | 19,805.581627 | |
| Final attacker balance (asserted by PoC) | 19,805,581,627 | 19,805.581627 | |
| Reconciliation | matches to the wei | ✔ |
19,305,581,627 + 500,000,000 == 19,805,581,627 exactly — the attacker's final USDC balance equals
the sum of the two drains, confirming zero value was lost to fees, slippage, or rounding (USDC has
no transfer fee on the whitelisted path the attacker used).
Diagrams#
Sequence of the attack (per victim)#
Flow of value / state evolution#
The flaw inside _swap#
Why each magic number#
amountIn: 1andminAmountOut: 0(test/RevertFinance_exp.sol:56-57): the input amount is irrelevant because_prepareAddpulls from the attacker's stub (which fakes any amount).minAmountOut = 0ensures the slippage check (src_V3Utils.sol:557-560) never reverts even though_swapmeasuresamountOutDelta = 0.transferAmount = min(balance, allowance)(test/RevertFinance_exp.sol:44-48): the maximum USDCtransferFromwill actually move. For victim 1 this is the balance (19,305,581,627, since allowance38,315,581,627exceeds it); for victim 2 this is the allowance (500,000,000, since balance608,929,547exceeds it). These two values are what get abi-encoded as theamountfield insidedataand are visible in the trace as theTransferevent values (output.txt:1607, output.txt:1653).tokenIn = tokenOut = recipient = address(this)(test/RevertFinance_exp.sol:54-58): setting all three to the attacker's contract makes_swap'sbalanceIn/balanceOutboth read the same stub, so the post-call deltas are both zero and no honest-token payout path is entered.- The stub
balanceOfreturning1whencounter == 1(test/RevertFinance_exp.sol:84-89): thecounterflag is toggled by the stub's owntransferFromso that the fee-on-transfer check in_prepareAdd(balanceAfter - balanceBefore == amount) sees1 - 0 == 1(==amountIn), i.e. a self-consistent "deposit." It is the minimal lie needed to pass that single guard. swapData = abi.encode(USDC, address(this), data)(test/RevertFinance_exp.sol:49-52):swapRouter= the real USDC proxy (soswapRouter.call(data)invokesUSDC.transferFrom(...)),allowanceTarget= the attacker stub (so thetokenIn.approveis a no-op),data= the encodedtransferFrom(victim, attacker, amount).
Remediation#
- Whitelist swap routers and validate calldata structure.
swapRoutermust be one of a known set of DEX routers (UniV3 quoter/router, 0x exchange proxy, etc.), andallowanceTargetmust be eitherswapRouteritself or an address derived from it. Nevercallan arbitrary address the caller supplies. - Do not let the caller choose
tokenInarbitrarily. Ifswapis meant to consume a position's tokens, sourcetokenInfrom a position the caller actually owns (via the NFT callback path), not from a free-form parameter. At minimum, requiretokenInto be a known, honest ERC20 (e.g. by checking it against the position'stoken0/token1). - Bound
recipient. For a helper acting on user allowances,recipientshould be the caller (or the position's owner), not any address the caller names. This alone would have prevented the stolen USDC from being routed to the attacker. - Make accounting independent of caller-supplied tokens.
_swap's balance-delta trick relies ontokenIn.balanceOfbeing honest. A malicioustokenIndefeats it. Either restricttokenIn(per #2) or compute deltas against a trusted oracle of the input token, not the token itself. - Revoke unused allowances. The blast radius here is every outstanding
approve(V3Utils, …). After the fix, contract-level re-approval hygiene (per-actionapprove(router, 0)after use, which the code already does at src_V3Utils.sol:549) should be paired with user guidance to grant only the minimum allowance needed.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has
unrelated PoCs that do not all compile together under one forge build).
_shared/run_poc.sh 2023-02-RevertFinance_exp --mt testExploit -vvvvv
- RPC: this PoC runs offline against the bundled
anvil_state.json.setUp()doescreateSelectFork("http://127.0.0.1:8545", 16_653_389)— the127.0.0.1:8545endpoint is the local anvil instance the shared harness starts fromanvil_state.json, not a public RPC. No external archive RPC is required. The fork pins to Ethereum mainnet block 16,653,389. - EVM:
foundry.tomlsetsevm_version = "cancun"; the PoC compiles under Solc 0.8.34 with no special flags. - Result:
[PASS] testExploit()withAttacker USDC balance after exploit: 19805.581627.
Expected tail (verbatim from output.txt:1566-1685):
Ran 1 test for test/RevertFinance_exp.sol:ContractTest
[PASS] testExploit() (gas: 157666)
Logs:
Attacker USDC balance after exploit: 19805.581627
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.18s (3.58s CPU time)
Ran 1 test suite in 5.59s (5.18s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Reference: Revert Finance post-mortem — https://mirror.xyz/revertfinance.eth/3sdpQ3v9vEKiOjaHXUi3TdEfhleAXXlAEWeODrRHJtU ; attack tx https://etherscan.io/tx/0xdaccbc437cb07427394704fbcc8366589ffccf974ec6524f3483844b043f31d5
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-02-RevertFinance_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
RevertFinance_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Revert Finance (V3Utils) 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.