Skip to main content
Smart Contract Storage

Storage Collisions in Smart Contracts

Smartmuv

Upgradeable smart contracts are essential for maintaining and enhancing blockchain applications. However, the process of upgrading can introduce risks, particularly storage collisions. Storage collisions happen when different storage structures use the same storage for different purposes in proxy scenarios, leading to unintended data overwrites and corrupted states. This article aims to explain these scenarios with practical examples.

How Solidity Works with Storage

In Solidity, each contract has its own storage layout, with variables assigned to specific slots. When a contract is upgraded, it must maintain a compatible storage layout with the previous version to avoid overwriting important data. This includes state variables, mappings, and arrays.

Delegatecall and Its Role in Upgradable Contracts

Delegatecall is most popular for smart contract upgrade scenarios. It allows a contract to execute another contract's code while using its own storage context. This method enables a proxy contract to delegate function calls to various implementations, and facilitate upgrades.

Flow of Delegatecall

  1. User interacts with the Proxy contract.
  2. Proxy contract uses delegatecall to forward the request to the implementation contract.
  3. Implementation contract executes the logic, using the Proxy's storage.
Storage collisions between the proxy and implementation contracts. Image credit: cyfrin.io

Risks with Delegatecall

  • Storage Collisions: Inconsistent storage layouts can lead to data corruption.
  • Security Vulnerabilities: Improper access control can allow unauthorized upgrades or changes.

This article examines two storage collision scenarios in depth with visual aids and code references.

Case 1: Proxy Admin and Implementation Contracts Sharing Same Slot

When a smart contract Transparent Upgradable Proxy pattern or any pattern that uses multiple storage structures to store its critical information, their collision can result in devastating effects on the contract's functionalities.

Let's go over the scenario where two separate storage structures of a proxy contract mistakenly use the same starting storage slot for two components

  • Proxy Admin Storage
  • Implementation Storage

For simplicity, let's assume the common slot is 0

Insights and Diagrams

When the starting slot of admin and implementation are pointing to the same slot number, they can overwrite each other, causing unintended behavior.


+------------------+
|   ProxyAdmin     |
|------------------|
| - owner          |
|------------------|
| + getProxyAdmin  |
| + upgrade        |
+-------+----------+
        |
        |
+-------v----------+
|     Proxy        |
|------------------|
| * ADMIN_SLOT     | <----+ Collision
| * IMPLEMENT_SLOT | <----+
| - admin          | 
| - implementation |
|------------------|
| + upgradeTo()    |
| + changeAdmin()  |
+----+---------+---+
     |         |
     | delegate| upgrade
     | call    |
     |         |
+----v-----+  +---v------+
| CounterV1 |  | CounterV2 |
|-----------|  |-----------|
| + count   |  | + count   |
| + inc()   |  | + inc()   |
|           |  | + dec()   |
+-----------+  +-----------+

Key Solidity Code:

The following is the core of the problem, as we can see that both slot variables are pointing to slot 0, therefore

bytes32 private constant IMPLEMENTATION_SLOT = 0;
bytes32 private constant ADMIN_SLOT = 0;

complete code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// Transparent upgradeable proxy pattern

library StorageSlot {
    struct AddressSlot {
        address value;
    }

    function getAddressSlot(bytes32 slot)
        internal
        pure
        returns (AddressSlot storage r)
    {
        assembly {
            r.slot := slot
        }
    }
}

contract Proxy {
    bytes32 private constant IMPLEMENTATION_SLOT = 0;
    bytes32 private constant ADMIN_SLOT = 0;
    
    constructor() {
        _setAdmin(msg.sender);
    }

    modifier ifAdmin() {
        if (msg.sender == _getAdmin()) {
            _;
        } else {
            _fallback();
        }
    }

    function _getAdmin() private view returns (address) {
        return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
    }

    function _setAdmin(address _admin) private {
        require(_admin != address(0), "admin = zero address");
        StorageSlot.getAddressSlot(ADMIN_SLOT).value = _admin;
    }

    function _getImplementation() private view returns (address) {
        return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
    }

    function _setImplementation(address _implementation) private {
        require(
            _implementation.code.length > 0, "implementation is not contract"
        );
        StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
    }

    function changeAdmin(address _admin) external ifAdmin {
        _setAdmin(_admin);
    }

    function upgradeTo(address _implementation) external ifAdmin {
        _setImplementation(_implementation);
    }

    function admin() external ifAdmin returns (address) {
        return _getAdmin();
    }

    function implementation() external ifAdmin returns (address) {
        return _getImplementation();
    }
        
}

