On November 6, 2017, a single wallet library contract was killed. The call was seven lines of code. The result: $280 million in Ether frozen permanently — not stolen, just unreachable forever. This was the Parity multi-sig wallet freeze, and the root cause was a single uninitialized implementation contract that anyone could take ownership of.
The initialization gap in upgradeable contracts has been known since 2017. It still appears in production audits in 2026. This post breaks down every variant — with vulnerable code, secure code, and the specific patterns ContractScan detects automatically.
How Upgradeable Proxies Work
Standard Solidity contracts use constructors for initialization. Constructors run once at deployment, set initial state, and are never callable again. This is safe by design.
Upgradeable contracts break this model. A proxy pattern separates state (held in the proxy contract) from logic (held in the implementation contract). When someone calls the proxy, it forwards execution to the implementation using delegatecall. The implementation's code runs, but writes go to the proxy's storage slots.
The problem: constructors do not run when a contract is deployed as an implementation target. The proxy uses delegatecall, not a fresh deployment, so the implementation's constructor never executes in the context where it matters — the proxy's storage. Any initialization that happened in the constructor is in the implementation's own storage, not the proxy's.
The solution the ecosystem settled on: replace constructors with an initialize() function and call it manually once after the proxy is set up. OpenZeppelin's Initializable contract provides the initializer modifier to enforce single-call semantics.
This works — when done correctly. When done incorrectly, it creates the vulnerability classes below.
Vulnerability 1: Uninitialized Implementation Contract
When a UUPS or Transparent Proxy implementation is deployed, the implementation contract itself sits at a real address on-chain. The proxy calls it via delegatecall, but anyone can also call the implementation directly as a regular call.
If initialize() was never called on the implementation contract directly, an attacker can call it themselves, set their address as owner, and then exploit whatever the owner is authorized to do.
// Vulnerable implementation — no protection on the bare contract
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
function initialize(address _owner) public {
__Ownable_init(_owner);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
In the Parity case, the wallet library contract was an unprotected implementation. An attacker called initWallet() on it directly, became the owner, and then called kill() — a selfdestruct wrapped in an onlyOwner function. The implementation was destroyed. Every proxy (multi-sig wallet) that depended on it became permanently non-functional because delegatecall to a dead address returns empty bytes, causing every wallet transaction to fail silently.
The attacker did not drain funds. The funds became inaccessible because the logic contract ceased to exist.
This same pattern enables a UUPS exploit path: attacker calls initialize() on the bare implementation, becomes owner, then calls upgradeTo(maliciousImpl) on the implementation directly. In UUPS, upgradeTo is defined on the implementation and checks the caller against the implementation's own owner slot — not the proxy's. The proxy's upgrade logic is now hijacked.
Vulnerability 2: Missing initializer Modifier
If initialize() is not guarded by the initializer modifier from OpenZeppelin's Initializable, it can be called any number of times — including after the contract has been live for months.
// Vulnerable — missing initializer modifier
contract TokenV1 is Initializable, OwnableUpgradeable {
uint256 public totalSupply;
function initialize(address _owner, uint256 _supply) public {
// No modifier — callable unlimited times
__Ownable_init(_owner);
totalSupply = _supply;
}
}
An attacker calls initialize(attackerAddress, 0) at any time. Ownership transfers to the attacker. From there, any admin function is accessible. The initializer modifier from OpenZeppelin uses a boolean flag in a specific storage slot to enforce that the function runs exactly once.
Vulnerability 3: _disableInitializers() Not Called in Implementation Constructor
Even with the initializer modifier correctly applied to initialize(), the implementation contract itself is vulnerable if nothing prevents someone from calling initialize() on it directly.
OpenZeppelin's recommended pattern since v4.6 is to call _disableInitializers() inside the implementation's constructor. This sets the initialized flag to the maximum possible value, making any future call to any initializer-guarded function revert immediately.
// Vulnerable — implementation can be initialized directly
contract VaultV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {}
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
// Secure — implementation is locked against direct initialization
contract VaultV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
The _disableInitializers() call in the constructor runs when the implementation bytecode is deployed. It permanently locks the implementation's own storage, preventing direct exploitation. The proxy's storage remains unaffected and is initialized normally via the proxy's first initialize() call.
Vulnerability 4: Reinitializer Version Not Incremented on Upgrade
When a contract is upgraded to a new version that requires running additional initialization logic (setting new storage variables, migrating values), the reinitializer(N) modifier is used. It accepts a version number and ensures that initialization at that version level runs exactly once.
The bug: if a developer forgets to increment the version number, the old initializer can be called again after an upgrade.
// VaultV1 — version 1 initializer
contract VaultV1 is Initializable, OwnableUpgradeable {
uint256 public cap;
function initialize(address _owner, uint256 _cap) public initializer {
__Ownable_init(_owner);
cap = _cap;
}
}
// VaultV2 — vulnerable: uses reinitializer(1) instead of reinitializer(2)
contract VaultV2 is VaultV1 {
address public feeRecipient;
function initializeV2(address _feeRecipient) public reinitializer(1) {
// Version 1 was already consumed — this will revert on correct chains
// but if the upgrade path skipped re-initialization, the wrong version
// may be stored, allowing a later call at the intended version
feeRecipient = _feeRecipient;
}
}
// VaultV2 — secure: increments to reinitializer(2)
contract VaultV2 is VaultV1 {
address public feeRecipient;
function initializeV2(address _feeRecipient) public reinitializer(2) {
feeRecipient = _feeRecipient;
}
}
Always increment the reinitializer version for each new upgrade that needs initialization. The version must be strictly greater than all previously consumed versions.
Vulnerability 5: Storage Layout Collision on Upgrade
This is not an initialization bug in the strict sense, but it is commonly introduced during the upgrade process and can corrupt all state the contract holds.
The proxy stores state in numbered storage slots. Slot 0 holds the first declared variable, slot 1 holds the second, and so on. The implementation contract must declare variables in the same order to avoid misreading slots.
// V1 — original layout
contract VaultV1 {
address public owner; // slot 0
uint256 public balance; // slot 1
}
// V2 — vulnerable: new variable inserted in the middle
contract VaultV2 {
address public owner; // slot 0
address public guardian; // slot 1 — NEW, shifts everything below
uint256 public balance; // slot 2 — was slot 1 in V1, now corrupted
}
After the upgrade, any read of balance returns the bytes stored at slot 2, which previously held nothing or a different variable. Any write to balance corrupts slot 2 instead of slot 1. Depending on what sits at slot 2, this can silently corrupt ownership, balances, or access control state.
The fix is the gap storage pattern. Reserve unused slots in each version so future variables can be appended into the gap rather than inserted into the middle:
// V1 — gap reserved for future variables
contract VaultV1 {
address public owner; // slot 0
uint256 public balance; // slot 1
uint256[48] private __gap; // slots 2-49 reserved
}
// V2 — appends into the gap, layout intact
contract VaultV2 is VaultV1 {
address public guardian; // slot 2, within the old gap
// __gap is now effectively 47 slots wide
}
OpenZeppelin's upgradeable contracts all include __gap arrays for exactly this reason. If your base contracts do not, every upgrade is a storage collision risk.
Vulnerability 6: Unprotected Base Contract Initializers
Contracts in an inheritance chain each have their own __Base_init() functions, intended to be called once from the top-level initialize(). If these inner initializers are exposed as public functions without the initializer modifier, they become callable directly.
// Vulnerable base contract
contract BaseVault is Initializable {
address public admin;
// Missing onlyInitializing modifier — callable directly
function __BaseVault_init(address _admin) public {
admin = _admin;
}
}
contract VaultV1 is BaseVault, OwnableUpgradeable {
function initialize(address _owner) public initializer {
__BaseVault_init(_owner);
__Ownable_init(_owner);
}
}
An attacker calls __BaseVault_init(attackerAddress) on the proxy directly, bypassing the top-level initializer guard entirely. Admin is reset to the attacker.
The fix: use onlyInitializing on all internal init functions. This modifier allows calls only during an active initialization sequence, not as standalone calls.
// Secure base contract
contract BaseVault is Initializable {
address public admin;
function __BaseVault_init(address _admin) internal onlyInitializing {
admin = _admin;
}
}
Secure Pattern: Full OpenZeppelin Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract SecureVault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public cap;
uint256[49] private __gap; // Reserve slots for future variables
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // Lock bare implementation against direct calls
}
function initialize(address _owner, uint256 _cap) public initializer {
__Ownable_init(_owner);
__UUPSUpgradeable_init();
cap = _cap;
}
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
// V2 upgrade — correct reinitializer version
contract SecureVaultV2 is SecureVault {
address public feeRecipient;
// __gap is inherited; this variable occupies a slot within it
function initializeV2(address _feeRecipient) public reinitializer(2) {
feeRecipient = _feeRecipient;
}
}
This example covers all six vectors: _disableInitializers() in the constructor, initializer on the main init function, onlyOwner on _authorizeUpgrade, gap storage reserved, and reinitializer(2) incremented on upgrade.
What ContractScan Detects
| Vulnerability | Detection Method |
|---|---|
| Uninitialized implementation contract | Static analysis: initialize() callable on non-proxy address |
Missing initializer modifier |
AST check: initialize function without modifier guard |
Missing _disableInitializers() in constructor |
Pattern match: constructor body in upgradeable contracts |
Wrong reinitializer version |
Cross-version analysis: version number not incremented |
| Storage layout collision | Slot mapping diff across V1/V2 ABI and storage declarations |
Unprotected __Base_init() functions |
Visibility + modifier check on internal init functions |
Audit Your Proxy Contract
Every upgradeable contract in production carries initialization risk. The six vulnerabilities above can be present in contracts that pass a basic review — they require understanding the full upgrade lifecycle, not just the current version's source.
Audit your proxy contract at https://contract-scanner.raccoonworld.xyz
Related Reading
- Proxy Pattern Vulnerabilities: UUPS, Transparent, and Diamond
- Solidity Storage Layout Vulnerabilities and Proxy Collision
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.