Skip to content
Quantix Security Labs
Primer

Initializers, proxies and the contract nobody owns

Upgradeable contracts move construction logic into ordinary functions. That small change creates a family of access-control failures that a constructor would never allow.

Written by
Quantix Research, Smart Contract Security
Published
Reading time
7 minutes
Category
Smart Contract Vulnerabilities

Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Illustration: a proxy delegating to an implementation contract whose initializer slot was never set, with an outside caller reaching it.
Contents
  1. Why constructors disappear
  2. The initializer anyone can call
  3. The implementation nobody owns
  4. Re-initialisation and inheritance
  5. Who may upgrade, and how fast
  6. A safer baseline
  7. Review checklist
  8. Conclusions
  9. Recommendations
  10. Bibliography

A constructor is one of the few places in Solidity where access control is automatic. It runs once, at deployment, in the context of the contract being created, and nobody can call it again. Upgradeable contracts give that guarantee up. Their setup logic moves into an ordinary function, and ordinary functions have to be protected by hand.

This primer walks through the failures that follow from that change, why some of them are subtle, and the baseline Quantix reviewers expect to see in any proxy-based system.

Why constructors disappear

In the common proxy patterns, users interact with a small proxy contract that stores all state and forwards every call to an implementation contract using delegatecall. The implementation’s code runs, but it reads and writes the proxy’s storage. Standardised storage slots, defined in EIP-1967, tell tools where the proxy keeps the implementation address and admin.

A constructor, however, runs only once, when the implementation is deployed, and it writes to the implementation’s own storage. Nothing it sets is visible through the proxy. So upgradeable contracts replace the constructor with an initialize function that the proxy calls after deployment. From that point on, the safety of the system depends on three things the compiler no longer enforces: that initialize runs exactly once, that the right party runs it, and that the implementation contract itself cannot be initialised by someone else.

Proxy, implementation and two ways to take an uninitialised contractUsers call a proxy, which delegates every call to an implementation contract: the implementation's code runs against the proxy's storage. An attacker can call initialize on the proxy if deployment and initialisation happen in separate transactions, or call initialize directly on the implementation, whose own storage was never initialised.Users, integratorscall the proxy addressProxy (ERC-1967)storage: owner, initialized,balances, …fallback(): delegatecall tothe implementationImplementationcode: initialize(),upgradeToAndCall(), …own storage: initialized =false unless disableddelegatecallImplementation code runs against the proxy’s storage.Attackerwatches deployments12initialize() on the proxy, between deploy and initialiseinitialize() on the implementation directlyFix: deploy and initialise atomically, and call _disableInitializers() in the implementation’s constructor.
Two ways to take control of an uninitialised upgradeable contract: initialising the proxy before its deployer does, and initialising the implementation directly.

The initializer anyone can call

The simplest failure is an initializer without a guard.

VaultV1.sol (illustrative)solidity
// Illustrative only. Several independent problems are shown together.contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {    IERC20 public asset;     // (1) No constructor: the implementation itself is never locked.     // (2) No initializer modifier: callable again, by anyone, at any time.    function initialize(IERC20 asset_, address owner_) external {        asset = asset_;        __Ownable_init(owner_);    }     function _authorizeUpgrade(address) internal override onlyOwner {}}

Without the initializer modifier, initialize can be called again after deployment. Here, calling it re-runs __Ownable_init and hands ownership, and with it the power to authorise upgrades, to whoever calls it. An upgrade to a malicious implementation then gives the caller control of every asset the proxy holds.

A second, quieter version of the same problem is a race. If the proxy is deployed in one transaction and initialised in the next, anyone watching the mempool can call initialize first with their own parameters. The fix is to make deployment and initialisation atomic: proxy constructors that accept initialisation data execute it in the same transaction.

deploy.ts (illustrative)ts
// Two separate transactions: the window between them is the vulnerability.const proxy = await ERC1967Proxy.deploy(implementation, "0x")   // tx 1await vault.attach(proxy).initialize(asset, owner)               // tx 2 (can be front-run) // One transaction: initialisation data is executed by the proxy constructor.const data = Vault.interface.encodeFunctionData("initialize", [asset, owner])const proxy = await ERC1967Proxy.deploy(implementation, data)

The implementation nobody owns

Even when the proxy is initialised correctly, the implementation contract sits on-chain as a separate, callable contract with its own, empty storage. If its initialize function is unprotected at that address, anyone can call it and become the implementation’s owner.

For many designs that is harmless, because the implementation’s storage is never used. For UUPS proxies it was not. In the UUPS pattern the upgrade logic lives in the implementation. In 2021 OpenZeppelin published an advisory (CVE-2021-41264) for UUPSUpgradeable in Contracts 4.1.0 to 4.3.1: an attacker who initialised an implementation could call its upgrade function directly and have it delegatecall into a contract that executed selfdestruct, removing the implementation’s code and leaving every proxy pointing at it unable to operate.

The defence is one line. Calling _disableInitializers() in the implementation’s constructor marks it as fully initialised, so initialize reverts at the implementation address while still working through the proxy, whose storage is separate.

Re-initialisation and inheritance

Upgrades often need setup of their own: a new fee parameter, a new role, a migrated value. Teams sometimes add an unguarded setupV2 function and call it straight after the upgrade, which reopens the original problem for the new variables. Versioned guards such as OpenZeppelin’s reinitializer(n) allow each version’s setup to run exactly once.

Inheritance adds a second hazard. Upgradeable base contracts expose their own initialisation functions, and in deep or diamond-shaped hierarchies it is easy to call a parent’s initialiser twice, or not at all. Calling it twice may silently reset state; skipping it leaves variables at their zero values, which for an owner or a pause flag can be just as dangerous as an attacker’s choice.

