← Back to Blog

Solidity Storage Layout Upgrade Vulnerabilities and Storage Gap Security Patterns

2026-07-07 · By Josh Kim (Lead Security Researcher, ContractScan) storage upgradeable storage-gap proxy solidity security storage-layout

Upgradeable Solidity contracts live and die by storage layout discipline. The EVM has no schema, no migration system, and no runtime type safety — it only has slot numbers. When you upgrade an implementation contract, the proxy delegates calls into the new code while keeping the old storage in place. If the new code's variable declarations no longer map to the same slots as the old code's declarations, every read and write silently operates on the wrong data. There is no revert, no error log, and no warning. The contract simply continues running with corrupted state.

This post covers six distinct storage layout vulnerability classes that appear in upgradeable contract patterns. Each section shows the vulnerable pattern, explains exactly why it breaks, and provides the correct fix. All six bugs have appeared in production codebases and audit reports from the last two years.


1. Adding a New Variable in a Base Contract Shifts All Child Slots

In Solidity, storage slot assignment follows declaration order across the entire inheritance chain. When a child contract inherits from a base, the base contract's variables occupy the first slots, and the child's variables follow immediately after. If you add a new variable to the middle of a base contract in an upgrade, every subsequent slot — including all variables declared in every child contract — shifts down by one or more positions.

Vulnerable:

// V1 — deployed base contract
contract OwnableBase {
    address public owner;   // slot 0
}

contract TokenV1 is OwnableBase {
    uint256 public totalSupply;  // slot 1
    mapping(address => uint256) public balances; // slot 2
}

// V2 — developer adds a paused flag to the base, thinking it is safe
contract OwnableBaseV2 {
    address public owner;   // slot 0
    bool public paused;     // slot 1  <-- INSERTED HERE
}

contract TokenV2 is OwnableBaseV2 {
    uint256 public totalSupply;  // now slot 2 — was slot 1 in V1
    mapping(address => uint256) public balances; // now slot 3 — was slot 2 in V1
}

The proxy's storage still has totalSupply encoded at slot 1 and balances rooted at slot 2. After the upgrade, TokenV2 reads totalSupply from slot 2 (getting garbage or zero) and balances from slot 3 (an entirely different mapping tree). Every user balance is now missing.

Detection tips: Any time a new state variable is added to a contract that is inherited by another contract, run a full storage layout diff using forge inspect <ContractName> storage-layout --json on both the old and new versions. Compare each variable's slot and offset. Any slot that changes for a variable that existed before the upgrade is a breaking change. Automated layout diff tools like hardhat-storage-layout or OpenZeppelin's Upgrades plugin will flag this as an incompatible upgrade and refuse to proceed. Never insert variables into a base contract between upgrades — always append at the end, or use a storage gap.

Fixed:

// Correct V2 — append new variable at end of base, never insert
contract OwnableBaseV2 {
    address public owner;   // slot 0 — unchanged
    // paused is added AFTER all existing variables, at the end of the base
}

contract TokenV2 is OwnableBaseV2 {
    uint256 public totalSupply;  // slot 1 — unchanged
    mapping(address => uint256) public balances; // slot 2 — unchanged
    bool public paused;          // slot 3 — new variable appended safely
}

2. Missing __gap Array in Upgradeable Base Contract

When a base contract is designed to be inherited by multiple child contracts across many protocol upgrades, it needs reserved storage space so that future additions to the base do not displace the child's variables. The standard mechanism is a uint256[N] private __gap array at the end of the base contract. This array occupies N consecutive slots that can be shrunk in future versions to make room for new base variables without touching anything the child declared.

Vulnerable:

// Base contract shipped without a storage gap
contract AccessControlBase {
    mapping(address => bool) public isAdmin;  // slot 0
    // No __gap — no room to grow
}

contract VaultV1 is AccessControlBase {
    uint256 public totalDeposits;  // slot 1
    mapping(address => uint256) public userDeposits; // slot 2
}

// Later: developer needs to add a role registry to the base
contract AccessControlBaseV2 {
    mapping(address => bool) public isAdmin;  // slot 0
    mapping(address => bytes32) public roles;  // slot 1 <-- COLLIDES with VaultV1.totalDeposits
}

contract VaultV2 is AccessControlBaseV2 {
    uint256 public totalDeposits;  // now slot 2 — was slot 1
    mapping(address => uint256) public userDeposits; // now slot 3 — was slot 2
}

