Skip to main content Link Search Menu Expand Document (external link)

Security Guide to Proxies

Note: If you are unsure which proxy type is in the scope of your audit or security review, see the proxy identification guide.

Table of contents

  1. Uninitialized Proxy Vulnerability
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  2. Storage Collision Vulnerability
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  3. Function Clashing Vulnerability
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  4. Metamorphic Contract Rug Vulnerability
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  5. Delegatecall with Selfdestruct Vulnerability
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  6. Delegatecall to Arbitrary Address
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading
  7. Delegatecall external contract missing existence check
    1. Testing procedure
    2. Hacks
    3. Bug Bounties
    4. CTF Examples
    5. Further reading

Uninitialized Proxy Vulnerability

Playground Link

Why do proxies need an initialize function when a contract constructor is called automatically? The reason is explained here by OpenZeppelin. The code in a contract’s constructor is run once at deployment, but there is no way to run constructor code of the implementation contract (AKA logic contract) in the context of the proxy contract. Because the implementation contract must store the value of the _initialized variable in the proxy contract context, the constructor cannot be used for this purpose, because the implementation contract’s constructor code will always run in the context of the implementation contract. This is why there exists an initialize function in the implementation contract - because the initialize call must happen through the proxy. Because the initialize call must happen as a separate step from the implementation contract deployment, there is a potential race condition that can happen that should also received attention, such as by protecting the initialize function with an address control modifier so only a specific msg.sender can initialize the function.

A specific variant of the uninitialized UUPS proxy vulnerability is found in the OpenZeppelin library between version 4.1.0 and 4.3.2. This issue is related to an edge case of delegatecall and selfdestruct interaction.

Testing procedure

To test for this vulnerability, first identify the storage slot of the initialized state variable or a similar variable that the initialization function uses to revert if this is not the first time that the function is called. Try using these tools to find the correct storage slot. If the OpenZeppelin private _initialized variable from Initializable.sol is used, a _initialized value of zero means the contract has not been initialized while a _initialized value of 1 means the contract has been initialized.

A simplistic check that has a low false-positive rate with a high false-negative is to check if the implementation contract has a non-empty constructor. Because any storage values set in the constructor of the implementation contract will not be used when the implementation contract is called through the proxy contract, a constructor can indicate a case of initialization values not getting set as expected.

Slither has a slither-check-upgradeability tool that has several initializer issue detectors.

Hacks

Bug Bounties

CTF Examples

None?

Further reading


Storage Collision Vulnerability

Playground Link

A storage collision happens when the storage slot layout in the implementation contract does not match the storage slot layout in the proxy contract. This causes a problem because the delegatecall in the proxy contract means that the implementation contract is using the proxy contract’s storage, but the variables in the implementation contract determine where that data is stored. If there is a mismatch between the proxy contract storage slots and the implementation contract storage slots, a storage collision can happen.

Take the Audius hack as an example. The AudiusAdminUpgradeabilityProxy contract storage slots collided with the initialization boolean values that indicated whether the proxy was initialized or not. The links to writeups about the details of the Audius hack are found below.

AudiusAdminUpgradeabilityProxy storage slots after mitigation

The proxy contract storage slots visualized with sol2uml

DelegateManager storage slots before mitigation

The DelegateManager contract storage before mitigation. The storage of the boolean values collide with the proxyAdmin address in the proxy contract.

DelegateManager storage slots after mitigation

The DelegateManager contract storage after mitigation. The storage of the boolean values has been moved to a new storage slot to avoid a storage collision.

Testing procedure

There are many approaches to testing for this vulnerability. One way to test for this vulnerability is using the sol2uml tool. You can visualize the storage slots of the proxy contract and the implementation contract to see if they have any mismatches.

A second approach that is more programmatic is using slither-read-storage to collect the storage slots used by the proxy contract and the implementation contract, then comparing them.

A third approach is to find a tool that is designed to compare the storage slots of two contracts. This tool may work.

Be aware that these approaches would not have caught the vulnerability in the Furucombo hack. A solution specific to the Furucombo hack would be to check if the a delegatecall calls another contract with a delegatecall where the contracts used different storage slots to store their implementation contract addresses. One could argue this issue is a subcategory of the uninitialized proxy vulnerability.

OpenZeppelin previously investigated an automated detection strategy for storage upgrades for zos.

Slither has a slither-check-upgradeability tool that has several detectors for storage layout issues.

There is a semgrep rule designed to detect the Audius hack problem pattern, but the semgrep rule does not appear to be a robust method of identifying proxy collisions.

Hacks

Bug Bounties

None?

CTF Examples

Further reading


Function Clashing Vulnerability

Playground Link

Function clashing is a result of compiled smart contracts using a 4 byte identifier (derived from the function name’s hash) to identify functions, known as a function selector. Functions with different names can contain identical 4 bytes identifiers when the first 32 bits of their hashes are the same. The compiler will detect when the same 4 byte function selector exists twice in a single contract, but it does not prevent the same 4 byte function selector from existing in different contracts of a project.

