In early September, we received two independent reports of a vulnerability in the UUPSUpgradeable base contract of the OpenZeppelin Contracts library, first released in version 4.0 in April, 2021.
In this post, we share an overview of the contract and the vulnerability, executed mitigation strategies, and the implemented fix. To the best of our knowledge, we successfully executed mitigations and no project suffered loss of funds.
Thank you Raymond Yeh of Bluejay Finance for disclosing this vulnerability to us, including a working proof-of-concept of the exploit, on September 3rd. Additional thanks to Ashiq Amien of Iosiro who independently detected and reported the same issue just six days later. Finally, thank you to the Tenderly team, for helping us in track affected projects; and to Dedaub for promptly volunteering help, as well.
Upgrade Proxies
OpenZeppelin Contracts includes several proxy contracts for enabling upgradeability. All are based on the same concept: each upgradeable deployment consists of an implementation contract, which holds the code to be executed, and a proxy contract, which holds the state. Whenever a user calls the proxy contract, the proxy delegates execution to the implementation contract. In order to upgrade the contract, the proxy is pointed to a different implementation contract, thus changing the code being executed but preserving the same address and state. You can read more about how this pattern works here.
The two most popular patterns in the library are the Transparent proxy and the UUPS proxy. As described here, the main difference between them is where the upgrade logic resides. In the Transparent proxy, the upgrade logic is in the proxy. In the UUPS pattern, the proxy delegates all calls to the implementation, which handles both the business logic and upgrade logic. This allows users to decide the authorization logic used for upgrades, such as Ownable or AccessControl, or any other custom solution. To facilitate this, the library provides a base UUPSUpgradeable contract that implementation contracts should extend from.
The internal upgrade logic in UUPSUpgradeable performs several tasks. Besides changing the implementation contract stored in the proxy, it also executes a DELEGATECALL
to the new implementation to atomically execute any migration function, as part of an upgradeToAndCall
action.
After this, the contract performs a rollback test. In order to make sure that the new implementation contract has valid upgrade logic, it executes another DELEGATECALL
to upgrade back into the current version. If it succeeds, the contract finally re-upgrades to the new implementation. This prevents accidentally upgrading to a contract without upgrade logic, which would freeze the proxy in that version.
The code for this upgrade operation is the following, which is implemented in the ERC1967Upgrade base contract.
function _upgradeToAndCallSecure(address newImplementation, bytes memory data, bool forceCall) internal {
address oldImplementation = _getImplementation();
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0 || forceCall) {
Address.functionDelegateCall(newImplementation, data);
}
// Perform rollback test if not already in progress
StorageSlot.BooleanSlot storage rollbackTesting = StorageSlot.getBooleanSlot(_ROLLBACK_SLOT);
if (!rollbackTesting.value) {
// Trigger rollback using upgradeTo from the new implementation
rollbackTesting.value = true;
Address.functionDelegateCall(
newImplementation,
abi.encodeWithSignature("upgradeTo(address)", oldImplementation)
);
rollbackTesting.value = false;
// Check rollback was effective
require(oldImplementation == _getImplementation(), "ERC1967Upgrade: upgrade breaks further upgrades");
// Finally reset to the new implementation and log the upgrade
_upgradeTo(newImplementation);
}
}
The UUPSUpgradeable contract extends ERC1967Upgrade, by exposing this upgrade internal method behind a customizable access control check.
Vulnerability
The vulnerability lies in the DELEGATECALL instructions in the upgrade function, exposed by the UUPSUpgradeable base contract. As described here, a DELEGATECALL
can be exploited by an attacker by having the implementation contract call into another contract that SELFDESTRUCT
s itself, causing the caller to be destroyed.
Given an UUPS implementation contract, an attacker can initialize it and appoint themselves as upgrade administrators. This allows them to call the upgradeToAndCall
function on the implementation directly, instead of on the proxy, and use it to DELEGATECALL
into a malicious contract with a SELFDESTRUCT
operation.
If the attack is successful, any proxy contracts backed by this implementation become unusable, as any calls to them are delegated to an address with no executable code. Furthermore, since the upgrade logic resided in the implementation and not the proxy, it is not possible to upgrade the proxy to a valid implementation. This effectively bricks the contract, and impedes access to any assets held on it.
Mitigation
The mitigation for this vulnerability is simple: initializing the implementation contract. If the implementation is initialized, an attacker can no longer pass the authorization check of calling upgradeToAndCall
in it. We shared this on September 9th as a public security advisory before disclosing the fix and vulnerability, to minimize its impact.
Before sharing the advisory, we sent transactions to initialize more than 150 uninitialized UUPS implementation contracts across Ethereum Mainnet, Polygon, xDAI, Binance, and Avalanche. We found no instances on Fuse, Fantom, Arbitrum, or Optimism. These transactions were all sent from EOA 0x37E8d216c3f6c79eC695FBD0cB9842e62fB84370
, and through a batching contract at 0x310fAC62C976d8F6FDFA34332a56EA1a05493b5b
. We relayed the transaction on mainnet privately using Taichi to protect against generalized frontrunning bots.
To identify UUPS instances, we searched through Upgraded
events on all chains, which are emitted when a proxy is created. We then filtered proxies with ERC1967 implementation data and without admin, removing Transparent proxies from the mix. We then searched for occurrences of the DELEGATECALL
opcode in the bytecode of the implementations obtained, and proceeded to analyze them. Additionally, we searched for the UUPSUpgradeable
string across verified contracts on Etherscan, in case we had missed any with the previous search.
In order to identify the initialize
function, which can vary between implementations depending on the parameters it receives, we extracted the calldata used when deploying a proxy for the implementation. This worked in most cases, but for others we had no recourse other than to reconstruct the initialize call manually.
To avoid accidentally granting access to an unsafe address when initializing a contract, we replaced anything resembling an address in the initialization calldata with a dummy contract. This contract implements several methods that were required by the initializers we reviewed, such as name, symbol, decimals, token0, token1, factory, and others.
Additionally, after executing the initialization, we selfdestruct
ed the batching contract, since several initializers granted admin rights to msg.sender. This ensured that no address had control over any of the implementations we initialized.
Hotfix
A hotfix was shipped in Contracts 4.3.2 and in the upgrade-safe version of the library. The fix adds an onlyProxy
modifier to the UUPSUpgradeable base contract preventing the upgrade functions to be called directly on the implementation.
address private immutable __self = address(this);
modifier onlyProxy() {
require(address(this) != __self, "Function must be called through delegatecall");
require(_getImplementation() == __self, "Function must be called through active proxy");
_;
}
We also updated our documentation and guidelines to suggest initializing all implementation contracts upon deployment. Additionally, we modified the code generated by the Wizard to include a constructor that automatically initializes the implementation when deployed.
Next steps
Our team will thoroughly review the rollback test mechanism for the next release of the library. While the code provides solid guarantees against accidentally bricking the contract, it also requires the usage of the potentially dangerous DELEGATECALL
operation. We will evaluate removing this check in favor of a simpler one, and adding more safeguards on the tooling side.
In terms of processes, we plan to increase the number of required reviews for all major features in Contracts, especially when it involves a code change that requires going against a security recommendation, as is the case here.
Last but not least, we are working on setting up active monitoring infrastructure to alert in the event of a deployment of a vulnerable uninitialized implementation contract. We will be sharing more news about this soon.