Proxies are used to implement upgradeability in Solidity smart contracts. They serve as a middleman between a contract and its users. They are employed to change a contract’s logic without altering its address. A master copy contract and a proxy contract that refers to the master copy are how proxies are typically set up. A lot has already been said about how proxies are built, if you are not familiar with it in the last paragraph you can find references to materials about how to create proxies and their types – it is recommended to understand proxies at least on a foundational level first. In this article we will focus solely on potential security issues that are typically found in upgradable smart contracts.
Uninitialized proxy
Proxies do not have a constructor, instead, they usually have the initialize() function which sets the key parameters – the reason for this is described here in details. Normally, the initialization should be an one-time operation, similarly to the constructor. However it might happen, that for some reason function initialize() is not called and the contract is deployed on the blockchain with the initialize() function possible to be called by anyone! As you probably imagine, this may lead to serious consequences, since usually the initializers sets the owner to the caller, which means, any attacker may call the function and take over the vulnerable contract. The exploitation scenario usually may involve updating the contract implementation to attacker-controlled one, which contains a malicious function that executes selfdestruct. If the proxy utilizes delegatecall, once selfdestruct is called, the destruction operation will be performed on the vulnerable contract’s storage (as this is how delegatecall works).
References:
https://medium.com/immunefi/wormhole-uninitialized-proxy-bugfix-review-90250c41a43a
https://medium.com/immunefi/harvest-finance-uninitialized-proxies-bug-fix-postmortem-ea5c0f7af96b
https://forum.openzeppelin.com/t/security-advisory-initialize-uups-implementation-contracts/15301
Unable to upgrade
Understanding the proxy upgrade process should be key point when auditing an upgradeable contract, especially, if the contract contains altered logic taken from original, well-known solidity libraries.
pragma solidity ^0.8.0; contract ImplementationV1 { function doSomething() public pure { // code that does something } } contract Upgradable { address public implementation; bool public initialized; constructor() public { implementation = address(new ImplementationV1()); initialized = true; } function upgradeImplementation(address _newImplementation) public { require(!initialized); implementation = _newImplementation; } }
In above code, which is intentionally vulnerable, there is a logic issue. As you can see, the require statement in function upgradeImplementation() will always be false after the contract variable initialized is set to true (which will be always the case as it’s being set in the constructor). This way, the contract cannot be upgraded, so one of core utilities is broken. As a result, the effort required to re-deploy the contract might be extremely high, especially, if the contract is a critical part of a DeFi with a high TVL.
Multiple initializations
While current OpenZeppelin proxy implementations make use of modifiers such as initializer or disableinitializers it is possible that if there are custom tweaks in proxy implementations, the fact of initialization will not be notified in the smart contract, which might lead to a scenario equal to an uninitialized proxy, or in slightly more secure case, that the initialization can be done multiple times, but its only possible for contract admin.
pragma solidity ^0.8.0; contract Upgradable { address public implementation; bool public initialized; function initialize() public { require(!initialized); implementation = address(new ImplementationV1()); owner = msg.sender; initialized = false; uint256 totalBalance = 0; //assume its some key variable holding user's balances } function upgradeImplementation(address _newImplementation) public onlyOwner { implementation = _newImplementation; } }
In above scenario, the initializer fails to mark that initialization has been done, which means, it can be done multiple times. In this case, initialize() can be at any time called by an attacker to take over the contract as in uninitialized proxy scenario mentioned previously. Moreover, even if that’s mitigated e.g. by checking the ownership, consider that function still stays open for the owner, and it’s accidentally called, clearing all users’ balances.
Frontrunning initialization
When initializing an upgradable contract, you should be aware that there is still a possibility to get front run. Since initialization is usually public function, even if its one-time, it should be checked after deployment if the transaction succeeded from proper account and if it was not frontrun. It would be a pity to discover on a late stage of a project, that the deployer is in fact someone else. Of course, there is a possibility to implement in-code check, for example, if the initialization has been called twice or more in a short time and in such case revert, however, simply examining the contract after deployment if the owner is who it was meant to be should be the quickest and most effective way of ensuring no frontrunning took place.
Proxy resources
If you want to learn more about the proxies itself to better understand how they are built, you might want to take a look at following resources:
Youtube Smart Contract Programmer channel, videos dedicated to building a proxy:
- Minimal proxy contract
- Wrong way to write an upgradeable proxy
- Return data from fallback
- Write to any slot – transparent upgradeable proxy
- Proxy Admin – transparent upgradeable proxy
Also resources from CoinMonks:
In case you want to read also the second part of this article, it’s available here.