The proxy pattern allows smart contracts to upgrade their logic while maintaining their on-chain address and state values. Invoking a proxy contract executes code from the logic contract via delegateCall, modifying the state of the proxy contract.
This article provides an overview of proxy contract types, related security events and recommendations, and best practices for using proxy contracts.
Upgradable Contracts and Proxy Pattern Overview
We all know the “immutable” nature of the blockchain, which means that smart contract code cannot be modified after being deployed on the blockchain.
- Analysis of Lybra Finance (LSDfi): How stable is it? What are the risks of “layer 2 nesting”?
- Zcash releases stable version of Zebra – Zebra 1.0.0
- Decryption sorter: Key to ensuring transaction authenticity
Therefore, when developers want to upgrade the logic, fix errors, or update the contract code due to security, they must deploy a new contract and generate a new contract address.
To solve this problem, the proxy pattern can be used.
The proxy pattern implements contract upgradability without changing the contract deployment address, which is currently the most common contract upgrade pattern.
The proxy pattern is an upgradable contract system, consisting of a proxy contract and logical implementation contracts.
The proxy contract handles user interactions, data, and contract state storage. Calls to the proxy contract by users execute code from the logic contract via delegatecall(), thereby changing the state of the proxy contract. Upgrades are then implemented by updating the logical contract address recorded in the storage slot reserved for the proxy contract.
The three most common proxy patterns are transparent proxy, UUPS proxy, and Beacon proxy.
Transparent Proxy
In the transparent proxy pattern, the upgrade function is implemented in the proxy contract. The administrator role of the proxy contract is given direct permission to operate the proxy to update the logic implementation address corresponding to the proxy. Callers without administrator privileges delegate their calls to the implementation contract.
Note: Proxy contract administrators cannot be key roles in the logic implementation contract or even ordinary users, as proxy administrators cannot interact with the implementation contract.
UUPS Proxy
In the UUPS (Universal Upgradeable Proxy Standard) pattern, the contract upgrade function is implemented in the logical contract. Since the upgrade mechanism is stored in the logical contract, the upgraded version can delete the upgrade-related logic to prevent future upgrades. In this mode, all calls to the proxy contract are forwarded to the logical implementation contract.
Beacon Proxy
The Beacon proxy pattern allows multiple proxy contracts to share the same logic implementation by referencing a Beacon contract. The Beacon contract provides the address of the logic implementation contract to the called proxy contract. When upgrading to a new logic implementation address, only the address recorded in the Beacon contract needs to be updated.
Proxy Misuse and Security Incidents
Developers can use proxy pattern contracts to implement upgradable contract systems. However, the proxy pattern also has a certain operational threshold. If used improperly, it may cause devastating security issues to the project. The following section shows events related to proxy misuse and the centralization risk brought by the proxy.
Key Leakage of Proxy Management
The proxy administrator controls the upgrade mechanism of the transparent proxy pattern. If the administrator’s private key is leaked, attackers can upgrade the logic contract and execute their own malicious logic on the proxy status.
On March 5, 2021, BlockingID Network suffered a “coin minting” attack due to poor private key management. The attacker used BlockingID Network, stole the proxy administrator’s private key, and triggered the upgrade mechanism to change the logic contract.
After the upgrade, the attacker can destroy the user’s BlockingID and mint a batch of BlockingID for themselves, which they can then sell. There are no security vulnerabilities in the code itself, but the attacker obtained the private key for upgrading the contract from the administrator.
Uninitialized UUPS Proxy Implementation
For UUPS proxy pattern, in the initialization process of the proxy contract, the initial parameters are passed to the proxy contract by the caller, and then the initialize() function in the logic contract is called by the proxy contract to implement initialization.
The initialize() function is usually protected by the “initializer” modifier to limit the function to be called only once. After calling the initialize() function, the logic contract is initialized from the perspective of the proxy contract.
However, from the perspective of the logic contract, the logic contract has not been initialized because initialize() has not been directly called in the logic contract. Given that the logic contract itself has not been initialized, anyone can call the initialize() function to initialize it, set the state variables to a malicious value, and potentially take over the logic contract.
The impact of the logic contract being taken over depends on the contract code in the system. In the worst case, the attacker can upgrade the logic contract in the UUPS proxy pattern to a malicious contract and execute a “self-destruct” function call, which may render the entire proxy contract useless and permanently lose the assets in the contract.
Case
① Blockingrity Multisig Freeze: Uninitialized logical contract. The attacker triggered the initialization of many wallets and locked ether in the contract by calling selfdestruct().
② Harvest Finance, Teller, KeeperDAO and Rivermen all use uninitialized logical contracts, which allows attackers to arbitrarily set the initialization parameters of the contract and destroy the proxy contract during delegatecall() execution by calling selfdestruct().
Storage Conflict
In an upgradable contract system, the proxy contract does not declare state variables, but instead uses pseudo-random storage slots to store important data.
The proxy contract saves the values of the logical contract state variables in their declared relative positions. If the proxy contract declares its own state variables, a storage conflict occurs when both the proxy and the logical contract attempt to use the same storage slot.
The proxy contract provided by the OpenZeppelin library does not declare state variables in the contract, but instead saves the values that need to be stored (such as the management address) in a specific storage slot based on the EIP 1967 standard to prevent conflicts.
Case
On July 23, 2022 Beijing time, the decentralized music platform Audius was hacked, which was caused by introducing new logic into the proxy contract, resulting in a storage conflict.
The proxy contract declared a proxyAdmin address state variable, and its value was incorrectly read when executing the logical contract code.
The value of proxyAdmin defined by the project party was mistakenly treated as the values of initialized and initializing, causing the initializer modifier to return incorrect results, allowing the attacker to call the initialize() function again and grant themselves permission to manage the contract. The attacker then changed the voting parameters and passed their malicious proposal to steal Audius assets.
Calling delegatecall() or an untrusted contract in the logical contract
Suppose delegatecall() exists in a logical contract and the contract does not properly verify the call target. In this case, an attacker can use this function to execute calls to a malicious contract they control to disrupt the logical implementation or perform custom logic.
Similarly, if there is an unrestricted address.call() function in the logical contract, an attacker can use it as a proxy contract once they maliciously provide the address and data fields.
Case
Pickle Finance, Furucombo, and dYdX attack incidents.
In these incidents, vulnerable contracts obtained approval for user tokens, and the contract contained a call()/delegatecall() that allowed users to provide the calling contract address and data. An attacker would then be able to call a contract with transferFrom() functionality to extract the user’s balance. In the dYdX incident, dYdX carried out their own white hat attack to protect the funds.
Best Practices
General
(1) Only use the proxy pattern when necessary
Not every contract needs to be upgradable. As shown above, using the proxy pattern involves many risks. The “upgradable” attribute also raises trust issues, as the proxy administrator can upgrade the contract without community consent. We recommend integrating the proxy pattern into the project only when necessary.
(2) Do not modify the proxy library
The proxy contract library is very complex, especially the part that deals with storage management and upgrade mechanisms. Any errors in modification will affect the work of the proxy and logic contracts. A large number of high-severity bugs related to proxies that we discovered during the audit were caused by improper modifications to the proxy library. The Audius incident is a typical example that demonstrates the consequences of improper modification of the proxy contract.
Proxy Contract Operation Management Points
(1) Initialize the logic contract
An attacker can take over an uninitialized logic contract and may use this to destroy the proxy contract system. Therefore, please initialize the logic contract after deployment, or use _disableInitializers() in the logic contract constructor to automatically disable initialization.
(2) Ensure the security of the proxy management account
A upgradable contract system usually requires a “proxy administrator” privileged role to manage contract upgrades. If the management key is leaked, an attacker can freely upgrade the contract to a malicious contract, and then steal the user’s assets. We recommend carefully managing the private key of the proxy management account to avoid any potential risks of being hacked. A multi-signature wallet can be used to prevent single-point key management failure.
(3) Use a separate account for transparent proxy management
Proxy management and logic governance should be separate addresses to prevent the loss of interaction with logic implementation. If the proxy management and logic governance refer to the same address, no call forwarding will be made to execute privileged functions, thus prohibiting changes to governance functions.
Proxy Contract Storage Related
(1) Be careful when declaring state variables in a proxy contract
As explained in the Audius hack, proxy contracts must be careful when declaring their own state variables. State variables declared in a proxy contract in the normal way can cause data conflicts when reading and writing data. If a proxy contract needs a state variable, save the value in a storage slot like EIP1967 to prevent conflicts when executing the logic contract code.
(2) Maintain the order and type of variable declarations in the logic contract
Each version of the logic contract must keep the same order and type of state variables, and new state variables need to be added to the end of existing variables. Otherwise, delegate calls can cause the proxy contract to read or overwrite incorrect storage values, and old data may be associated with newly declared variables, which can cause serious problems for the application.
(3) Include storage gaps in the base contract
The logic contract needs to include storage gaps in the contract code to predict new state variables when deploying new logic implementations. After adding new state variables, the size of the gap needs to be updated appropriately.
(4) Do not set values for state variables in the constructor or declaration process
Assigning state variables during declaration or constructor only affects the value in the logic contract and does not affect the proxy contract. Non-immutable parameters should use the initialize() function to assign.
Contract Inheritance
(1) Upgradeable contracts can only inherit from other upgradeable contracts
Upgradeable contracts have a different structure than non-upgradeable contracts. For example, the constructor is incompatible with changing the proxy state, and it uses the initialize() function to set state variables.
Any contract that inherits another contract must use the initialize() function of its inheritance contract to assign its own variables. When using the OpenZeppelin library or writing your own code, make sure that upgradeable contracts can only inherit other upgradeable contracts.
(2) Do not instantiate new contracts in the logic contract
Contracts created by Solidity instantiation will not be upgradeable. Contracts should be deployed separately and their addresses passed as parameters to upgradeable logic contracts to achieve upgradeable status.
(3) Risk of initializing the Blockingrent contract
When initializing the Blockingrent contract, the __{ContractName}_init function will initialize its Blockingrent contract. Calling multiple __{ContractName}_init may cause the Blockingrent contract to be initialized for the second time. Note that __{ContractName}_init_unchained() will only initialize the parameters of {ContractName} and will not call the initializer of its Blockingrent contract.
However, this is not a recommended practice because all Blockingrent contracts need to be initialized, and not initializing the required contract will cause execution issues later on.
Implementation of logic contract
Avoid using selfdestruct() or executing selegatecall()/call() on untrusted contracts
If there is a selfdestruct() or delegatecall() in the contract, attackers may use these functions to break the logic implementation or execute custom logic. Developers should verify user input and not allow contracts to execute delegatecall/calls on untrusted contracts. In addition, it is not recommended to use delegatecall() in the logic contract, as managing the storage layout in a chain of multiple contract proxies can be difficult.
In conclusion
Proxy contracts bypass the immutable nature of the blockchain by allowing protocols to update their code logic after deployment. However, developing proxy contracts still requires great caution, as incorrect implementation may cause project security and logic issues.
In general, the best practice is to use authoritative and widely tested solutions, as transparent, UUPS, and Beacon proxy modes each have verified upgrade mechanisms for their respective use cases. In addition, the privileged role of upgrading proxies should be securely managed to prevent attackers from changing proxy logic.
Logic implementation contracts should also be careful not to use delegatecall(), which can prevent attackers from executing certain malicious code, such as selfdestruct().
Although following best practices can ensure the stability of proxy contract deployment while maintaining upgrade flexibility, all code is prone to new security or logic issues that may jeopardize the project. Therefore, all code should be audited by a security expert team with experience auditing and protecting proxy contract protocols.
Like what you're reading? Subscribe to our top stories.
We will continue to update Gambling Chain; if you have any questions or suggestions, please contact us!