Credit : Updated Version of Proxy Code from SolidityByExample

Initial Deployment

Expected:

Variable Name Value
Implementation address(0)
Admin 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

Actual:

Variable Name Value
Implementation 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
Admin 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
              +-----------------+
              |     Proxy       |
              |-----------------|
              | IMPLEMENTATION  | --+--> Address 0x5B38... (admin address initially)
              |                 |   |
              |-----------------|   |
              |      ADMIN      | --+
              +-----------------+

After Upgrading Implementation

Expected:

Variable Name Value
Implementation 0xE19b7C663051327aCD91537e71fA1FE2E04De50f
Admin 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

Actual:

Variable Name Value
Implementation 0xE19b7C663051327aCD91537e71fA1FE2E04De50f
Admin 0xE19b7C663051327aCD91537e71fA1FE2E04De50f
              +-----------------+
              |     Proxy       |
              |-----------------|
              | IMPLEMENTATION  | --+--> Address 0xE19b... (new implementation address)
              |                 |   |
              |-----------------|   |
              |      ADMIN      | --+
              +-----------------+

Therefore, storage collision will either:

  • make the contract non-upgradable (due to an invalid admin address)
  • or implementation address will be the address of the proxy admin, resulting in the proxy admin forwarding calls to admin's address.

Case 2: Storage Collision during the Upgrade of Implementation Contract

The upgradeable proxy contract uses mappings to store tokenUri and nftVersions data, and both mappings share uint256 as key-type.

In the first version of the implementation contract named NFTManager1, mapping tokenUri and nftVersions were at slot n+1 and n+2 respectively. Suppose, during upgrading the implementation contract to NFTManager2 storage layout changed to mapping nftVersions and tokenUri declared at slot n+1 and n+2 respectively, where n is the value of the implementation slot.

In this case, since both mappings share the same key type (uint256), in NFTManager2 contract tokenUri will actually be accessing storage data of nftVersions mapping and vice versa.

Overview Layout

+------------------+
|   ProxyAdmin     |
|------------------|
| - owner          |
|------------------|
| + getProxyAdmin  |
| + upgrade        |
+--------+---------+
         |
         |
+--------v---------+
|     Proxy        |
|------------------|
| * IMPLEMENT_SLOT |
| * ADMIN_SLOT     |
| - admin          |
| - implementation |
|------------------|
| + upgradeTo()    |
| + changeAdmin()  |
+----+---------+---+
     |         |
     | delegate| upgrade
     | call    |
     |         |
+----v---------+-------------+
| NFTManager1  | NFTManager2 |
|--------------|-------------|
| - tokenUri   | - nftVersions | <----+ Collision
| - nftVersions| - tokenUri    | <----+ Collision
| + setTokenUri()| + setTokenUri()|
| + setNftVersion()| + setNftVersion()|
+--------------+------------+

Solidity Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;


contract NFTManager1 {
    bytes32 private constant IMPLEMENTATION_SLOT =
        bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

    function setTokenUri(uint256 tokenId, string calldata uri) external {
        ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).tokenUri[tokenId] = uri;
    }

    function setNftVersion(uint256 tokenId, string calldata version) external {
        ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).nftVersions[tokenId] = version;
    }

// rest of the code
}

contract NFTManager2 {
    bytes32 private constant IMPLEMENTATION_SLOT =
        bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

    function setTokenUri(uint256 tokenId, string calldata uri) external {
        ImplementationStorageSlot2.getAddressSlot(IMPLEMENTATION_SLOT).tokenUri[tokenId] = uri;
    }

    function setNftVersion(uint256 tokenId, string calldata version) external {
        ImplementationStorageSlot2.getAddressSlot(IMPLEMENTATION_SLOT).nftVersions[tokenId] = version;
    }

// rest of the code
}

