Critical UUPS Proxy Vulnerability in OpenZeppelin 4.1.0-4.3.1

Summary
Because the implementation contract's `upgradeTo` function lacked access control, a specially crafted upgrade path could call `SELFDESTRUCT` and permanently break the proxy system.

OpenZeppelin supports two main proxy patterns:

  • Transparent proxy
  • UUPS proxy

The most important difference is where the upgradeTo logic lives. In a transparent proxy, the upgrade logic is implemented in the proxy contract. In a UUPS proxy, the upgrade logic lives in the implementation contract.

The Vulnerability

OpenZeppelin’s upgradeable system is usually composed of multiple contracts. Each upgradeable deployment includes an implementation contract that contains the business logic and a proxy contract that stores the state. Upgrading the proxy simply means changing the proxy’s implementation pointer to a new implementation contract.

Proxy and implementation

Upgrade flow diagram:

Proxy upgrade

In versions 4.1.0 through 4.3.1, the upgradeTo function on UUPSUpgradeable did not restrict who could call it. That meant anyone could call it directly on the implementation contract. An attacker could deploy a malicious contract whose upgradeTo path executes SELFDESTRUCT, then call upgradeTo on the implementation contract and point it to the malicious contract. The result is that the implementation destroys itself. One crucial detail is that calling SELFDESTRUCT here does not cause the transaction to fail. Once the implementation code is gone, the proxy loses its logic and the whole system breaks.

The older implementation of upgradeTo was:

1
2
3
4
    function upgradeTo(address newImplementation) external virtual {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallSecure(newImplementation, bytes(""), false);
    }

In version 4.3.2 it became:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    modifier onlyProxy() {
        require(address(this) != __self, "Function must be called through delegatecall");
        require(_getImplementation() == __self, "Function must be called through active proxy");
        _;
    }

    function upgradeTo(address newImplementation) external virtual onlyProxy {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallSecure(newImplementation, new bytes(0), false);
    }

Now upgradeTo on the implementation can only be executed through the active proxy.

How upgradeTo Works in the UUPS Pattern

The deployment process for a UUPS system is roughly:

First, deploy the implementation contract. It must inherit from ERC1967UpgradeUpgradeable.

Second, deploy the ERC1967Proxy contract and initialize it with the implementation address from the previous step.

Finally, use the proxy to call upgradeTo.

The relevant code looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
    function upgradeTo(address newImplementation) external virtual onlyProxy {
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallSecure(newImplementation, new bytes(0), false);
    }

    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) {
            _functionDelegateCall(newImplementation, data);
        }

        // Perform rollback test if not already in progress
        StorageSlotUpgradeable.BooleanSlot storage rollbackTesting = StorageSlotUpgradeable.getBooleanSlot(_ROLLBACK_SLOT);
        if (!rollbackTesting.value) {
            // Trigger rollback using upgradeTo from the new implementation
            rollbackTesting.value = true;
            _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);
        }
    }

    function _upgradeTo(address newImplementation) internal {
        _setImplementation(newImplementation);
        emit Upgraded(newImplementation);
    }
    function _setImplementation(address newImplementation) private {
        require(AddressUpgradeable.isContract(newImplementation), "ERC1967: new implementation is not a contract");
        StorageSlotUpgradeable.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
    }

Here, _authorizeUpgrade is the permission check implemented by the application contract. Any contract inheriting from ERC1967UpgradeUpgradeable must provide this function, and in practice it usually restricts upgrades to the owner.

Inside _upgradeToAndCallSecure, the new implementation is subjected to a rollback test. The contract temporarily delegates into the new implementation and asks it to upgrade back to the old implementation. This verifies that the new implementation’s upgrade logic is still functional and that future upgrades will remain possible.

If that rollback test succeeds, the contract finally stores the new implementation address in _IMPLEMENTATION_SLOT.

https://forum.openzeppelin.com/t/uupsupgradeable-vulnerability-post-mortem/15680