Home - Coinspect Security
upgradeable smart contract

Learn How To Make Secure Upgradeable Smart Contracts

Security Engineer
Ethereum, DeFi

While smart contracts were once envisioned as immutable, trustless agreements, in practice, security of mainstream DeFi protocols depends heavily on the integrity of their upgrade mechanisms. A single misstep in managing upgradeable contracts can lead to attackers taking control of entire smart contract systems. For smart contract audit professionals and web3 developers, these risks are not theoretical—they are daily threats. This post identifies some of the most common pitfalls in upgradeable smart contracts, providing essential insights to have your basis covered before comprehensive code reviews by security experts.

What are upgradeable contracts, and when are they used?

Upgradeable smart contracts allow for code updates after deployment while preserving their state. Upgradeability enables fast integration of new features, security patches, or legacy code updates. Various design patterns arise as a way to update the code since contracts in most blockchains were conceived as immutable entities.

The basic design relies on two contracts working in a tandem: a proxy, which stores all data, and an implementation, where the logic is located.

Upgradeable Smart Contract Diagram

The proxy executes the logic of the implementation but writes to its own storage, leaving the implementation’s storage unaltered. This mechanism is possible since the proxy executes foreign logic via delegatecall. When an upgrade is needed, it’s simply a matter of deploying a new implementation contract and modifying the proxy to point to the new implementation.

Developers use upgradeable smart contracts when:

  • Frequent updates are necessary
  • New features need to be integrated quickly
  • Security patches are required
  • Legacy code needs to be updated
  • Code needs to comply with new regulations

Additionally, there are some use cases that rely on more complex architectures of upgradeable contracts, such as Diamond Proxies which allow the creation of larger smart contracts that exceed the standard size limit of 24,576 bytes.

Balancing Innovation and Risk: The Double-Edged Sword

While upgradeability offers numerous benefits, it also introduces complexity and risks that developers must carefully navigate. Let’s explore the common pitfalls that can compromise the security, functionality, and trust in upgradeable smart contracts. By understanding these challenges, developers can better prepare and implement robust upgradeability strategies.

Not using common proxy patterns

Creating a new upgradeability pattern is a complex task that requires a deep understanding of EVM fundamentals. Because of this, using a custom design can introduce bugs that are already mitigated in the already battle-tested patterns.

These design patterns allow you to separate the contract logic from the storage safely, enabling upgrades without changing the contract address.

Common Proxy Patterns:

  • Transparent Proxy Pattern: Upgrade and admin logic in the proxy itself and user logic is in the implementation.
  • UUPS (Universal Upgradeable Proxy Standard): Allows for smaller deployment cost and gas-efficient upgrades.
  • Beacon Proxy: Useful for upgrading multiple proxy contracts at once.
  • Diamond Proxy: More complex architecture that allows creating a smart contract without size limit

Declaring variables in the proxy at a non randomized slot

Proxies write their storage slots according to the layout determined by the implementation. The following example shows that the implementationAddress at the Proxy is modified after calling ProxyClash.setAddress() which executes logic via the fallback function making a delegatecall to the implementation. This happens because the implementation writes the slot 0, where both myAddress and implementationAddress are:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract ProxyWithCollision {
    address public implementationAddress; // This variable will be overwritten after calling setAddress()

    constructor(address _impl) {
        implementationAddress = _impl;
    }

  fallback() external {
   // delegate call logic targeting the implementation 
  } 

}

contract CorruptStorage {
    address public myAddress;
    function setAddress(address _address) public {
        myAddress = _address;
    }
}

Solution

To prevent storage collisions, the EIP1822 suggests using a “randomized” slot of the proxy:

 // Code position in storage is keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"

This way, the possibility of collision between variables in the Proxy and Logic Contracts is practically eliminated.

Not reserving storage gaps

Storage gaps are a strategic method to reserve space in a base contract’s layout for future variables without disrupting child contracts’ storage. This technique is extremely important for implementation contracts that use inheritance, otherwise, adding a state variable to a base contract will cause a collision with the child contract storage. Remember that state variable ordering follows the C3-linearized order of contracts, starting from the most base-ward.

Solution

To implement a storage gap, simply reserve a fixed-size array indicating the number of reserved slots:

contract Parent {
    uint256 firstVariable;
    uint256[49] __gap; // 50 slots are allocated, one is already used by firstVariable
}

contract Child is Parent {
    uint256 child;
}

When upgrading the implementation to add new variables to the parent, reduce the gap size accordingly. For example:

contract Base {
    uint256 firstVariable;
    uint256 newVariable; // 32 bytes
    uint256[48] __gap; // gap reduced to 48
}

It’s critical to append new variables to the end of existing ones. Failure to do so can result in data corruption, as the proxy will write to incorrect slots. Proper management of storage gaps ensures flexible, upgradeable contracts while maintaining data integrity.

Leaving contracts uninitialized

Upgradeable contracts requiring on-deployment configuration face a unique challenge: they can’t use constructors. This limitation arises from a context mismatch. In upgradeable systems, the proxy’s storage holds the contract state, while the implementation’s state remains unchanged. And constructors always write to the storage of the contract they are constructing, making the state written by the constructor of the implementation contract invisible when seen through the proxy.

Initialization logic must be executed in the proxy’s context. This is typically achieved through an initialize() function, which serves as a surrogate constructor and needs to be called via the proxy.

function initialize(uint256 _initialValue) public initializer {
    value = _initialValue;
}

Failing to properly implement and call this function can leave contracts in an uninitialized, vulnerable state.

Initializing contracts on a different transaction

Moreover, if a contract is not deployed and initialized atomically, in the same transaction, adversaries could frontrun the initialization call. This allows malicious actors to set suboptimal values or even introduce vulnerabilities for future exploitation. Therefore, proper initialization is extremely important for maintaining contract security and intended functionality in upgradeable systems.

