$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

5 Upgradeable Contract Vulnerabilities to Check

Updated 9 min read

Quick answer. The five most common upgradeable contract vulnerabilities are missing storage gaps, non-upgradeable inheritance, skipped parent initializers, a missing initializer modifier and an unprotected UUPS upgrade. Each can corrupt storage or hand control to an attacker.

Upgradeability patterns keep getting more common, and upgradeable contract vulnerabilities are rising with them. Five come up again and again in security reviews and audit contests.

Learn these five and you will know what to check on any proxy you review.

If one is new to you, write it down: kept in a notebook or a tool like Notion, these become your checklist for upgradeable contracts.

This guide assumes you know what a proxy is. If you need a refresher on transparent and UUPS proxies, start with upgradeable smart contracts explained.

1. Missing Storage Gaps

Start with two contracts: a base contract, and an implementation contract that inherits from it.

The base declares two variables, a in slot 0 and b in slot 1. The inheriting contract declares c and d, which get slots 2 and 3.

Variables in contracts higher in the hierarchy always come first.

Now put a proxy in front. Users call the proxy, and the proxy delegatecalls the implementation. All the storage lives in the proxy, not in the implementation.

Before any upgrade, the proxy's storage holds a, b, c and d in slots 0 to 3.

What goes wrong on upgrade

Say the upgrade adds new logic to the base contract, along with a new variable x.

Now a is in slot 0, b in slot 1, and x takes slot 2. That pushes c to slot 3 and d to slot 4.

But the proxy's slot 2 still holds the old value of c. When the new logic reads x, it reads c, a completely different variable.

This almost always causes a critical logic error.

This is a specific kind of storage collision.

Appending variables to an inherited base contract pushes down every variable in the contracts that inherit from it, and the slots stop making sense to the implementation logic.

The fix: reserve space

Since the system is meant to be upgraded, add padding between the base contract's slots and the inheriting contract's slots.

You will most often see it as an array called __gap.

contract Base {
    uint256 public a;           // slot 0
    uint256 public b;           // slot 1
    uint256[48] private __gap;  // slots 2 to 49, reserved
}

// Upgrade: add x and shrink the gap by one
contract BaseV2 {
    uint256 public a;           // slot 0
    uint256 public b;           // slot 1
    uint256 public x;           // slot 2, taken from the gap
    uint256[47] private __gap;  // slots 3 to 49
}

Declaring the gap reserves the next 48 slots, even though nothing is stored in them. The inheriting contract's c and d now sit after the gap.

To upgrade, decrement the gap by one and add x. c and d stay in exactly the same slots, so nothing collides.

Each inherited base contract gets its own room for future variables.

OpenZeppelin's OwnableUpgradeable used this pattern. It declared an owner variable, then a 49-slot gap at the bottom.

A note explains that the space lets future versions add variables without shifting down storage in the inheritance chain.

The Newer Fix: Namespaced Storage

