Reproduced Exploit
DAO Maker Exploit — Unprotected `init()` Re-initialization → `emergencyExit` Vesting Drain
DAO Maker deployed many minimal-proxy ("clone") vesting contracts, one per token sale / SHO allocation. Each clone is configured after deployment by an external init(...) function that, among other things, sets the owner. That function had no initializer modifier and no caller restriction, so it co…
Loss
5,760,000 DERC (DeRace Token) swept from the 0x2fd6... clone. Across the campaign (4 clones) ≈ $4M at the tim…
Chain
Ethereum
Category
access-control
Date
Sep 2021
Source
DeFiHackLabs
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
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: 2021-09-DaoMaker_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/DaoMaker_exp.sol.
Vulnerability classes: vuln/access-control/uninitialized-proxy · vuln/data/uninitialized
One-line summary: DAO Maker's token-distribution/vesting clone contracts left their
init()initializer permissionless and re-callable; an attacker re-initialized a live vesting contract to make itself the owner, then called the owner-gatedemergencyExit()to sweep the entire token balance out — 5,760,000 DERC in this PoC.
Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder using the registry's harness (
_shared/run_poc.sh 2021-09-DaoMaker_exp --mt testExploit -vvvvv). It boots a dedicatedanvil --load-state anvil_state.json(fully offline) and rewrites the localhost fork URL for parallel isolation. Full verbose trace: output.txt. PoC: test/DaoMaker_exp.sol. The only source that resolved on Etherscan at the fork block is the DERC token: sources/ERC20_9fa695/ERC20.sol. The DAO Maker vesting proxy/implementation were unverified at the fork block, so the vesting-logic snippets below are reconstructed from the storage diff + call trace inoutput.txt, SlowMist decompile, and public post-mortems.
Key info#
| Loss (this PoC) | 5,760,000 DERC (DeRace Token) swept from the 0x2fd6... clone. Across the campaign (4 clones) ≈ $4M at the time (13.5M CAPS, 2×2.5M CPD, 5.76M DERC in this tx, ~20.6M SHO). |
| Vulnerable contract | DAO Maker vesting/distribution proxy 0x2FD602Ed1F8cb6DEaBA9BEDd560ffE772eb85940 → impl (delegatecall) 0xF17CA0E0F24A5FA27944275Fa0ceDec24Fbf8eE2 |
| Drained token / victims | DERC = DeRace Token 0x9fa69536d1cda4A04cFB50688294de75B505a9aE; the funds were vesting allocations belonging to DERC token holders / DAO Maker SHO participants. |
| Attacker EOA | 0x2708cace7b42302af26f1ab896111d87faeff92f |
| Attack tx (drain step; init was immediate prior call by same EOA in campaign) | 0x96bf6bd14a81cf19939c0b966389daed778c3a9528a6c5dd7a4d980dec966388 |
| Chain / fork block / date | Ethereum mainnet / 13,155,320 (pre-exploit) / September 3, 2021 (tx at 13,155,350) |
| Compiler (DERC token) | Solidity v0.8.4+commit.c7e474f2, optimizer on, 1000 runs |
| Bug class | Unprotected / re-callable initializer (missing initializer guard + missing access control) → privilege escalation → owner-gated fund drain |
TL;DR#
DAO Maker deployed many minimal-proxy ("clone") vesting contracts, one per token sale / SHO
allocation. Each clone is configured after deployment by an external init(...) function that,
among other things, sets the owner. That function had no initializer modifier and no caller
restriction, so it could be called again at any time by anyone.
The attacker simply:
- Called
init(...)on a live, funded vesting contract, passing benign-looking vesting parameters but causing the initializer to overwriteownerwith the attacker's address (the trace showsOwnershipTransferred(0x95626473…6367700-owner → attacker)). - Called the now-attacker-owned
emergencyExit(address)— an owner-only escape hatch thattransfers the contract's entire token balance to an arbitrary address — pointing it at themselves.
SlowMist's contemporaneous analysis (decompiling the impl at 0xf17ca0e0f24a5fa27944275fa0cedec24fbf8ee2) confirmed:
the init function (selector 0x84304ad7) "does not authenticate the caller", directly sets owner,
and owner may then invoke emergencyExit. The same pattern was used against the other three clones.
In this PoC that swept the whole DERC balance held by the clone: 5,760,000 DERC (5.76e24 wei),
confirmed by the before/after logs in output.txt:6-7 and the Transfer event in
output.txt:43. No flash loan, no price manipulation, no math trick — just a forgotten
access-control modifier on an initializer.
Per SlowMist and DAO Maker's own Sept 3 2021 update, only the vested public-sale tokens of four projects (DeRace/DERC, Showcase/SHO, Ternoa, Coinspaid/CPD) in the SHO claim portals were affected; the projects' own tokens and contracts were not compromised. The attacker(s) realized ~$4M at the time (later reports sometimes cite broader ~$7M figures that may include other incidents). Victims were compensated via market buys by the team + IOU mechanisms in follow-up statements.
Background — what the contract does#
DAO Maker is a launchpad. For each token sale ("Strong Holder Offering" / SHO) it deploys a small
vesting contract that holds the sold tokens and releases them to beneficiaries over time. To save
gas these were deployed as EIP-1167 minimal proxies (clones) that delegatecall into one shared
implementation — visible in the trace as the proxy 0x2FD60…5940 forwarding every call into impl
0xF17CA…8eE2 via [delegatecall] (output.txt:20, output.txt:39).
A clone has no constructor (the implementation's constructor never runs in the proxy's context), so
its state is set up by an init(...) call instead. From the PoC interface and the trace, init takes:
function init(
uint256 startTime, // 1640984401 (vesting start, here 2021-12-31)
uint256[] calldata periods, // [5702400] (one 66-day release period)
uint256[] calldata percents, // [10000] (100.00% released — bps)
address token // DERC token address
) external; // ⚠️ no `initializer`, no `onlyOwner`/factory check
The owner of the clone is granted an emergencyExit(address recipient) function whose purpose is
to rescue funds in a stuck state. The trace shows exactly what it does:
function emergencyExit(address recipient) external /* onlyOwner */ {
uint256 bal = token.balanceOf(address(this)); // reads full balance
token.transfer(recipient, bal); // sends ALL of it out
}
— see the call sequence balanceOf(self) = 5.76e24 then transfer(attacker, 5.76e24) at
output.txt:40-47.
The on-chain facts at the fork block:
| Fact | Value (from trace) |
|---|---|
Clone's owner before attack | 0x95626473b6782292D20f1b07a85a8B7F6aB63677 (DAO Maker deployer) |
| DERC balance held by the clone | 5,760,000 DERC (5.76e24 wei) |
init re-callable? | Yes — call succeeds and emits OwnershipTransferred |
emergencyExit access | owner-only — but owner was just overwritten to the attacker |
The vulnerable code#
The vesting implementation was unverified on Etherscan at block 13,155,320, so the exact source is not in
sources/. The two functions below are the minimal behavior the trace proves:initrewroteowner(and the vesting schedule) without any guard, andemergencyExitmoved the entire balance under owner authority. The verified DERC token source — the asset that was drained — is in sources/ERC20_9fa695/ERC20.sol; its_transfer(ERC20.sol:177-195) is a plain balance move, so the drain is a fully legitimate transfer from the contract's perspective.
1. init — initializer with no guard (reconstructed from the storage diff)#
// ⚠️ no `initializer` modifier ⚠️ no factory/owner caller check
function init(uint256 start, uint256[] calldata periods, uint256[] calldata percents, address token) external {
owner = msg.sender; // slot 0 ← privilege escalation happens HERE
vestingStart = start; // slot 1
vestingEnd = ...; // slot 2
releasePeriods = periods; // slot 3 (length) + 0xc257… (data)
releasePercents = percents;// slot 4 (length) + 0x8a35… (data)
// ...
}
What the storage diff in output.txt:22-35 proves the call did:
| Slot | Before | After | Meaning |
|---|---|---|---|
0 | …95626473…6367700 | …7fa9385be1…3e149600 | owner ← attacker (high 20 bytes), trailing 00 = packed init flag — not preventing re-call |
1 | 0x61093038 = 1627912760 (2021-08-02) | 0x61cf6f51 = 1640984401 | vesting start moved to attacker's start param |
2 | 0x62e3cc38 = 1659169336 (2022-07-30) | 0x62267251 = 1646598737 | vesting end pulled in earlier |
3 | 4 | 1 | releasePeriods.length 4 → 1 |
4 | 4 | 1 | releasePercents.length 4 → 1 |
0x8a35…19b | 2500 | 10000 | percents[0] 25.00% → 100.00% |
0x8a35…19c/d/e | 2500 each | 0 | percents[1..3] zeroed (single 100% tranche) |
0xc257…85b | 0x76a700 = 7,775,000? (period[0]) | 0x570300 = 5,702,400 | periods[0] ← attacker's 5_702_400 |
0xc257…85c/d/e | 0x76a700 each | 0 | periods[1..3] zeroed |
The crucial line is slot 0: init writes owner = msg.sender with no check that the contract was
not already initialized and no check on who is calling. The attacker walks in and becomes owner.
2. emergencyExit — owner-gated full sweep (reconstructed from the trace)#
function emergencyExit(address recipient) external onlyOwner {
IERC20 token = ...; // DERC
uint256 bal = token.balanceOf(address(this));// 5,760,000 DERC
token.transfer(recipient, bal); // ALL of it → recipient
}
Trace evidence (output.txt:38-49):
emergencyExit(ContractTest)
ERC20::balanceOf(0x2FD60…5940) → 5760000000000000000000000 (5.76e24)
ERC20::transfer(ContractTest, 5.76e24)
emit Transfer(0x2FD60…5940 → ContractTest, 5.76e24)
emergencyExit itself is correctly onlyOwner. The bug is not in emergencyExit; it is that
init let the attacker become the owner.
Root cause#
Two independent omissions in the vesting clone compose into a critical bug:
- Re-callable initializer. Clone contracts have no constructor, so initialization is done via an
external
init. The standard, mandatory pattern is to guard it with aninitializer/ "already-initialized" check (e.g. OpenZeppelinInitializable). DAO Maker'sinithad no such guard, so it could be invoked a second time on an already-live, already-funded contract. - No access control on
init. Even a one-shot initializer is dangerous if anyone can be the one to shoot it, but here it was also re-callable. There was no check that the caller was the factory or a privileged role, andinitsetowner = msg.sender. Anyone callinginittherefore makes themselves owner.
The owner then holds emergencyExit, an arbitrary-recipient full-balance withdrawal. The privilege
escalation (omission 1+2) is converted directly into theft by the legitimate-but-powerful escape
hatch (3).
In short: a function that should have been callable exactly once, by the factory only, was callable any number of times, by anyone — and it grants the keys to the vault.
Preconditions#
- The target clone has tokens to steal (
balanceOf(clone) > 0). At the fork block the DERC clone held 5,760,000 DERC. initis callable (noinitializerguard) — true for every DAO Maker clone of this implementation.- The attacker can pass any
initarguments; the values only need to be accepted, not sensible — the only one that matters for the theft is the implicitowner = msg.sender. (The benign-looking vesting params in the PoC are cosmetic.)
No flash loan, no capital, no specific block timing, and no victim interaction are required. This is a single-actor, permissionless attack that any externally-owned account could run.
Step-by-step attack walkthrough#
Ground-truth values are taken from output.txt. clone = 0x2FD60…5940,
attacker = ContractTest 0x7FA9385bE1…3e1496 (the live attacker contract in the original tx).
| # | Step | Call (trace line) | State change | Result |
|---|---|---|---|---|
| 0 | Pre-state | DERC.balanceOf(attacker) | — | Attacker DERC = 0 (output.txt:16-18) |
| 1 | Re-initialize & seize ownership | clone.init(1640984401, [5702400], [10000], DERC) (output.txt:19-37) | slot 0 owner 0x95626473…6377 → attacker; emits OwnershipTransferred(deployer → attacker) | Attacker is now owner of the clone |
| 2 | Drain via the escape hatch | clone.emergencyExit(attacker) (output.txt:38-49) | reads balanceOf(clone)=5.76e24; transfer(attacker, 5.76e24); emits Transfer(clone → attacker, 5.76e24) | Clone DERC → 0, attacker DERC → 5.76e24 |
| 3 | Confirm | DERC.balanceOf(attacker) (output.txt:50-52) | — | Attacker DERC = 5,760,000.000000000000000000 |
The whole exploit is two external calls. The original on-chain campaign repeated this exact pattern against four different clones (CAPS, CPD ×2, DERC, SHO) — see the PoC header (test/DaoMaker_exp.sol:10-14).
Profit / loss accounting#
| Item | Amount |
|---|---|
| Attacker DERC before | 0 |
| DERC held by clone (= sweep amount) | 5,760,000 DERC |
| Attacker DERC after | 5,760,000 DERC |
| Net gain (this contract) | +5,760,000 DERC |
DERC = DeRace Token (18 decimals), drained 1:1 from the vesting contract — the loss is borne by the vesting beneficiaries / DERC project. Across all four contracts the published campaign loss is ≈ $4,000,000 at the time. Gas cost was trivial (PoC ran in 100,437 gas, output.txt:4).
Diagrams#
Sequence of the attack#
Ownership / privilege state machine#
Where the access control should have been#
Remediation#
- Guard the initializer. Use OpenZeppelin
Initializableand theinitializermodifier oninit, so it reverts on any second call. For clones, set the flag in the clone's own storage, not the implementation's. - Restrict who can initialize. Even one-shot,
initmust be callable only by the factory that deploys the clone (e.g. requiremsg.sender == factory, or have the factory callinitatomically inside the samecreateClonetransaction so an external caller can never win the race). - Do not derive
ownerfrommsg.senderin an externally-callable initializer. Pass the intended owner explicitly from the trusted factory, or hard-code it; neverowner = msg.senderin a function anyone can call. - Constrain the escape hatch.
emergencyExitshould at minimum be time-locked / two-step, emit an event, and ideally only send to a fixed treasury address rather than an arbitraryrecipient, so that even a compromised owner cannot instantly exfiltrate to an unknown address. - Sweep deployed clones. Because the bug affected an entire family of already-deployed clones, the fix must include pausing/migrating funds out of every existing clone of the vulnerable implementation, not just patching the implementation for future deployments.
How to reproduce#
Use the registry harness (handles anvil state snapshot + port isolation automatically):
_shared/run_poc.sh 2021-09-DaoMaker_exp --mt testExploit -vvvvv
- Uses
anvil --load-state anvil_state.json(captures the exact pre-attack state of the DERC clone proxy + token storage at block 13,155,320). No live archive RPC needed. - The script rewrites the
createSelectFork("http://127.0.0.1:...")URL in a temp copy. - Result:
[PASS] testExploit()— attacker's DERC balance goes from0to5,760,000.
Expected tail (see output.txt):
Ran 1 test for test/DaoMaker_exp.sol:ContractTest
[PASS] testExploit() (gas: 100437)
Logs:
Before exploiting, Attacker DERC balance: 0.000000000000000000
After exploiting, Attacker DERC balance: 5760000.000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: DAO Maker SHO vesting exploit, Ethereum, September 3, 2021 (~$4M across the four affected clones: CAPS, CPD×2, DERC, SHO).
Primary sources: on-chain tx 0x96bf..., SlowMist analysis (decompiled init 0x84304ad7 unauthenticated + emergencyExit), DAO Maker post-mortem.
Bug class: unprotected re-callable initializer (clone) → owner takeover → owner-gated full-balance drain.
References & Further Reading#
- SlowMist: "An Analysis of the Attack on DAO Maker" (decompile of init / emergencyExit) — https://slowmist.medium.com/slowmist-an-analysis-of-the-attack-on-dao-maker-627fcccd8fcb
- Etherscan tx (drain): https://etherscan.io/tx/0x96bf6bd14a81cf19939c0b966389daed778c3a9528a6c5dd7a4d980dec966388 (5,760,000 DERC)
- DAO Maker incident statement (Sep 3 2021) — only four SHO portals affected; no token minting.
- On-chain storage proof in this PoC's output.txt matches the attack (owner overwrite + full balance transfer).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2021-09-DaoMaker_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
DaoMaker_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "DAO Maker Exploit".
- 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.