The absence of a gap means there is no safe way to add any variable to the base without corrupting the child. Every deposit amount stored in userDeposits is now read from the wrong mapping root. Funds appear to vanish or become inaccessible.

Detection tips: Audit every base contract that is intended to be inherited by upgradeable children. If the contract does not end with a uint256[N] private __gap declaration, it is unsafe to extend in future upgrades. OpenZeppelin's Upgrades plugin enforces gap requirements through its @openzeppelin/contracts-upgradeable library — if you are writing a custom base, mirror this pattern. When reviewing a pull request, check that any reduction in gap size exactly matches the number of new variables added (a gap shrunk by 2 must be accompanied by exactly 2 new 32-byte-equivalent slots of new variables). Mismatches indicate a layout error.

Fixed:

// Base contract with proper storage gap
contract AccessControlBase {
    mapping(address => bool) public isAdmin;  // slot 0
    uint256[49] private __gap;  // slots 1–49 reserved for future base variables
}

contract VaultV1 is AccessControlBase {
    uint256 public totalDeposits;  // slot 50
    mapping(address => uint256) public userDeposits; // slot 51
}

// Safe upgrade: shrink gap by 1 to add new base variable
contract AccessControlBaseV2 {
    mapping(address => bool) public isAdmin;  // slot 0
    mapping(address => bytes32) public roles;  // slot 1 — taken from gap
    uint256[48] private __gap;  // slots 2–49 still reserved
}

contract VaultV2 is AccessControlBaseV2 {
    uint256 public totalDeposits;  // slot 50 — unchanged
    mapping(address => uint256) public userDeposits; // slot 51 — unchanged
}

3. Changing a Variable's Type Widens Its Slot and Corrupts Adjacent Storage

Solidity packs multiple small variables into a single 32-byte slot when their combined size fits. If you change the type of one variable in an upgrade — for example, widening a uint128 to a uint256 — the widened variable can no longer share its slot with adjacent packed variables. The compiler now assigns it a full slot alone, which shifts the offset or slot of the next variable. The proxy's storage still holds the old packed encoding, but the new implementation reads it with completely different slot and byte-offset assumptions.

Vulnerable:

// V1 — two uint128 values packed into slot 0
contract PriceFeedV1 {
    uint128 public price;      // slot 0, bytes 0–15
    uint128 public confidence; // slot 0, bytes 16–31
    uint256 public timestamp;  // slot 1
}

// V2 — developer widens price to uint256 for precision
contract PriceFeedV2 {
    uint256 public price;      // slot 0, full slot (was bytes 0–15)
    uint128 public confidence; // slot 1, bytes 0–15 — WAS slot 0 bytes 16–31
    uint256 public timestamp;  // slot 2 — was slot 1
}

After the upgrade, reading confidence from slot 1 bytes 0–15 returns the high 128 bits of what was previously stored as timestamp in slot 1. Reading timestamp from slot 2 returns zero or garbage. Price oracle reads are now completely wrong, potentially allowing arbitrage or liquidation attacks based on corrupted price data.

Detection tips: Storage layout diffs must check not only the slot number for each variable but also the byte offset within the slot. A change from uint128 to uint256 on a packed variable is a breaking change even if the slot number appears to stay the same for that specific variable. Use forge inspect with the --json flag and compare the offset field for every variable, not just the slot field. In upgrade scripts, reject any change where an existing variable's type size increases or where a packed variable's declared neighbors change. When precision needs to increase, add a new variable and migrate data through a one-time upgrade function rather than widening an existing packed type.

Fixed:

// Safe V2 — keep original variables unchanged, add new precision field
contract PriceFeedV2 {
    uint128 public price;         // slot 0, bytes 0–15 — unchanged
    uint128 public confidence;    // slot 0, bytes 16–31 — unchanged
    uint256 public timestamp;     // slot 1 — unchanged
    uint256 public precisePrice;  // slot 2 — new variable appended
}

4. Removing a Storage Variable Instead of Deprecating It

Removing a state variable declaration from an implementation contract does not clear the underlying storage — the data is still in the proxy's storage at that slot. What removal does is cause all subsequently declared variables to shift up in slot assignment, so the new code reads them from the wrong slots. The removed variable's slot is now silently aliased by whatever variable takes its position in the declaration order.

