Reproduced Exploit
MakerDAO flip-keeper — ungated `0x8804d1de` hopes a caller-supplied worker, then exits 200 WETH
This is not a MakerDAO Vat, Flipper, or GemJoin bug. Those contracts did what they are supposed to do. The loss is a dormant third-party liquidation keeper.
Loss
200 ETH exactly (200 WETH unwrapped). output.txt:196. Alert figure ~$538K. Not a Maker core loss
Chain
Ethereum
Category
access-control
Date
Oct 2026
Source
DeFiHackLabs
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-10-MakerDAOFlipKeeper_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/MakerDAOFlipKeeper_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/dependency/unsafe-external-call · vuln/logic/incorrect-state-transition Reproduction: online only. This folder has no
anvil_state.json. Passing archival-fork trace: output.txt. Folder: (.). Verified proxy: sources/AdminUpgradeabilityProxy_9c05a0. The keeper implementation is unverified.
Key info#
| Loss | 200 ETH exactly (200 WETH unwrapped). output.txt:196. Alert figure ~$538K. Not a Maker core loss |
| Vulnerable contract | Third-party ETH-A flip keeper, EIP-1967 AdminUpgradeabilityProxy 0x9c05a05893Ada984FC20D0DA0c046De5Cc0e8273. Implementation 0x68399ed8aa33C5b43F863EE6782de492006A5546 (unverified) |
| Attacker EOA | 0x01EB957E5C7DcDDD60F3C875956cCc6fb9BdA5FA (fresh nonce-2 EOA). The PoC pranks makeAddr("attacker") = 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e instead |
| Attack contract | Real helper 0xEc997d2aD033277913d6002277353368E8321dcF, which created worker 0xF09A13072ed939B79bC25b66AA3a836eA6DcC170. PoC worker is FlipKeeperWorker at 0xA9d5026A3fF2db063632bE894537ce4Fb11688bC output.txt:21 |
| Attack tx | 0xbb6940f7c2a1e68cafbae7bb9b94d09af9af06ec3a114f6996f2cab993f3a88c |
| Chain / block / date | Ethereum mainnet (chain id 1) / exploit block 26,131,471, fork parent 26,131,470 / 6 October 2026, 06:13:11 UTC |
| Compiler | Proxy: Solidity v0.5.3+commit.10d17f24, optimizer off, 200 runs (_meta.json). Implementation: no verified source |
| Bug class | Selector 0x8804d1de on the keeper implementation has no auth check. Via the proxy's delegatecall it reads vat() / drip() off a caller-supplied worker, Vat.hopes that worker on the keeper's own account, then calls worker.join. The worker settles four undealt 2020 ETH-A flips and exits 200 WETH |
TL;DR#
This is not a MakerDAO Vat, Flipper, or GemJoin bug. Those contracts did what they are supposed to do. The loss is a dormant third-party liquidation keeper.
The keeper is an OpenZeppelin AdminUpgradeabilityProxy whose implementation at 0x6839…5546 is unverified. Its drain selector 0x8804d1de(address,uint256) never reads authority or msg.sender. A public alert described this as "ds-auth with authority == 0". That framing is wrong: other selectors on the same bytecode do check the caller. This one does not check at all. See the header of test/MakerDAOFlipKeeper_exp.sol.
Called by a fresh EOA, the drain hopes the attacker's worker (vat.can goes from 0 to 1) and hands it control. The worker calls Flipper.deal on auctions 1457, 1458, 1459, and 1460. Each credits the keeper 50 WETH of ETH-A gem. Vat.flux moves 200 WETH of gem to the worker, GemJoin.exit pays out 200 WETH, and the worker unwraps and forwards 200 ETH. [PASS] testExploit() gas 765,949 output.txt:4.
Background — what was sitting in the keeper#
The proxy won four ETH-A flip auctions in 2020 and never called deal. Until deal, the 50 WETH lot on each auction stays in the Flipper, and vat.gem(ETH-A, keeper) is 0. The PoC checks that at the fork block output.txt:15–output.txt:17. The test header records auction 1457 as guy == keeper, lot == 50e18, end == 1584285968 (15 March 2020). The trace then shows each deal fluxing 50000000000000000000 (50 WETH) from the Flipper into the keeper output.txt:51, output.txt:75, output.txt:99, output.txt:123.
Maker addresses used, all behaving as designed:
| Contract | Address |
|---|---|
| Vat | 0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B |
| ETH-A Flipper | 0xd8a04F5412223F513DC55F839574430f5EC15531 |
ETH-A GemJoin (gem == WETH) | 0x2F0b23f53734252Bda2277357e97e1517d6B042A |
| WETH | 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 |
Flipper.deal is permissionless once an auction has ended. Vat.flux only needs vat.can(src, msg.sender) == 1 (or msg.sender == src). GemJoin.exit burns the caller's own gem and sends WETH. None of those checks was bypassed. The keeper itself called hope for the attacker.
The vulnerable code#
The proxy source is verified. The implementation is not, so the drain body below is RECONSTRUCTED from the live trace and the decompile notes in the test. It is not Etherscan source.
Verified proxy, AdminUpgradeabilityProxy.sol lines 112–148. Every non-admin call is a delegatecall into the implementation, so storage writes (vat.hope from the keeper's address) happen as the proxy:
function _delegate(address implementation) internal {
assembly {
calldatacopy(0, 0, calldatasize)
let result := delegatecall(gas, implementation, 0, calldatasize, 0, 0)
returndatacopy(0, 0, returndatasize)
switch result
case 0 { revert(0, returndatasize) }
default { return(0, returndatasize) }
}
}
function _fallback() internal {
_willFallback();
_delegate(_implementation());
}
_meta.json records implementation = 0x68399ed8aa33c5b43f863ee6782de492006a5546. The trace enters that address as 8804d1de under the proxy output.txt:32–output.txt:33.
RECONSTRUCTED drain (selector 0x8804d1de, args (address worker, uint256)). Order is the trace, not a guess: drip, then vat, then hope, then join. There is no CALLER comparison and no SLOAD of an authority slot on this path.
// RECONSTRUCTED — unverified implementation 0x68399ed8…5546. Not source.
function drain(address worker, uint256 x) external {
// no auth. first external call is drip() on the caller-supplied worker
worker.drip(); // trace returns 1e27
address vat = worker.vat(); // trace returns the real Vat
IVat(vat).hope(worker); // can[keeper][worker] = 1
worker.join(x); // attacker moves the keeper's gem
}
The PoC calls that selector with its own worker and x = 0 test/MakerDAOFlipKeeper_exp.sol. join(0) is what the trace shows output.txt:49. The worker, also reconstructed in the test, then does the only economically meaningful steps: deal four ids, flux the keeper's gem to itself, GemJoin.exit, WETH.withdraw, forward ETH.
drip() on the worker returns 1e27 output.txt:34–output.txt:35. The keeper does not use a Maker Jug.drip. It just calls whatever the attacker deployed.
Root cause — why it was possible#
0x8804d1deis ungated. The implementation is a ds-auth contract on other selectors. This selector's dispatcher does the non-payableCALLVALUEcheck and jumps into the body. Authority is never loaded. "Authority is the zero address" is not why the call succeeded. A brand-new EOA withvat.can(keeper, worker) == 0before the call output.txt:28 completed it.- The body trusts the caller's contract.
vatanddripare getters onworker, an argument. The keeper thenhopes that same address.hopeisVat's normal "I authorize this usr" and it is correct that the keeper can authorize someone. It is not correct that any external caller can make the keeper do it. joinis an arbitrary external call after the grant. The worker runs withcan[keeper][worker] == 1, soVat.flux(ETH-A, keeper, worker, amt)is legal. That is the state transition that moves the collateral. After the call,canis still 1 output.txt:197–output.txt:199. The authorization is not scoped to one auction or one token and it is not revoked.- The collateral was already the keeper's.
dealon a finished flip only moves the lot from the Flipper intovat.gem[ETH-A][guy]. Four lots of 50 WETH is 200 WETH. No flash loan, no price move, no Maker parameter change.
Secondary analysis (@DefimonAlerts)#
Missing access control on keeper selector 0x8804d1de: it Vat.hopes a caller-supplied worker, which deals auctions 1457–1460 and GemJoin.exits 200 WETH. Other selectors check ds-auth; this one never reads authority.
Source: https://x.com/DefimonAlerts/status/2107391959853600812
Secondary analysis (@CertiKAlert)#
Missing access control on keeper selector 0x8804d1de: it hopes a caller-supplied worker on the keeper’s Vat account, then the worker deals auctions 1457–1460 and GemJoin.exit()s 200 WETH. Already documented; CertiK does not change the class.
Source: https://x.com/CertiKAlert/status/2107813422817579518
Preconditions#
- The keeper proxy still points at the implementation that exposes ungated
0x8804d1de. It did, at block 26,131,470. - The keeper is
guyon ended ETH-A flips that were neverdealed, andvat.gemfor the keeper is still 0 so the lots have not been taken. Auctions 1457–1460, 50 WETH each. - The caller can deploy a worker with
vat(),drip(), andjoin(uint256). No priorhope, no keeper-owner signature, no allowance. dealis still callable (auctionendin the past, not already dealt). It was.
Attack walkthrough (with numbers from the trace)#
| # | Step | Effect (from output.txt) |
|---|---|---|
| 1 | Fork parent 26,131,470. vat.gem(ETH-A, keeper) == 0 | output.txt:10, output.txt:15–output.txt:17 |
| 2 | Fresh EOA deploys FlipKeeperWorker. vat.can(keeper, worker) == 0 | output.txt:21, output.txt:28–output.txt:30 |
| 3 | keeper.fallback(0x8804d1de ‖ worker ‖ 0) delegatecalls the implementation | output.txt:32–output.txt:33 |
| 4 | Implementation calls worker.drip() → 1e27, then worker.vat() → Vat | output.txt:34–output.txt:37 |
| 5 | Vat.hope(worker). can storage slot goes 0 → 1 | output.txt:40–output.txt:48 |
| 6 | worker.join(0) calls Flipper.deal(1457), deal(1458), deal(1459), deal(1460). Each Vat.fluxes 50 WETH of ETH-A gem from the Flipper to the keeper | output.txt:49, output.txt:50–output.txt:51, output.txt:74–output.txt:75, output.txt:98–output.txt:99, output.txt:122–output.txt:123 |
| 7 | Vat.flux(ETH-A, keeper, worker, 200e18) | output.txt:148 |
| 8 | GemJoin.exit(worker, 200e18) transfers 200 WETH from the join adapter to the worker | output.txt:158, output.txt:168–output.txt:169 |
| 9 | WETH.withdraw(200e18) and forward 200 ETH to the pranked attacker | output.txt:182–output.txt:189 |
| 10 | Logged profit 200.000000000000000000 ETH. can stays 1. Exact equality asserted | output.txt:196, output.txt:197–output.txt:203 |
No principal is repaid because nothing was borrowed. 200 WETH in, 200 ETH out.
Diagrams#
Remediation#
- Put the drain behind the same auth gate as the other selectors.
0x8804d1deshould require the ds-auth owner or a dedicated keeper role before any external call. Do not treat "authority happens to be unset" as the bug to fix. The missing modifier is the bug. A zero authority would still be wrong, but this function never reads it. - Do not
hopean address the caller picked. If a worker is required, it must be an immutable or owner-set contract, andhopeshould be paired withnopebefore returning. A permission granted inside an unauthenticated function is a persistentcan[keeper][worker] = 1, which the trace leaves set output.txt:197. - Do not call
drip/vat/joinon untrusted addresses. Those are attacker-controlled. A keeper that needs Maker's Vat should store the Vat address itself. - Settle leftover auctions from 2020.
dealplusGemJoin.exitto a fixed recipient, under auth, pulls the same 200 WETH out without a public callback. Anyone candeala finished flip, but only an authorizedfluxfrom the keeper can move the gem afterward. Clearingguy's auctions into a controlled address removes the prize. - Maker core needs no patch for this incident.
deal,flux, andexitenforced their own rules.
How to reproduce#
There is no anvil_state.json in this folder. The offline harness refuses to start:
_shared/run_poc.sh 2026-10-MakerDAOFlipKeeper_exp -vvvvv
# no anvil_state.json in 2026-10-MakerDAOFlipKeeper_exp
# exit 2, before forge
The test forks a live archival node itself. setUp calls vm.createSelectFork("https://eth-mainnet.public.blastapi.io", 26131470). From this folder, with network access:
forge test --match-path test/MakerDAOFlipKeeper_exp.sol -vvvvv
output.txt is the verbose trace of that online run. Expected tail:
[PASS] testExploit() (gas: 765949)
Attacker ETH profit: 200.000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: exploit tx 0xbb6940f7… (hash in test/MakerDAOFlipKeeper_exp.sol)
References#
-
https://x.com/DefimonAlerts/status/2107391959853600812 (@DefimonAlerts secondary analysis)
-
https://x.com/CertiKAlert/status/2107813422817579518 (@CertiKAlert secondary analysis)
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-10-MakerDAOFlipKeeper_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
MakerDAOFlipKeeper_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "MakerDAO flip-keeper".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.