Solution

  • Use access control or atomic deployment of the proxy with initialize()
  • Prevent reinitialization by ensuring initialize() is called only once
  • Disable initialize() in the implementation context (e.g., by calling it during the implementation’s deployment)
  • Set all critical variables required for production
  • Ensure variables are initialized correctly prior to invoking functions that use them

Privileges and predictability of Upgrades

Allowing upgrades to be triggered at any time by a single account poses significant risks to a protocol’s smart contract security. This centralized control creates a single point of failure, as a compromised privileged account could deploy malicious upgrades without warning or oversight. For instance, an attacker could easily drain an upgradeable vault with locked user funds by executing a sudden, malicious upgrade.

Solution

  • Implement a governance system or multi-signature (multisig) wallet as the privileged account for triggering upgrades. This distributes authority and reduces single points of failure.
  • Use a timelock mechanism for upgrades. This introduces a mandatory delay between upgrade proposal and execution, allowing users time to review changes and react accordingly (e.g., withdrawing funds if they disagree with the upgrade).
  • Clearly communicate the upgrade process, including who has upgrade privileges and how the timelock works, to all stakeholders.

Lack of protection against selfdestruct

Proxies execute the implementation’s logic via delegatecall, preserving context while running external code. This mechanism, however, could expose proxies to a critical vulnerability: if the implementation contains a selfdestruct instruction, the proxy itself could be irreversibly destroyed. While EIP-6780 deprecated this opcode for Ethereum to work only at the creation transaction, the risk persists on other EVM chains..

Solution

Given these risks, it’s important to strictly limit arbitrary calls to specific scenarios or authorized parties. This precaution helps prevent the destruction of both proxy and implementation contracts. A proxy linked to a destroyed implementation could lead to disastrous outcomes, such as permanently locked funds, for example. Notably, this vulnerability has been discovered in several protocols using a vulnerable version of OpenZeppelin’s UUPS proxy, underscoring the importance of robust safeguards against unintended contract destruction.

Diamond Proxies: Advanced Upgradeability and Its Challenges

A Diamond proxy is a complex smart contract upgradeability pattern that allows for modular and flexible contract design. It enables a single proxy to interact with multiple implementation contracts (facets), allowing developers to add, replace, or remove any number of functions atomically.

Advantages

This approach solves issues with contract size limits and enables more flexible and gas-efficient upgrade patterns. Diamonds feature shared state between facets, a standard interface for finding facets and functions, and optionally immutable functions. This design allows for complex, upgradeable smart contract systems with improved maintainability and extensibility, making it a powerful tool for large-scale decentralized applications.

Challenges

  1. Interface Compliance

The diamond must fully implement the IDiamond interface. This includes key functions like diamondCut, facetAddress, and facetFunctionSelectors. Failing to implement any of these correctly can lead to severe issues. For instance, an improperly implemented diamondCut function could prevent any future upgrades, effectively locking the contract’s version. Similarly, incorrect implementation of facetAddress or facetFunctionSelectors can break external integrations that rely on these functions to interact with the diamond.

Solution

To address this, use a stress tested implementation for all core Diamond Proxy methods. If a custom implementation is required, thoroughly test all the features.

  1. Initialization Issues

The DiamondCut contract, responsible for upgrades, is particularly vulnerable to initialization issues. If not properly initialized, it could be left in a state where anyone can call it, potentially leading to unauthorized upgrades. Even worse, if it contains a path that leads to selfdestruct which is not properly protected, an attacker could permanently destroy it, blocking all future upgrades or even destroying the proxy. This is especially critical because diamondCut initializes new facets via delegatecall, which executes in the context of the proxy. However, attackers could also call DiamondCut directly from the implementation, destroying it preventing future upgrades.

Solution

As a mitigation, ensure that the diamondCut method, as well as any other function making a delegatecall, is properly access controlled.

  1. Storage Conflicts

Diamond proxies introduce complex storage management due to multiple facets potentially accessing and modifying shared storage. Unlike single-implementation proxies, where storage layout is managed in one place, diamonds must coordinate storage across multiple facets. This increases the risk of storage slot collisions, where two facets unknowingly use the same storage slot for different purposes, leading to data corruption or unexpected behavior.

Solution

As a key takeaway to prevent this, define a randomized slot per Facet. This way, each Facet will start writing from a specific, distinct, slot preventing collisions.

  1. Function Selector Management

Diamonds must carefully manage function selectors across multiple facets. This introduces risks not present in single-implementation proxies. For example, adding a function with a selector that already exists could unintentionally replace an existing function. Removing a function incorrectly could leave the contract in an inconsistent state. Additionally the proxy could define different privileges for addFunctions, removeFunctions, and replaceFunctions. However, this approach may create an unintended bypass. For instance, if a function can be removed using replaceFunction, an account without the proper privileges to remove functions directly could still do so by using the replace functionality. This situation would violate the principle of separation of duties.

Solution

To mitigate these risks, add strict checks in addFunctions, removeFunctions, and replaceFunctions functions to ensure they perform only their intended operations.

Final Thoughts on Proxy Design for Upgradeable Smart Contracts

Smart contract upgradeability, while offering crucial flexibility in blockchain development, comes with its own set of challenges and pitfalls. From storage collisions and initialization vulnerabilities to the complexities of Diamond proxies, developers must navigate a minefield of potential issues. By understanding these risks and implementing robust mitigation strategies, teams can harness the power of upgradeable contracts while maintaining security and transparency.

However, the intricacies of upgradeable systems often require specialized knowledge and experience. If you’re developing upgradeable smart contracts, don’t hesitate to reach out to our team and schedule a security review tailored to your project’s needs.