Vulnerable:

// V1
contract StakingV1 {
    address public owner;          // slot 0
    uint256 public legacyRewardRate; // slot 1 — used in V1, being removed
    uint256 public totalStaked;    // slot 2
    mapping(address => uint256) public stakes; // slot 3
}

// V2 — developer removes legacyRewardRate since it is no longer used
contract StakingV2 {
    address public owner;          // slot 0 — unchanged
    // legacyRewardRate removed!
    uint256 public totalStaked;    // now slot 1 — was slot 2
    mapping(address => uint256) public stakes; // now slot 2 — was slot 3
}

The proxy's storage still has totalStaked at slot 2 and all stake balances rooted at slot 3. StakingV2 reads totalStaked from slot 1 — which holds the old legacyRewardRate value — and reads stakes from mapping root slot 2, which now points into totalStaked's old location. All user stake balances are inaccessible.

Detection tips: Never remove a storage variable declaration from an upgradeable contract, even if the variable is no longer used in the new implementation's logic. Instead, rename it with a deprecated_ prefix or replace it with a blank placeholder of the same type to make the intent clear and preserve the slot. Automated upgrade validators like the OpenZeppelin Upgrades plugin will flag the removal of a storage variable as an incompatible change. In code review, any deletion of a state variable declaration from an upgradeable contract is a red flag that requires manual slot-by-slot verification of every subsequent variable.

Fixed:

// Safe V2 — deprecate the variable in place, never remove it
contract StakingV2 {
    address public owner;                    // slot 0 — unchanged
    uint256 private deprecated_legacyRewardRate; // slot 1 — kept as placeholder
    uint256 public totalStaked;              // slot 2 — unchanged
    mapping(address => uint256) public stakes; // slot 3 — unchanged
}

5. Struct Field Addition Breaking Packed Slot Boundaries

Solidity applies the same slot-packing rules to struct fields as it does to top-level state variables. If you add a field to a struct in an upgrade, the struct's total storage footprint grows. Any state variable declared after a storage-of-struct variable in the same contract can shift to a different slot, and if the struct itself is stored inside a mapping or array, the per-element layout changes for all future reads while old data remains encoded in the old layout.

Vulnerable:

// V1
struct Position {
    uint128 size;    // bytes 0–15 of first struct slot
    uint128 margin;  // bytes 16–31 of first struct slot
    // total: 1 slot
}

contract PerpV1 {
    mapping(address => Position) public positions; // slot 0 (mapping root)
    uint256 public openInterest;  // slot 1
}

// V2 — developer adds a liquidation price field to Position
struct PositionV2 {
    uint128 size;            // bytes 0–15 of slot 0
    uint128 margin;          // bytes 16–31 of slot 0
    uint256 liquidationPrice; // slot 1 — ADDED: struct now uses 2 slots
}

contract PerpV2 {
    mapping(address => PositionV2) public positions; // slot 0 (mapping root)
    uint256 public openInterest;  // slot 1 — layout of mapping entries changed
}

Old Position data for each user was written as a single 32-byte slot. The new PositionV2 expects two slots per entry. When PerpV2 reads a user's liquidationPrice, it reads from the second slot of their entry — which in V1 was either the next user's data or uninitialized storage. All existing positions are unreadable without a data migration.

Detection tips: Any modification to a struct that is used in a stored mapping or array is a breaking change for existing data. Struct expansion requires a data migration: deploy the new implementation, iterate over all stored entries through a privileged migration function, re-encode each entry in the new layout, and only then open the contract for normal use. In audit and review, flag every struct definition change in an upgradeable contract and trace every location where that struct is stored persistently. If the struct is stored in a mapping and there is no migration function, the upgrade is unsafe. Tools like slither --detect do not catch struct layout changes automatically, so manual review and layout diffing are essential.

Fixed:

// Safe approach: add new field at end of struct, provide migration function
struct PositionV2 {
    uint128 size;             // bytes 0–15 of slot 0 — unchanged
    uint128 margin;           // bytes 16–31 of slot 0 — unchanged
    uint256 liquidationPrice; // slot 1 — new, requires migration for existing entries
}

