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.
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 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.
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.
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.
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.
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.
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()
.
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:
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 ContractSuppose 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:
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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
selfdestruct()
or Performing delegatecall()
/call()
to Untrusted ContractsIf 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.
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.