Finally, every upgrade must preserve storage layout. Adding a variable to a base contract shifts the slots of every variable declared after it in derived contracts. Reserved storage gaps, or namespaced storage as described in ERC-7201, keep layouts stable across versions, and upgrade tooling can check layouts automatically before a deployment.

Who may upgrade, and how fast

Initialisation decides who holds privileged roles; upgrade authorisation decides what those roles can do with them. In the transparent proxy pattern the upgrade function lives in the proxy and is reachable only by a dedicated admin, usually a separate admin contract. In UUPS the upgrade function lives in the implementation, and each implementation must restrict it by overriding an authorisation hook such as _authorizeUpgrade.

That design places a burden on every future version. An implementation that forgets the access check lets anyone upgrade the proxy. An implementation that omits the upgrade logic entirely leaves the proxy permanently frozen at that version, which may be intended but is rarely discussed. OpenZeppelin’s UUPS implementation checks that a new implementation reports the expected proxiable identifier before switching to it, which catches some, but not all, of these mistakes.

Speed matters as much as authority. An upgrade that takes effect in the same block it is proposed gives users no chance to react to a malicious or mistaken change. Routing upgrades through a timelock, and publishing the pending implementation address, turns an instantaneous risk into an observable one that monitoring can flag.

A safer baseline

VaultV1.sol / VaultV2.sol (illustrative)solidity
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {    IERC20 public asset;     /// @custom:oz-upgrades-unsafe-allow constructor    constructor() {        _disableInitializers();       // the implementation can never be initialised    }     function initialize(IERC20 asset_, address owner_) external initializer {        __Ownable_init(owner_);        __UUPSUpgradeable_init();        asset = asset_;    }     function _authorizeUpgrade(address) internal override onlyOwner {}} contract VaultV2 is VaultV1 {    uint256 public withdrawalFee;     // New setup for version 2 runs once, and only through a guarded path.    function initializeV2(uint256 fee) external reinitializer(2) onlyOwner {        withdrawalFee = fee;    }}
FailureWhat goes wrongDefence
Unguarded initializerAnyone can re-run setup and take privileged roles.Use initializer; test that a second call reverts.
Deploy-then-initialise raceAn observer initialises the proxy first.Pass initialisation data to the proxy constructor.
Uninitialised implementationAttacker owns the implementation contract.Call _disableInitializers() in the constructor.
Unguarded upgrade setupNew-version setup can be called repeatedly or by anyone.Use reinitializer(n) plus access control.
Skipped or doubled parent initState reset, or left at zero values.Map the inheritance graph; test post-deployment state explicitly.
Storage layout driftVariables collide after an upgrade.Storage gaps or ERC-7201 namespaces; automated layout checks.
Initialisation failures in upgradeable contracts and their defences

Review checklist

  • Every initialize and versioned setup function carries an initialisation guard.
  • Implementation constructors call _disableInitializers().
  • Deployment scripts initialise proxies atomically; the scripts are in scope for review.
  • Each parent initialiser is called exactly once, and tests assert the resulting state.
  • Upgrade authorisation is restricted, ideally behind a timelock that users can observe.
  • Storage layout is checked between the deployed version and the candidate upgrade.

Most of these items can be tested, and should be. A deployment test that runs the real deployment script against a local fork, then asserts the owner, the paused state, every role and every configured address through the proxy, catches skipped initialisers before they reach production. A second test that calls initialize again, both on the proxy and on the implementation, and expects both calls to revert, documents the guarantee in code. For upgrades, a test that deploys the next version, performs the upgrade through the same path governance will use, and re-checks the stored state, is the cheapest insurance available against layout drift.

None of these items is exotic. They are easy to state and easy to miss, because the code that breaks them often looks exactly like code that is correct. That is why reviewers check deployment scripts and upgrade procedures alongside the contracts themselves.

Conclusions

Moving construction into an ordinary function moves its guarantees into the developer’s hands. Every failure in this primer is the absence of one of three properties: initialisation runs exactly once, the right party runs it, and the implementation contract cannot be initialised by anyone else.

Each property is cheap to establish and cheap to test, and missing one usually costs control of the whole system. Our primer on upgradeable proxy security continues with storage layout, selector clashes and the governance of upgrades.

Recommendations

Developers

  1. Guard every initialiser with initializer or reinitializer(n), and call _disableInitializers() in every implementation constructor.
  2. Initialise proxies atomically by passing initialisation data to the proxy constructor.

Deployment and governance

  1. Treat deployment and upgrade scripts as in scope for review.
  2. Route upgrades through a timelock and publish the pending implementation address.

Reviewers

  1. Assert the post-deployment state through the proxy: owner, roles, paused flag and configured addresses.
  2. Test that a second call to initialise reverts on both the proxy and the implementation.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Writing Upgradeable Contracts(opens in a new tab)OpenZeppelin Upgrades documentation
  2. 2ERC-1967: Proxy Storage Slots(opens in a new tab)Ethereum Improvement Proposals
  3. 3ERC-1822: Universal Upgradeable Proxy Standard (UUPS)(opens in a new tab)Ethereum Improvement Proposals
  4. 4EIP-6780: SELFDESTRUCT only in same transaction(opens in a new tab)Ethereum Improvement Proposals
  5. 5ERC-7201: Namespaced Storage Layout(opens in a new tab)Ethereum Improvement Proposals
  6. 6CVE-2021-41264: UUPSUpgradeable vulnerability(opens in a new tab)NIST National Vulnerability Database

Applied on this site

Assessments, case studies and tools that apply this publication.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.