Init Frontrun — Unguarded initialize()
A logic contract for a proxy pattern has an initialize() function without the initializer modifier and no _disableInitializers() call in the constructor. An attacker monitoring the mempool calls initialize() directly on the logic contract before the proxy does, taking ownership.
~7 min read
Upgradeable contracts use an initialize()() function instead of a constructor. But if the logic contract does not call _disableInitializers()() in its constructor, anyone can call initialize()() directly on the logic contract before the proxy does. An attacker watching the mempool front-runs the deployment to take ownership.
1. Constructors vs initializers
Solidity constructors run once at deploy time and are not part of the bytecode — they cannot be called after deployment. But proxy patterns require logic to be stored in a separate contract and called via delegatecall. Constructors in the logic contract only run in the logic contract's own storage — not the proxy's. So proxy patterns use an initialize()() function instead, called by the proxy's constructor (or atomically via a factory).
The risk: initialize() is a regular external function. It can be called by anyone before the proxy calls it. Between the moment the logic contract is deployed and the moment the proxy calls initialize(), there is a window where an attacker in the mempool can call initialize() on the logic contract directly, setting themselves as owner.
OpenZeppelin's Initializable contract provides the initializer modifier that ensures initialize() can only run once. But this modifier alone is not sufficient: it prevents re-initialization but does not prevent a direct call to the logic contract's initialize() before the proxy. The constructor must also call _disableInitializers()() to lock initialization on the logic contract itself.
2. The two-layered protection
Layer 1: initializer modifier. This sets an _initialized flag after the first call, preventing any second call to initialize(). Without this, the function can be called repeatedly.
Layer 2: _disableInitializers()() in the constructor. This sets the _initialized flag to the maximum value at deploy time of the logic contract, so even the first call to initialize() is blocked on the logic contract. Only the proxy can initialize (via delegatecall — which runs in the proxy's storage where _initialized starts at 0).
3. The attack — front-run in the mempool
The VulnerableLogicContract constructor does nothing. initialize()() lacks the initializer modifier — it uses a manual _initialized bool that starts at false. Any caller can invoke initialize() on the logic contract. Attack sequence:
// Deployer sends: deploy(VulnerableLogicContract)
// Attacker watches mempool, sees deployment tx
// Deployment confirms — logic contract now live
// Attacker calls: logicContract.initialize(attackerAddress, 0)
// require(!_initialized) passes (first call)
// owner = attackerAddress
// _initialized = true
// Proxy's subsequent initialize() call fails: already initializedThe attacker is now owner of the logic contract. If the proxy delegatecalls any onlyOwner function, the attacker controls those too. More directly: the attacker can call withdrawFunds() on the logic contract itself to drain any ETH sent to it.
Attack timeline
- 1
- 2
- 3
- 4
- 5
4. The fix — disableInitializers + initializer modifier
Add _disableInitializers() in the constructor AND add the initializer modifier from OpenZeppelin Initializable to the initialize() function:
The initializer modifier from OpenZeppelin Initializable records the highest initializer version called and reverts if any initializer is called again. _disableInitializers() sets this value to the maximum, permanently locking direct initialization on the logic contract.
In deployment scripts, call initialize() atomically in the same transaction as the proxy deployment, or use a factory that deploys and initializes in one transaction. Never leave a window between logic contract deployment and proxy initialization.
5. What to look for as an auditor
- Every upgradeable contract (logic/implementation): verify the constructor calls _disableInitializers(). If not present, flag it.
- Every initialize() function: verify it has the initializer modifier (or reinitializer). A manual _initialized bool is insufficient — it can be bypassed or set incorrectly.
- Deployment scripts and deployment transactions: check that initialize() is called atomically with the proxy deployment (same tx or same factory contract call). Any gap is an attack window.
- If initialize() is missing entirely: the proxy may be uninitialized, leaving owner as address(0) — also critical (anyone can call functions that check msg.sender == owner with a zero-address owner).
- Reinitializers: contracts that can be upgraded may have reinitializer(N) functions for migration. Check that each version's reinitializer cannot be called by an attacker to reset ownership.
- The OpenZeppelin Upgrades plugin (Hardhat/Foundry) enforces some of these checks at build time. Contracts that bypass the plugin should be audited manually for all of the above.
With the two-layer initialization model in mind, open the Hunt tab. Identify what is missing in the constructor and initialize() function, then describe the front-run sequence.