Newer versions of OwnableUpgradeable (from version 5.0 of OpenZeppelin's upgradeable contracts) no longer declare an owner state variable. The owner lives inside an OwnableStorage struct at a fixed storage location.

This is called namespaced storage, and it removes the need for a storage gap.

Instead of letting a fall into slot 0 and b into slot 1, you package them into one struct, often named after the contract.

The struct begins at a slot derived from the hash of a string that usually includes the contract's name.

That gives a huge, effectively random slot number. The struct's first field sits there, and the next field (b here) takes the following slot.

Now the inheriting contract's c is the first plainly declared variable, so the compiler puts it in slot 0, and d in slot 1.

Nothing collides, because the base contract's data lives far away.

OpenZeppelin derives the location from nested hashes of a namespace string, with the low bits masked off.

A private function returns the struct at that slot, and reading the owner means loading the struct and returning its owner field.

abstract contract OwnableLike {
    /// @custom:storage-location erc7201:example.storage.Ownable
    struct OwnableStorage {
        address owner;
    }

    function _getOwnableStorage() private pure returns (OwnableStorage storage $) {
        bytes32 slot = keccak256(abi.encode(uint256(keccak256("example.storage.Ownable")) - 1))
            & ~bytes32(uint256(0xff));
        assembly { $.slot := slot }
    }

    function owner() public view returns (address) {
        return _getOwnableStorage().owner;
    }
}

2. Non-Upgradeable Inheritance

Picture a UUPS implementation contract with a couple of issues. The first and most obvious: it inherits the regular Ownable, not OwnableUpgradeable.

This goes straight back to the first issue. The regular Ownable declares owner as a normal state variable, with no storage gap and no namespaced storage.

If a later version adds a variable to it, x writes over c, c overwrites d, and the storage collides on upgrade.

The fix is to inherit OwnableUpgradeable. You no longer call the Ownable constructor. Instead, call __Ownable_init with the initial owner during initialization.

The current OwnableUpgradeable uses namespaced storage, so its variables sit in their own slot and no remapping happens on upgrade.

Together, the first two issues give you two checks.

  • Base contracts written by the protocol team, and meant to be inherited by upgradeable contracts, use either storage gaps or namespaced storage.
  • Implementation contracts that inherit from popular libraries, such as OpenZeppelin, use the upgradeable versions of those contracts.

3. Skipped Parent Initializers

The third common vulnerability is not calling __Ownable_init, or the initializer of any other contract you inherit from. To see why that matters, look at why initializers exist.

The implementation contract is deployed independently of the proxy. Its constructor runs at deployment, but that state does not matter, because the implementation is not the live system.

The proxy holds the real storage and the real state. So once the proxy is deployed, it needs to run some constructor-like code of its own.

That is the job of an initialize function. It acts as the constructor for the implementation the proxy uses, which is why __Ownable_init goes there.

Assigning the initial owner is critical. The regular Ownable transfers ownership to the initial owner in its constructor, and OwnableUpgradeable does the same thing in its initializer.

The mistake: a contract inherits from several upgradeable contracts but forgets to call one or more of their initializers.

That can leave the system in an invalid state, and it leads to critical bugs.

4. Missing Initializer Modifier

The fourth is an unfortunate one: the initializer modifier is missing where it should be.

Without it, anyone could call initialize at any time and, through __Ownable_init, make themselves the owner.

How the initializer modifier works

The modifier lives in the Initializable contract, which itself uses namespaced storage so it is safe for upgradeable contracts. It tracks two values: initializing and initialized.

The modifier allows execution only in one of two cases. The first is initial setup: this is the top-level call carrying the modifier, and initialized is still zero.

The second is construction: the contract is already marked initialized, but address(this) has no code yet.

Because the call arrives by delegatecall, address(this) is the proxy, so this means the proxy is being deployed right now.

The modifier then sets initialized, so a second call fails. While the function runs, initializing is true, and it goes back to false afterwards.

OwnableUpgradeable's __Ownable_init uses onlyInitializing, which simply checks that initializing flag. So it can only be called during an initializer.

When the guard saves you, and when it does not

In the OwnableUpgradeable case, that guard means a missing initializer modifier does not let an attacker become owner. Instead, nobody can initialize, not even the proxy.

The initializing flag never gets set, __Ownable_init reverts, and the proxy is useless.

Other initialize functions may have no such protection, and a malicious actor could cause catastrophic damage by calling them outside initialization. Always put the initializer modifier on initialize functions.

You would be surprised how often a missing initializer modifier shows up in a real security review.

Disabling Initializers

Before the fifth issue, look at _disableInitializers, which you will see in the implementation's constructor.

When you deploy the implementation contract, you do not care about its constructor or its state. So _disableInitializers sets the implementation's own initialized value to type(uint64).max.

This does not touch the proxy's state. It sets storage on the implementation contract only.

Since the implementation's initialized is no longer zero, no one can ever call initialize on the implementation directly.

Through the proxy, initialized starts at zero, so the delegatecalled initialize still works.

That is the point: nobody should interact with the implementation contract directly, because the application's state does not depend on the implementation's storage.

5. Unprotected UUPS Upgrades

The fifth vulnerability is one we have seen in a real security review. With a UUPS upgradeable contract, you must override _authorizeUpgrade.

In UUPSUpgradeable, upgradeToAndCall has no access control of its own. It first calls _authorizeUpgrade, which has no implementation.

If you expose the upgrade function without overriding _authorizeUpgrade, anyone can call upgradeToAndCall and upgrade the proxy to any implementation they like.

As OpenZeppelin recommends, override it and add access control.

Often, though, teams protect the upgrade functions instead of _authorizeUpgrade. In the review we saw, the version of UUPSUpgradeable in use had both upgradeTo and upgradeToAndCall.

The team overrode only upgradeTo with access control and left _authorizeUpgrade alone. An attacker could still call upgradeToAndCall and set a malicious implementation.

Always override _authorizeUpgrade with onlyOwner or whatever access control you use. Do not bother overriding specific upgrade functions; this is exactly how OpenZeppelin recommends authorizing upgrades.

Upgradeability Review Checklist

Run every upgradeable contract through these checks.

  • Base contracts that upgradeable contracts inherit use storage gaps or namespaced storage.
  • Implementations inherit the upgradeable versions of library contracts, such as OwnableUpgradeable instead of Ownable.
  • initialize calls the initializer of every upgradeable parent contract.
  • Every initialize function carries the initializer modifier.
  • The implementation's constructor calls _disableInitializers.
  • UUPS contracts override _authorizeUpgrade with access control.

Here is a minimal UUPS implementation that follows all of them.

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract Vault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) external initializer {
        __Ownable_init(initialOwner);
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

For more bugs that survive into audits, see sneaky Solidity bugs and common smart contract vulnerabilities.

How Guardian Helps

Guardian's smart contract audits start with a design review of governance, roles and upgrade paths, before implementation locks in the expensive risks.

Senior researchers then attack privileged flows and upgrade risk with exploit-driven review.

With Vanguard, every later update re-enters the same review loop.

Frequently Asked Questions

What is a storage gap in Solidity?

A storage gap is a fixed-size array, often named __gap, at the end of an upgradeable base contract. It reserves slots so a later version can add variables without shifting the storage of contracts that inherit from it.

Does namespaced storage replace storage gaps?

It removes the need for them. Namespaced storage keeps a contract's variables in a struct at a hash-derived slot, far from the slots the compiler assigns in order. OpenZeppelin's upgradeable contracts use it from version 5.0.

Why call _disableInitializers in the constructor?

It marks the implementation contract itself as initialized, so nobody can call initialize on it directly. The proxy's storage is separate, so initializing through the proxy still works.

Who can upgrade a UUPS proxy?

Whoever _authorizeUpgrade allows. The upgrade functions call it first, and it has no default access control, so override it with onlyOwner or your own check. Protecting only one upgrade function leaves the others open.

Sources

Review your upgrade paths with Guardian

Guardian reviews roles, initializers and upgrade paths before a bad upgrade can reach your users.