Back to all stories
Blogs
Educational
Upgradeable Proxy Contract Security Best Practices
11/18/2022

Proxy patterns enable smart contracts to upgrade their logic while maintaining their on-chain address and state values. Calls made to proxy contracts execute logic from an implementation contract to modify the proxy’s state. This article gives an overview of the types of proxy contracts, associated security incidents and recommendations, as well as best practices when implementing a proxy contract.

Upgradeable Proxy Contract Security Best Practices

Section 1. A Brief Introduction to Upgradeable Contracts and Proxy Pattern

Smart contract code can't be modified after deploying on the blockchain. This is the whole point of immutable on-chain agreements. When developers want to update the contract code for logic upgrades, bug fixes, or security updates, they have to deploy a new contract, which results in a new contract address being generated. Proxy pattern achieves upgradeability while maintaining the same address as the most adopted solution.

Proxy pattern is an upgradeable system of contracts which includes a proxy contract and logic implementation. The proxy contract handles user interaction and data storage (contract state). User calls made to the proxy contract execute a delegatecall() to the logic implementation which changes the proxy contract state. Upgrades are made by updating the logic implementation contract address stored in the proxy contract at a predefined storage slot. The top three proxy patterns are Transparent proxy, UUPS proxy and Beacon proxy.

Transparent Proxy

Transparent proxy pattern includes the upgrade functionality within the proxy contract itself. An admin role is assigned with privilege to interact with the proxy contract directly to update the referenced logic implementation address. Callers that do not have the admin privilege will have their call delegated to the implementation contract.

Note: The proxy admin should NOT be any important role or even a regular user for the logic implementation contract because the proxy administrator cannot interact with the implementation contract.

Proxy 1

UUPS

UUPS (Universal Upgradeable Proxy Standard) includes the upgrade functionality in the logic implementation contract. Because the upgrade mechanism is in the implementation, later versions can remove related logic to disable future upgrades. All calls are forwarded from the proxy contract to the logic implementation.

UUPS

Beacon

The Beacon proxy pattern allows multiple proxy contracts to share one logic implementation by referencing the beacon contract. The beacon contract provides the logic implementation contract address to calling proxies and only the beacon contract needs to be updated when upgrading with a new logic implementation address.

Beacon

Section 2.Proxy Misuse and Security Incidents

Developers can take advantage of the proxy pattern contract to implement an upgradeable contract system. However, proxy patterns are not the easiest library to use and can bring devastating security issues to the project if used incorrectly. The following section showcases incidents related to proxy misuses, and the centralization risk that proxy brings.

Proxy Admin Key Compromise

The proxy admin controls the upgrade mechanism to transparent upgradeable proxies, should the admin’s private key become compromised an attacker could upgrade the logic contract to execute their own malicious logic on the proxy state.

PAID Network was exploited by an attacker who compromised the private key of the proxy admin and triggered the upgrade mechanism to change the logic contract. The upgrades allowed the attacker to burn user PAID tokens and mint PAID tokens to themselves which were subsequently sold. The code itself was not vulnerable but rather the attacker was able to gain access to the private key for the privileged admin.

Uninitialized UUPS Proxy Implementation

For the UUPS proxy pattern, logic contract states are initialized through an initialize() function with input parameters provided by the caller through the proxy contract. The initialize() function is often protected by the "initializer" modifier to restrict the function to only be called once. After the initialize() function call, the logic contract is initialized from the proxy contract's perspective. However, the logic contract is not initialized from the logic contract's perspective because the initialize() isn't directly called in the logic contract. Given a contract is uninitialized, anyone could call the initialize() function to initialize it, set state variables to a corrupted value and potentially take over the logic contract.

The impact of a logic contract being taken over depends on the contract code in the system. In a worst-case scenario, the attacker can upgrade the logic contract in the UUPS proxy pattern to a malicious one and perform the "self destruct" function call; this can cause the entire proxy contract to become useless and assets in the contract to be permanently lost.

Incidents:

  • Parity Multisig Freeze: Implementation initialization was not called. An attacker then triggered initialization for many wallets and locked ether in the contract via a selfdestruct() call.

  • Harvest Finance, Teller, KeeperDAO, and Rivermen NFT all used uninitialized contracts that would allowed attackers to set initialization parameters pointing to a contract and execute selfdestruct() during a delegatecall().

Storage Collision

In an upgradeable contract system, a proxy contract does not declare state variables but rather uses pseudo-random storage slots to store important data. The proxy contract saves values from the logic implementation state variables in the relative position they were declared. If the proxy contract declares its own state variables, the storage collision will happen when both the proxy and logic contract attempt to use the same storage slot. OpenZeppelin libraries do not use state variables and instead save values, such as the admin address, to specific storage slots to prevent conflict based on EIP 1967 standard.

Incidents:

  • The Audius governance hack resulted from a storage collision created by the introduction of new logic to the proxy contract. The proxy contract declared a proxyAdmin address state variable whose value would be incorrectly read when executing the logic contract code. The logic contract values for initialized and initializing were read as data from the proxyAdmin value, allowing the attacker to call the initialize() function again to change the governance to the attacker. The attacker then changed the voting parameters and passed their own proposal to steal the Audius treasury balance.

delegatecall() or call to an Untrusted Contract

Suppose delegatecall() is present within a logic contract, and the contract doesn't correctly verify the call target. In that case, an attacker could utilize the function to perform a call to his malicious contract to destroy the logic implementation or execute custom logic. Similarly, an unrestricted address.call() function in the logic contract with a maliciously supplied address and data field would allow the attacker to act as the proxy contract.

