Web3 & smart contracts
Verified by ROOTVIEL12 minUninitialized / Re-initializable Proxy
Upgradeable contracts replace constructors with an initializer function; if that function is not protected against repeated or late invocation, anyone can call it to become owner, set critical parameters, or seize an implementation contract left uninitialized behind a proxy.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- Rootchain
- CWE
- CWE-665, CWE-284
- OWASP
- SC01
Definition
An uninitialized or re-initializable proxy is an upgradeable contract whose one-time setup step is either never executed, left open to a second execution, or executable by an unintended caller. Because a contract reached through a proxy cannot use a Solidity constructor to set its initial state — a constructor runs in the implementation’s own storage at deploy time, not in the proxy’s storage — the setup is moved into an ordinary function, conventionally initialize(). If that function lacks a guard, its privileged effects (assigning ownership, setting admin roles, wiring parameters) can be claimed by whoever calls it first, or re-claimed later.
There are two related exposures: the proxy was deployed but initialize() was never called in the same transaction, so an attacker front-runs it; and the implementation contract itself was left uninitialized, so an attacker initializes the implementation directly and — in UUPS proxies where the upgrade authorization lives in the implementation — gains the ability to upgrade or self-destruct it.
Context
Every delegatecall-based upgradeable contract faces this because the constructor is unavailable for proxy state. The convention (OpenZeppelin’s Initializable) uses a modifier that records whether initialization has happened. The risk concentrates at deployment and in UUPS designs where the implementation carries its own upgrade authorization.
- ▸Transparent and UUPS proxies that move constructor logic into initialize().
- ▸UUPS implementations where _authorizeUpgrade lives in the logic contract — an uninitialized implementation can be taken over and upgraded/bricked.
- ▸Factory-deployed clones (EIP-1167 minimal proxies) that must each be initialized atomically.
- ▸Re-initializers used across versions (reinitializer(n)) that, if misversioned, re-open setup.
Technical fundamentals
A constructor executes exactly once, at contract creation, and its code is not part of the deployed runtime bytecode. Under a proxy, the implementation is deployed once (its constructor runs against its own, soon-ignored storage) and the proxy then delegatecalls into it; the proxy’s storage was never touched by that constructor. The initializer pattern substitutes a runtime function plus a persisted flag. OpenZeppelin’s Initializable stores an _initialized version counter: the initializer modifier requires the counter to be below the target version and then bumps it, so a plain initializer runs once and reinitializer(n) runs once per version n.
A correctly built implementation also calls _disableInitializers() in its own constructor, which sets the version counter to the maximum so the implementation contract can never be initialized directly — only through a proxy’s fresh storage. The invariant to hold is: initialization happens exactly once, in the intended storage, by the intended actor, atomically with deployment.
- Initializer
- A runtime function that performs one-time setup in place of a constructor, guarded so it runs only once.
- _initialized counter
- A persisted version number that records how far initialization has progressed and blocks re-entry.
- _disableInitializers()
- A constructor-time call that locks the implementation so it can never be initialized on its own storage.
- Atomic deploy+init
- Deploying the proxy and calling initialize() in one transaction, closing the front-running window.
Root cause
The root cause is a missing or incorrect one-time-execution guard on a function that performs privileged setup. Constructors are enforced-once by the EVM; initializers are ordinary functions whose single-execution property must be implemented and applied explicitly. The defect appears when the guard modifier is omitted, when deployment and initialization are split across transactions (leaving a window), when the implementation is not locked with _disableInitializers(), or when a reinitializer is given a version that re-opens an already-completed step.
It is an access-control-at-genesis failure: the code that assigns authority is itself unauthorized.
How the attack works, step by step
- 1A proxy is deployed, or an implementation is deployed, without initialize() being called guarded and atomically.
- 2The attacker observes the unprotected initialize() in the public bytecode or source.
- 3The attacker calls initialize(), passing their own address as owner / admin / upgrade authority.
- 4The guard either does not exist or had not yet been tripped, so the call succeeds and the persisted flag now records the attacker’s setup.
- 5For a UUPS implementation specifically, the attacker — now the upgrade authority — points the implementation at malicious logic or triggers a self-destruct, bricking every proxy that relies on it.
The re-initializable variant replaces step 2–4: a misversioned reinitializer lets the attacker run setup a second time, overwriting an existing owner or re-wiring parameters even after legitimate initialization.
Vulnerable example
A synthetic, illustrative upgradeable contract whose initializer has no guard and whose implementation is not locked:
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT // UNSAFE: initialize() is unguarded and the implementation is not locked. pragma solidity ^0.8.20; contract VaultLogic { address public owner; bool public started; // No initializer modifier, no once-only flag check. function initialize(address _owner) external { owner = _owner; // anyone can set themselves as owner started = true; } function withdrawAll(address to) external { require(msg.sender == owner, "not owner"); payable(to).transfer(address(this).balance); } // No constructor calling _disableInitializers(): // the implementation itself can be initialized directly. }
If the proxy is deployed without an atomic initialize(), an attacker calls initialize(attacker) first and becomes owner. Separately, the bare implementation can be initialized directly and — in a UUPS variant — taken over.
Impact
- ▸Authorization: an attacker becomes owner/admin and controls all privileged functions — withdrawals, parameter changes, pausing.
- ▸Code control (UUPS): seizing the upgrade authority on an uninitialized implementation lets the attacker upgrade to malicious logic or self-destruct the implementation, bricking dependent proxies.
- ▸Integrity: re-initialization overwrites existing configuration, including an already-assigned owner.
- ▸Fund loss: direct theft once owner-gated withdrawal functions are reachable.
Detection
Static analysis looks for initialize-style functions reachable externally that set ownership/roles without an initializer guard, for implementations missing a _disableInitializers() call in the constructor, and for reinitializer version numbers that overlap prior ones. Deployment-script analysis checks whether proxy creation and initialization occur in a single transaction. A static flag here is a precondition finding, not a confirmed takeover.
Dynamic detection attempts the call: on a local fork, send initialize() from an unprivileged account and observe whether ownership changes, or call initialize() a second time and observe whether it succeeds. An observed ownership change is a reproduced finding; a revert on the paths tried is informational, not proof the pattern is sound everywhere.
How autonomous agents investigate it
An autonomous agent enumerates every externally-reachable function that writes an owner, admin, or role variable, and classifies each as guarded or unguarded by inspecting for the initializer modifier and the persisted version flag. It separately checks the implementation’s constructor for _disableInitializers(), and reads the deployment transaction graph to see whether initialize() was bundled with proxy creation or left as a standalone, front-runnable call.
Finding an unguarded initializer is a SIGNAL. The agent then formulates the concrete takeover: which account calls initialize() with what arguments, and what privileged action that enables. Until it executes that call on a disposable chain and observes the ownership slot change, it reports UNKNOWN rather than claiming exploitability — and it never reports "protected" merely because a guard is present, since a guard can still be bypassed by a split deployment or a misversioned reinitializer it has not exercised.
How Rootchain analyzes it
- Support level
- Verified — unguarded/late/re-entrant initialization is deterministically reproducible, and Rootchain both detects the pattern statically and executes the takeover dynamically.
- Evidence model
- Evidence-first. A missing modifier is a signal; a Finding requires actually calling initialize() from an unprivileged account on a disposable local chain or fork and observing ownership transfer or a successful second initialization.
- Declared property
- “Privileged setup runs exactly once, in proxy storage, and cannot be invoked by an unprivileged actor”; for UUPS, “the bare implementation cannot be initialized directly.”
Rootchain uses AI only to compose the candidate attack — the caller, the arguments, the ordering (front-run before the legitimate init, or re-initialize after it). It then deploys the proxy and implementation on a local blockchain or a local fork and executes that candidate. A deterministic, AI-free verifier reads the owner/role slots and the _initialized counter before and after and checks the declared property: did an unprivileged account acquire ownership, or did a second initialization succeed? Only a reproduced takeover is graded a Finding. There is no live-network attack mode — the proof is obtained only against disposable local state.
A clean run means no takeover was reproduced on the paths exercised — UNKNOWN, not "safe". Coverage of exotic reinitializer-version mistakes and factory-clone patterns varies; keep the maturity caveat.
Mitigation
Guard the initializer, lock the implementation, and deploy atomically.
- ▸Preventive: apply the initializer modifier (OpenZeppelin Initializable) to initialize() so it runs exactly once; use reinitializer(n) with strictly increasing, unique version numbers.
- ▸Preventive: call _disableInitializers() in the implementation’s constructor so the bare logic contract can never be initialized directly.
- ▸Preventive: restrict who may initialize where appropriate (e.g. only the factory/deployer), and set owner/roles inside the guarded initializer.
- ▸Process: deploy proxy and initialize in a single transaction (factory or multicall) to eliminate the front-running window; verify post-deploy that ownership is the intended address.
Corrected example
The initializer is guarded, the implementation is locked at construction, and setup assigns ownership inside the guard:
synthetic — OpenZeppelin initializer pattern// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; contract VaultLogic is Initializable, OwnableUpgradeable { bool public started; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); // lock the bare implementation } function initialize(address _owner) external initializer { __Ownable_init(_owner); // runs exactly once, in proxy storage started = true; } function withdrawAll(address to) external onlyOwner { payable(to).transfer(address(this).balance); } }
Why it works: the initializer modifier flips the persisted version flag so a second call reverts; _disableInitializers() maxes that flag on the implementation at construction, so the bare logic cannot be initialized; __Ownable_init sets ownership only inside the guarded path. Deploying and initializing in one transaction closes the front-run window.
Limits: the modifier guarantees once-only execution but not correct ordering — a split deploy still leaves a window the modifier cannot close, so atomic deployment remains required. reinitializer(n) is only safe if version numbers are managed carefully; a repeated or decreasing n re-opens setup.
Verifying the mitigation
Verify on a local chain or fork: attempt initialize() from an unprivileged account after legitimate setup and confirm it reverts; attempt to initialize the bare implementation directly and confirm _disableInitializers() blocks it; confirm the deploy transaction bundled initialization and that the owner slot holds the intended address. Report "no takeover reproduced within the tested scope", never immunity.
Confirming the guard reverts a second call does not by itself prove the deployment was atomic — check the deploy script/transaction separately.
Limitations & honesty
- ▸A present initializer modifier is necessary but not sufficient — a split deployment or misversioned reinitializer can still open a window.
- ▸Dynamic reproduction covers only the caller/argument combinations exercised; untried paths remain UNKNOWN.
- ▸Factory-clone (EIP-1167) deployments must each be initialized atomically; coverage of every clone instance is not assumed.
- ▸No incident counts, figures, or CVE/SWC identifiers are asserted beyond the referenced, verifiable sources.
References
- [1]OpenZeppelin — Initializable & writing upgradeable contracts (initializers, _disableInitializers)
- [2]OpenZeppelin — Proxies & Upgrades overview
- [3]EIP-1967: Standard Proxy Storage Slots
- [4]EIP-1167: Minimal Proxy Contract
- [5]Solidity docs — Constructors and contract creation
- [6]OWASP Smart Contract Top 10
References point to public, well-known technical sources for further study.
Related articles
