Unprotected Initializer on Upgradeable Contracts — The Audius Lesson

A single missing modifier cost Audius $1.1M in 2022. Upgradeable contracts using OpenZeppelin's proxy pattern need their initializers locked down — both on the proxy and the implementation. Skip `_disableInitializers()` in the constructor or forget the `initializer` modifier, and any attacker can…

An unprotected initializer is a vulnerability in upgradeable Solidity contracts where the `initialize()` function — which replaces the constructor in proxy patterns — can be called by an attacker to seize ownership or governance. The fix requires the `initializer` modifier on the function and a `_disableInitializers()` call in the implementation's constructor to prevent direct initialization of the logic contract.
··6 min read

The Bug in One Sentence

In upgradeable contracts, constructors don't run on the proxy — so initialization logic lives in an `initialize()` function. If that function isn't protected, anyone can call it and become admin.

This isn't theoretical. Audius lost **$1.1M in July 2022** because of exactly this. We'll get to that.

Why Upgradeable Contracts Need Initializers at All

Proxy patterns (Transparent, UUPS, Beacon) separate storage from logic. The proxy holds state; the implementation holds code. When you deploy an implementation, its constructor runs — but it runs in the *implementation's* storage context, not the proxy's. So any state your constructor sets (owner, governance, token name) lives in the wrong contract.

The workaround: move constructor logic into a regular function called `initialize()`, and call it via the proxy after deployment. OpenZeppelin's `Initializable` contract gives you the `initializer` modifier to enforce this runs exactly once.

Three things can go wrong:

1. The initializer is missing the `initializer` modifier (callable repeatedly).
2. The implementation contract isn't locked, so someone initializes it directly.
3. A reinitializer is misnumbered, letting old versions re-run.

The Vulnerable Pattern

```solidity
// VULNERABLE
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
uint256 public totalDeposits;

// No initializer modifier — can be called repeatedly
function initialize(address _owner) public {
__Ownable_init();
transferOwnership(_owner);
}

function _authorizeUpgrade(address) internal override onlyOwner {}
}
```

Two problems here:

**No `initializer` modifier**: anyone who frontruns the deployer (or just calls it later, since `owner` starts as `address(0)` and `transferOwnership` doesn't check current ownership in this flow) takes control.
- **No constructor locking the implementation**: even if the proxy is initialized correctly, the implementation contract is still uninitialized. An attacker calls `initialize()` directly on the implementation, becomes owner of the implementation, then for UUPS calls `upgradeTo()` with a malicious contract containing `selfdestruct`. The implementation gets bricked. Every proxy pointing to it becomes useless.

This is the [parity wallet pattern](https://www.parity.io/blog/security-alert/) — different bug class, same root cause: uninitialized logic contract.

The Fix

```solidity
// FIXED
contract VaultV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public totalDeposits;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize(address _owner) public initializer {
__Ownable_init();
__UUPSUpgradeable_init();
transferOwnership(_owner);
}

function _authorizeUpgrade(address) internal override onlyOwner {}
}
```

Three things changed:

1. **`initializer` modifier** — enforces single-call semantics on the proxy.
2. **`_disableInitializers()` in the constructor** — runs on the implementation at deployment, marks it as initialized so direct calls revert.
3. **Parent `__*_init()` calls** — every inherited upgradeable contract needs its own init function run, or its state stays zero (this is a separate footgun: forget `__UUPSUpgradeable_init()` and your upgrade authorization may misbehave).

For V2 onwards, use `reinitializer(2)` with monotonically increasing version numbers.

Audius: $1.1M, July 2022

Audius — a decentralized music streaming protocol — had two compounding issues. Their `Governance` and `Staking` contracts inherited from `InitializableV2`, which used a storage slot to track initialization state. Due to a **storage layout collision** between `Governance` and the proxy admin, the initialization variable overlapped with another field. The attacker re-initialized the governance contract, became the sole guardian, then submitted a malicious proposal transferring 18.5M AUDIO tokens (~$6M nominal, ~$1.1M realized after dumping) from the community treasury to themselves.

The root cause was unprotected reinitialization combined with storage collision. The fix would have been:

Use OpenZeppelin's standard `Initializable` with the canonical storage slot (EIP-1967 style).
- Lock implementations with `_disableInitializers()`.
- Audit storage layouts on every upgrade.

Audius did pay the attacker a bounty and recovered most funds via a counter-proposal, but the protocol's governance was effectively frozen during the incident. Read the [postmortem](https://blog.audius.co/article/audius-governance-takeover-post-mortem).

UUPS Makes This Worse

Under Transparent Proxy, upgrade logic lives in the ProxyAdmin — a separate contract. Under UUPS, upgrade authorization lives in the implementation itself (`_authorizeUpgrade`). If an attacker initializes an unprotected UUPS implementation, they become its owner *and* can call `upgradeToAndCall()` directly on the implementation, pointing it to a contract with `selfdestruct`. The implementation dies, taking every proxy with it.

This is what happened to the original OpenZeppelin UUPS implementations before [the 2021 patch](https://forum.openzeppelin.com/t/uupsupgradeable-vulnerability-post-mortem/15680). 25+ projects were affected; OpenZeppelin coordinated upgrades to mitigate.

Detection Checklist

When reviewing upgradeable contracts:

[ ] Does every `initialize*` function have the `initializer` or `reinitializer(n)` modifier?
- [ ] Does the implementation contract have a constructor calling `_disableInitializers()`?
- [ ] Are all parent `__*_init()` and `__*_init_unchained()` functions called in the right order?
- [ ] Is the initializer callable only by the intended deployer (or otherwise frontrun-resistant)? Many teams add an `onlyDeployer` check or initialize atomically in the deployment transaction.
- [ ] On every upgrade, is the storage layout validated? Use `hardhat-upgrades`' `validateUpgrade` or Foundry's `forge inspect`.
- [ ] Is there a `reinitializer(n)` with a version number that hasn't been used before?

Atomic Deployment Pattern

Frontrunning the initializer is rare but possible on public mempools. The bulletproof pattern is to deploy and initialize atomically using the proxy constructor:

```solidity
// Using OpenZeppelin's ERC1967Proxy
bytes memory initData = abi.encodeWithSelector(
VaultV1.initialize.selector,
ownerAddress
);
ERC1967Proxy proxy = new ERC1967Proxy(implementation, initData);
```

This calls `initialize()` in the same transaction as the proxy deployment. No mempool window, no race condition.

How to Audit For This

This bug is mechanical — static analysis tools (Slither's `unprotected-upgrade`, OpenZeppelin's upgrade plugin) catch the obvious cases. They miss compound bugs like Audius's storage collision. For protocols handling real money on upgradeable architecture, run the [free AI audit](https://www.cryptohawking.com/audit) first to catch the low-hanging fruit, then escalate to a [manual audit](https://www.cryptohawking.com/audit/manual) where a human traces storage layouts across upgrades and checks every inherited initializer chain. Upgradeable contracts are where boring bugs become billion-dollar bugs.

TL;DR

`_disableInitializers()` in the constructor. Always.
- `initializer` modifier on `initialize()`. Always.
- Initialize atomically in the deployment tx.
- Validate storage layout on every upgrade.
- UUPS amplifies the blast radius — treat it accordingly.

Audius paid $1.1M to learn this. You shouldn't have to.

FAQ

What's the difference between `initializer` and `reinitializer(n)`?

The `initializer` modifier allows a function to run exactly once, ever, on a given contract. The `reinitializer(n)` modifier allows a function to run once per version `n`, where `n` is monotonically increasing. Use `initializer` for V1. Use `reinitializer(2)`, `reinitializer(3)`, etc. for subsequent upgrades that need new initialization logic. You cannot decrease the version number, and the standard `initializer` modifier is equivalent to `reinitializer(1)`.

Why does `_disableInitializers()` go in the constructor?

Constructors run when the implementation contract is deployed, but their state changes only affect the implementation's own storage — not the proxy's. By calling `_disableInitializers()` in the constructor, you mark the implementation itself as fully initialized, so nobody can call `initialize()` directly on it. The proxy's storage is separate and unaffected, so the proxy can still be initialized normally via `delegatecall`. This protects against the Parity-style 'kill the implementation' attack.

Can I just check `owner == address(0)` instead of using `initializer`?

No. Manual checks miss the reinitialization case after `transferOwnership` or `renounceOwnership`. They also don't compose with parent contract initializers — every `OwnableUpgradeable`, `ReentrancyGuardUpgradeable`, etc. has its own `__*_init` that needs single-call semantics. The OpenZeppelin `Initializable` contract uses a dedicated storage slot tracking initialization state across the entire inheritance chain. Roll your own and you'll miss an edge case. Use the modifier.

Does this affect Transparent Proxies too, or just UUPS?

Both, but the blast radius differs. Transparent Proxies put upgrade authorization in a separate `ProxyAdmin` contract, so even if you initialize an unprotected implementation, you can't upgrade the proxy through it. You can still grief by being 'owner' of the implementation, but the proxy itself is safe. UUPS puts upgrade authorization in the implementation, so initializing an unprotected UUPS implementation gives the attacker upgrade rights — they can `selfdestruct` it and brick every proxy. UUPS is more efficient but less forgiving.

How do I migrate an already-deployed contract that's missing `_disableInitializers`?

You can't add a constructor to a deployed contract. Instead: deploy a new implementation with the constructor fix and the same storage layout, then upgrade the proxy to point at it. The old implementation is still vulnerable to direct initialization, but if nobody else points proxies at it, the damage is limited to that one orphan contract. Front-run any potential attacker by calling `initialize()` on the old implementation yourself with a burn address as owner, effectively neutralizing it.

One Solidity tip + 1 case study per month

Unprotected Initializer on Upgradeable Contracts — The Audius Lesson | Crypto Hawking