Function clashing can be found in most but not all proxy types. Specifically UUPS proxies are normally not vulnerable to function clashing because the implementation contract stores all the custom functions.

Testing procedure

To test for this vuln, you can collect the function selectors of a proxy contract and implementation contract to compare them for any function clashing. One tool for this is solc, where solc --hashes MyContract.sol will list all function selectors. Slither has a Slither’s Function ID printer that can do the same thing. Slither also has a slither-check-upgradeability tool that can detect function clashing.

Hacks

None?

Bug Bounties

None?

CTF Examples

None?

Further reading


Metamorphic Contract Rug Vulnerability

Playground Link

The CREATE2 opcode was introduced in the Constantinople hardfork with EIP-1014. It allows a contract to be deployed at an address that can be calculated in advance, unlike the CREATE opcode. It is possible to deploy a contract, destroy the contract with selfdestruct, and then deploy a new contract with different code at the same address as the original contract. If a user is unaware that the code at this address changed since they originally interacted with the contract, they might end up interacting with a malicious contract. The planned removal of the selfdestruct opcode with EIP-4758 will remove the ability to create metamorphic contracts in the future.

Testing procedure

To test for this vulnerability, you can use one of the existing tools mentioned in the “further reading” section, or to manually search for this issue:

  1. Find the creation transaction on etherscan (manually or with the etherscan API)
  2. Check if a CREATE2 call was used in the transaction that created this target contract. If the target contract was created with CREATE2, continue testing. If not, the target contract is not at risk of being replaced with new code at the same address.
  3. Check if the target contract, created by a CREATE2 call, contains a selfdestruct or a delegatecall. If the delegatecall allows calling another contract’s selfdestruct, it is the same result as finding a selfdestruct in the target contract.
  4. If CREATE2 was not used to create this contract but a selfdestruct or delegatecall exists, check if the parent of the target was created with a CREATE2 call. Continue checking the ancestry of the contract up the family tree until you reach an EOA address, because a CREATE2 anywhere in the target contract’s ancestry can pose a risk.

Even if the target contract you are examining cannot be replace with this vulnerability, it may perform an external call (call, staticcall, or delegatecall) to another contract which is vulnerable to the metamorphic contract rug vulnerability. Consider testing all external addresses that are called by the target contract.

Hacks

Bug Bounties

None?

CTF Examples

Further reading


Delegatecall with Selfdestruct Vulnerability

Playground Link

There are unexpected edge cases when delegatecall and selfdestruct are used together. Specifically, if contract A has a delegatecall to contract B, and the function in contract B contains selfdestruct, it is contract A that will be destroyed.

Testing procedure

This vulnerability is easy to identify. First, if a contract has a delegatecall that delegates to a user-provided address (such as a function argument in an external function), this is a substantial security risk overall

If a contract has a delegatecall to a hardcoded target contract, check if the target contract contains a selfdestruct. If the target contract does not contain selfdestruct but contains a delegatecall, then check the contract that is delegated to for a selfdestruct (and continue the process if another delegatecall is found). If there is a selfdestruct in the target contract, the original contract that contains the delegatecall could be destroyed. If the master contract used for EIP-1167 cloning is selfdestructed, all clones created from this contract will stop working.

Hacks

Bug Bounties

None?

CTF Examples

Further reading


Delegatecall to Arbitrary Address

Playground Link

A delegatecall passes the execution from the proxy contract to another contract, but the state variables and context (msg.sender, msg.value) from the proxy contract are used. If the implementation contract that delegatecall passes execution to can be an arbitrary contract, substantial problems emerge. For one, a denial-of-service is possible by combining delegatecall with selfdestruct (see the relevant section). Another risk is that if users have used approve or set an allowance to trust the proxy contract containing the delegatecall to an arbitrary address, the arbitrary delegatecall target can be used to steal user funds. The address that a contract transfer execution to with delegatecall must be a trusted contract and must not be open-ended to allow a user to provide the address to delegate to.

Testing procedure

For automated testing, the “Controlled Delegatecall” slither detector can detect this issue. For manual testing, examine the address used for any delegatecall operation. If this value can be set by an untrusted user input at any point, there is a risk of code execution being passed to an arbitrary address.

Hacks

None?

Bug Bounties

CTF Examples

None?

Further reading


Delegatecall external contract missing existence check

When delegatecall is used, there is no automated check for whether the external contract exists. If the external contract called does not exist, the return value will be true. This is documented in a warning note in the solidity documentation with the following:

The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.

Testing procedure

The first step is to identify the external contract address that the call is using. If it is possible for there to be no contract at this address, and there is no check to verify that the contract exists before the delegatecall, then delegatecall may return true unexpectedly.

Hacks

None?

Bug Bounties

CTF Examples

None?

Further reading