contract PerpV2 {
    mapping(address => PositionV2) public positions;
    uint256 public openInterest;
    bool public migrationComplete;

    // Called once by admin after upgrade to populate liquidationPrice for all open positions
    function migratePositions(address[] calldata users, uint256[] calldata liqPrices)
        external
        onlyOwner
    {
        require(!migrationComplete, "already migrated");
        for (uint256 i = 0; i < users.length; i++) {
            positions[users[i]].liquidationPrice = liqPrices[i];
        }
    }
}

6. Mapping Key Collision When Storage Layout Is Reassigned

Solidity computes mapping slot locations as keccak256(key . mappingSlot) — the hash of the ABI-encoded key concatenated with the mapping's root slot number. If an upgrade causes a mapping to move from one root slot to another, every key in the mapping hashes to a different final storage slot. Old data written under the old slot root is now orphaned, and any data written under the new slot root starts from a blank state. Two mappings that accidentally swap root slots effectively exchange all their stored data.

Vulnerable:

// V1
contract LendingV1 {
    address public admin;                          // slot 0
    mapping(address => uint256) public deposits;   // slot 1 (root)
    mapping(address => uint256) public borrows;    // slot 2 (root)
}

// V2 — developer adds a fee parameter after admin, shifting both mappings
contract LendingV2 {
    address public admin;                          // slot 0
    uint256 public protocolFee;                    // slot 1 — INSERTED
    mapping(address => uint256) public deposits;   // slot 2 (was slot 1)
    mapping(address => uint256) public borrows;    // slot 3 (was slot 2)
}

deposits[alice] in V1 was stored at keccak256(alice . 1). After the upgrade, LendingV2 reads deposits[alice] from keccak256(alice . 2) — a completely different storage location that contains zero or unrelated data. All deposit records are effectively zeroed out from the new implementation's perspective, and borrows faces the same problem. An attacker who deposited before the upgrade now appears to have zero deposits, potentially bypassing collateral checks.

Detection tips: Mapping root slot changes are some of the most dangerous upgrade bugs because the data loss is total — there is no partial corruption, every single key is now wrong. The entire mapping appears empty to the new implementation while the old data sits unreachable in the proxy's storage forever. To detect this, run a full storage layout comparison using forge inspect before and after any proposed upgrade and verify that every mapping's root slot number is identical between versions. Any insertion, removal, or type change of a variable declared before a mapping in the contract will shift that mapping's root slot. In upgrade proposals and pull requests, treat any change that appears before a mapping declaration with extreme caution. Contract storage freeze — locking the layout at the point of the first mapping or critical variable — is a useful discipline for preventing this class of bug.

Fixed:

// Safe V2 — append new variables after all existing declarations
contract LendingV2 {
    address public admin;                          // slot 0 — unchanged
    mapping(address => uint256) public deposits;   // slot 1 — unchanged
    mapping(address => uint256) public borrows;    // slot 2 — unchanged
    uint256 public protocolFee;                    // slot 3 — appended safely
}

Preventing Storage Layout Bugs at Scale

All six vulnerability classes share a single root cause: the assumption that changing a Solidity source file has predictable storage effects without explicitly verifying the slot assignments. At scale, preventing these bugs requires three layers of defense.

First, enforce layout validation in your CI pipeline. Tools like the OpenZeppelin Upgrades plugin for Hardhat and Foundry's forge inspect command can generate and diff storage layouts automatically. Treat any unexpected slot change the same way you treat a failing test — block the merge.

Second, adopt structural disciplines: append-only additions to any contract in an inheritance chain, storage gaps in every upgradeable base contract sized generously (50 slots is the standard starting point), and deprecation-in-place instead of removal for any variable that is no longer used.

Third, include a storage layout review in every upgrade audit. Not just a code review — an explicit comparison of the compiled slot assignments for each variable, checked against both the previous on-chain implementation and the new proposed implementation. This review should be performed against the actual deployed bytecode ABI, not just the source, since compiler version changes and optimization settings can occasionally affect layout.

ContractScan at contract-scanner.raccoonworld.xyz automatically detects storage layout incompatibilities, missing storage gaps, packed type changes, and mapping root slot shifts across upgrade histories. Upload your proxy and implementation pairs to get a full storage diff report alongside your security scan.


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.

🛡️
Written by Josh Kim
Lead Security Researcher at ContractScan. Specializing in EVM smart contract vulnerability research, DeFi protocol security, formal verification, and automated vulnerability detection.
Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →