An unprotected drain selector on a dormant 2020 MakerDAO ETH-A flip-keeper proxy authorized an attacker's worker and let it sweep 200 WETH (200 ETH) from four undealt auctions.
Security ·
Logged as “2026-10-06: The Dormant Eth A Flip Keeper Proxys Drain Selector Has No Ds Auth Check So Anyone Can Call It To Make The Keeper Vathope A Caller Supplied Worker And Then Deal Its Four Undealt 2020 Eth A Auctions And Fluxexit The Freed 200 Weth Of Collateral Out on MakerDAOFlipKeeper”.
A dormant third-party liquidation keeper proxy deployed in 2020 held 200 ETH in WETH collateral across four unredeemed ETH-A flip auctions. On 6 October 2026, an attacker called an unprotected selector on the proxy that authorized the attacker's own worker contract to move that collateral, swept it through vat.flux and GemJoin.exit, and unwrapped the 200 WETH to 200 ETH sent to a fresh attacker EOA. The drained amount, per DeFiHackLabs, was exactly 200 ETH (200.000000000000000000 WETH).
The keeper at 0x9c05a05893Ada984FC20D0DA0c046De5Cc0e8273 is not a MakerDAO core contract. MakerDAO's Vat, ETH-A Flipper, and ETH-A GemJoin all behaved as designed. The bug is in the upgradeable proxy impl at 0x68399ed8aa33C5b43F863EE6782de492006A5546, which exposes a drain(address,uint256) selector (0x8804d1de) on an upgradeable proxy with no ds-auth modifier. Other functions on the impl gate on authority, but the drain path does not consult it.
The keeper impl, read from the proxy's EIP-1967 implementation slot, is a ds-auth contract that exposes authority() (selector 0xbf7e214f) and gated methods such as 0xcd33cd7a. The drain selector 0x8804d1de is ungated outright: its dispatcher does no SLOAD of an owner slot, no CALLER comparison, and no authority check. A call from any EOA executes to completion.
When drain(worker, x) runs, it reads vat from the supplied worker, calls vat.hope(worker) (so can[keeper][worker] = 1), and then calls worker.join(x). The attacker passes a worker that exposes the three getters the drain reads (vat, drip, and join(uint256)). The keeper's collateral is whatever happens to back the keeper's Vat position, which in this case was the unredeemed 2020 ETH-A flip auctions.
Pre-attack (block 26131470, immediately before the exploit), vat.gem["ETH-A"][0x9c05...8273] == 0. The keeper had won four ETH-A flip auctions (#1457, #1458, #1459, #1460), each 50 WETH, in 2020, and had never called deal() to settle them. Each deal credits vat.gem[ETH-A][keeper] with 50 WETH. The exploit transaction (0xbb6940...3a88c, block 26,131,471, 6 Oct 2026 06:13:11 UTC) calls 0x8804d1de on the keeper from a brand-new attacker contract, then runs:
flipper.deal(1457..1460) four times, crediting 4 × 50 WETH to the keeper's Vat gem balance.vat.flux(ETH-A, keeper, worker, 200e18) to move the 200 WETH out.gemJoin.exit(worker, 200e18) to convert the gem into 200 WETH.weth.withdraw(200 WETH) to unwrap, then forward 200 ETH to the attacker EOA.The PoC forks mainnet at block 26131470 and reproduces the same call sequence, asserting an attacker balance delta of exactly 200 ETH. The PoC and entry both record the realized loss as 200 ETH with zero variance. vat.hope is the gate step: the drain hands the attacker's chosen worker the ability to move the keeper's Vat collateral, which is what every later call relies on.
The drain selector missed the one access modifier every other function on the impl carries. Adding auth to its signature, or removing the selector entirely, would have closed it. A more conservative design would have read vat.can(msg.sender, worker) instead of authorizing the attacker's own worker, but the simplest fix is the omitted modifier. The keeper had been dormant since it won the auctions in 2020.
Query every incident yourself — free, no signup
Updated 11 Nov 2026. Figures are as reported by the sources listed above and are not independently verified.