library StorageSlot {
    struct AddressSlot {
        address value;
        string version;
    }

    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }
}

library ImplementationStorageSlot1 {
    struct AddressSlot {
        address value; //slot n+0
        mapping(uint256 => string) tokenUri; //slot n+1
        mapping(uint256 => string) nftVersions; //slot n+2
    }

    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }
}

library ImplementationStorageSlot2 {
    struct AddressSlot {
        address value; //slot n+0
        mapping(uint256 => string) nftVersions; //slot n+1 (shld be tokenUri)
        mapping(uint256 => string) tokenUri; //slot n+2 (shld be nftVersions)
    }

    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }
}

contract Proxy {
    // -1 for unknown preimage
    bytes32 private constant IMPLEMENTATION_SLOT =
        bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
    bytes32 private constant ADMIN_SLOT =
        bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);

    function _setImplementation(address _implementation) private {
        require(_implementation.code.length > 0, "implementation is not contract");
        ImplementationStorageSlot1.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
    }

    function upgradeTo(address _implementation, string memory _version) external ifAdmin {
        _setImplementation(_implementation);
        _setVersion(_version);
    }

    function _setVersion(string memory _version) private {
        StorageSlot.getAddressSlot(ADMIN_SLOT).version = _version;
    }

// Rest of the TUPS code

}

Sample Inputs

  • tokenId = 1
  • tokenUri = "/1.json"
  • nftVersion = "1.0"

Initial State after Deployment

1. After Deployment

Variable Name Value
tokenUri {}
nftVersions {}

2. Upgrade to NFTManager1 and Set Values

  • Call setTokenUri(1, "/1.json")
  • Call setNftVersion(1, "1.0")
Variable Name Value
tokenUri {1: "/1.json"}
nftVersions {1: "1.0"}

Diagram:

              +-----------------+
              |     Proxy       |
              |-----------------|
              | - tokenUri      | 
              | - nftVersions   | 
              |-----------------|
              | * IMPLEMENT_SLOT| -> NFTManager1
              | * ADMIN_SLOT    |
              |-----------------|
              | + upgradeTo()   |
              | + changeAdmin() |
              +-----------------+
                      |
                      v
              +-----------------+
              |  NFTManager1    |
              |-----------------|
              | - tokenUri      | -> {1: "/1.json"}
              | - nftVersions   | -> {1: "1.0"}
              +-----------------+

3: Upgrade to NFTManager2 and Set Values

  • Call setTokenUri(1, "/1.json")
  • Call setNftVersion(1, "1.0")
Variable Name Value
tokenUri {1: "1.0"} (Collision)
nftVersions {1: "/1.json"} (Collision)

Diagram:

              +-----------------+
              |     Proxy       |
              |-----------------|
              | - tokenUri      | 
              | - nftVersions   | 
              |-----------------|
              | * IMPLEMENT_SLOT| -> NFTManager2
              | * ADMIN_SLOT    |
              |-----------------|
              | + upgradeTo()   |
              | + changeAdmin() |
              +-----------------+
                      |
                      v
              +-----------------+
              |  NFTManager2    |
              |-----------------|
              | - nftVersions   | -> {1: "/1.json"} (Collision)
              | - tokenUri      | -> {1: "1.0"}     (Collision)
              +-----------------+

Explanation

  • Proxy: Delegates calls to the implementation contract, that defines storage slots for tokenUri and nftVersions.
  • Implementation Contracts:
    • NFTManager1: Uses mappings for tokenUri and nftVersions.
    • NFTManager2: Uses mappings in an opposite order, causing a storage collision.
  • Collision: Due to different slot layouts in both versions of the implementation contracts, NFTManager2 contract tokenUri will be accessing the storage data of nftVersions mapping, and nftVersions mapping of tokenUri mapping.