Incidents:

  • Pickle Finance and Furucombo, dYdX. Vulnerable contracts had approval of user tokens. An arbitrary call()/delegatecall() was present in the code that used an user supplied address with user supplied data. Attackers would be able to call token contracts with the transferFrom() function to remove user balances. dYdX executed their own white-hat hack to preserve funds due to proxy vulnerability.

Section 3. Best Practices

General

Integrate Proxy Pattern Only When Necessary

Not every contract needs to be upgradeable. There are numerous risks involved in using the proxy pattern, as shown in the previous section. The "upgradeable" property also raises trust issues because the proxy admin can upgrade the contract without consensus from the community. It's recommended to integrate the proxy pattern into your project only when necessary. You definitely don't want everyone to be able to kill your contract.

Do Not Modify the Proxy Library

Proxy contract libraries are complex, especially the parts that handle the storage management and the upgrade mechanism. Any mistake made in the modification can affect the working of the proxy and logic contract. Plenty of high severity proxy-related bugs we found during audits are caused by incorrectly modified proxy libraries. The Audius incident is a perfect example to show the consequence of a poorly modified proxy.

Operational

Initialize the Logic Contract

An attacker can take over an uninitialized logic contract and potentially damage the proxy contract system. Initialize the logic contract after deployment or use the _disableInitializers() in the logic contract's constructor to disable the initialization automatically.

Secure the Proxy Admin Account

An upgradeable contract system often requires a "Proxy Admin" privilege role to manage the contract upgrade. An attacker can freely upgrade the contract to a malicious one and steal the users' assets if he compromises the admin key. We recommend carefully managing the proxy admin account's private key to avoid any potential risks of being hacked. A multi-sig wallet can be used to prevent the single point of key management failure.

Use a Separate Account for the Transparent Proxy Admin

The proxy admin and logic governance should be separate addresses to prevent loss of interaction with the logical implementation. If the proxy admin and logical governance reference the same address, no call will be forwarded to execute the privileged functions, sealing change of governance functionality.

Contract Storage

Be Cautious when Declaring State Variables in Proxy Contracts

As explained in the Audius Governance hack incident, a proxy contract must be careful when declaring its own state variable. State variables declared in the proxy contract in a normal way would cause a data collision when reading and writing data. If the proxy contract needs a state variable, save the value to an EIP1967-like storage slot that would prevent the collision when executing the logic contract’s code.

Maintain the Logic Contract's Variable Declaration Order and Type

Each version of a logic contract must maintain the same order and type of state variables, and new state variables need to be added to the end of the existing variables. Otherwise, the delegate call would result in the proxy contract reading and/or overriding the incorrect storage values; and the old data may be associated with a newly declared variable, which can cause serious issues to the application.

Screen Shot 2022-11-14 at 7.55.38 PM

Include Storage Gap in Base Contracts

Logic contracts require storage gaps included in the contract code to anticipate new state variables when a new logic implementation is deployed. The size of the gap needs to be updated appropriately after adding the new state variables.

Screen Shot 2022-11-14 at 7.50.00 PM

No State Variable Assignment During Construction or Declaration

Assigning state variables during declaration or in the constructor will only affect the value in the logic contract, not the proxy contract. Non-immutable parameters should be assigned using the initialize() function.

Screen Shot 2022-11-14 at 1.52.22 PM Screen Shot 2022-11-14 at 1.52.41 PM

Contract Inheritance

Upgradeable Contracts Should Only Inherit From Other Upgradeable Contracts

An upgradeable contract has a different structure compared to a non-upgradeable contract. For example, constructors are not compatible with changing the proxy state and use the initialize() function to set state variables. Any contract inheriting another contract will need to use the inherited contract’s initialize() function to assign respective variables. When using the OpenZeppelin library or writing your own code, ensure the upgradeable contract should only inherit from other upgradeable parent contracts.

Do Not Instantiate New Contracts in the Logic Contract

Contracts instantiated through Solidity creation will not be upgradeable. Contracts should be deployed separately and have their addresses passed as a parameter into an upgradeable logic contract to be upgradeable.

Screen Shot 2022-11-14 at 8.03.30 PM

Parent Initialization Risks

When initializing parent contracts, the __{ContractName}_init function will initialize its parent contracts. Calling multiple __{ContractName}_init could result in a second initialization of a parent contract. Note that __{ContractName}_init_unchained() will only initialize the parameters of {ContractName} and not call parent initializers. However, this is not a recommended practice as all parent contracts need to be initialized and not initializing a needed contract will lead to later execution issues.

Logic Contract Implementation

Avoid Using selfdestruct() or Performing delegatecall()/call() to Untrusted Contracts

If selfdestruct() or delegatecall() are present within a contract, an attacker could potentially utilize these functions to destroy the logic implementation or execute custom logic. Verify the user input and do not allow the contract to perform delegatecall/calls to untrust contracts. Furthermore, using delegatecall() in the logic contract is not recommended, as managing the storage layout in multiple contracts can be troublesome.

Conclusion

Proxy contracts bypass blockchain immutability by enabling protocols to update their code execution post deployment. However, developing proxy contracts is still meticulous, and incorrect implementations could lead to project compromising security and logical issues.

The overall best practice is to use provided and tested solutions, as Transparent, UUPS, and Beacon proxy patterns each have proven upgrade mechanisms with respective use cases. Otherwise, the privileged role to upgrade the proxy should also be securely managed to prevent an attacker from changing the proxy logic.

Logical implementations should also be careful to not implement functions that would result in a delegatecall() chain that could potentially execute attacker logic, such as selfdestruct(). While following best practices ensures that proxy contracts are robust deployments while maintaining the upgradeable flexibility, all code is prone to new security or logical issues that may compromise a project. It is best that all code is properly reviewed by an expert auditor, such as CertiK, that has experience auditing and securing proxy contract protocols.