ChainScore Labs
All Guides

How Upgradeable Contracts Can Be Hijacked

LABS

How Upgradeable Contracts Can Be Hijacked

Chainscore © 2025

Core Concepts of Upgradeable Contracts

Understanding the foundational patterns and mechanisms that enable smart contract logic to be modified post-deployment, and the inherent security trade-offs involved.

Proxy Pattern

The proxy pattern is the architectural foundation. A lightweight proxy contract stores the contract's state and delegates all logic calls to a separate implementation contract. The proxy's fallback function uses delegatecall to execute code from the implementation in its own storage context. This separation allows the logic address to be swapped while preserving user data and contract address.

  • Transparent Proxy: Uses a proxy admin to manage upgrades, preventing function selector clashes.
  • UUPS (EIP-1822): Upgrade logic is built into the implementation contract itself, making it more gas-efficient.
  • Beacon Proxy: Multiple proxies point to a single beacon contract that holds the implementation address, enabling mass upgrades.

Storage Layout & Inheritance

Managing storage layout is critical for upgrade safety. The proxy and implementation share the same storage slots. Adding, removing, or reordering state variables in a new implementation can lead to catastrophic data corruption, as variables are accessed by slot position, not name.

  • Unstructured Storage: Using libraries like StorageSlot to manually manage slot assignments, avoiding collisions.
  • Inherited Storage: Employing base contracts that define storage structures, ensuring consistency across versions.
  • Gap Variables: Reserving unused storage slots in base contracts to allow for future state variable additions without layout shifts.

Initialization & Constructors

Standard constructors are ineffective in upgradeable contracts because they run only once during the implementation contract's deployment, not the proxy's. Instead, initializer functions are used, protected by an initializer modifier to prevent re-execution.

  • Initializable Contracts: Using OpenZeppelin's Initializable base contract to manage initialization state.
  • Security Risk: An unprotected initialize function is a major vulnerability, allowing anyone to become the owner.
  • Proxied Constructor Simulation: The initializer must set up the same state a constructor would, including setting roles and initial values.

Implementation Contract

The implementation contract (or logic contract) contains the executable code but holds no persistent state of its own. When a user calls the proxy, it delegates the call to the current implementation address. This contract can be replaced, but its old versions remain on-chain.

  • Stateless Logic: The implementation's functions must read from and write to the proxy's storage via delegatecall.
  • Version Immutability: Once deployed, an old implementation bytecode is permanent and can be analyzed for exploits.
  • Selfdestruct Risk: A malicious upgrade could add a selfdestruct to the implementation, which would destroy the proxy when called via delegatecall.

Upgrade Authorization

The mechanism controlling who can perform an upgrade is a central security checkpoint. Upgrade authorization is typically managed by an admin address or a decentralized governance contract (like a DAO). Unauthorized upgrade access is equivalent to handing over full control of the protocol.

  • Timelocks: Introducing a delay between an upgrade proposal and its execution, allowing users to exit or review changes.
  • Multi-signature Wallets: Requiring multiple trusted parties to approve an upgrade transaction.
  • Governance Tokens: Allowing token holders to vote on upgrade proposals, decentralizing control.

Function Clashing & Selectors

Function selector clashes occur when a function signature in the proxy admin overlaps with a function in the implementation. In a Transparent Proxy, this is mitigated by the proxy admin, which routes calls based on the caller's address. In UUPS, the upgrade function resides in the implementation, requiring careful naming to avoid clashes with standard functions like transfer().

  • Transparent Proxy Logic: If msg.sender is the admin, call the proxy; otherwise, delegate to the implementation.
  • Collision Risk: Poorly chosen function names in the implementation can inadvertently override critical proxy admin functions in UUPS patterns.
  • Testing: Comprehensive testing must verify all selectors are uniquely routed as intended.

Primary Attack Vectors and Exploit Paths

Process overview

1

Identify the Proxy Storage Layout

Analyze the proxy contract to locate the implementation address slot.

Detailed Instructions

The first step in a storage collision attack is to identify the specific storage slot where the proxy contract stores the address of its current logic implementation. This is the implementation slot. In the common EIP-1967 standard, this address is stored at the keccak256 hash of eip1967.proxy.implementation minus 1, which resolves to the slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc. For UUPS proxies, you must also locate the _IMPLEMENTATION_SLOT. Use a block explorer or a script to read the raw storage from the proxy contract address at these known locations to confirm the current implementation address.

  • Sub-step 1: Deploy a script or use cast storage to read the proxy's storage.
  • Sub-step 2: Query the known EIP-1967 implementation slot: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc.
  • Sub-step 3: Verify the returned value is a valid, non-zero address, confirming the proxy pattern.
javascript
// Example using ethers.js and a provider const implementationSlot = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'; const storageValue = await provider.getStorageAt(proxyAddress, implementationSlot); const currentImpl = '0x' + storageValue.slice(26); // Extract address console.log('Current Implementation:', currentImpl);

Tip: For non-standard proxies, you may need to decompile the bytecode or trace a previous upgrade transaction to find the storage layout.

2

Craft a Malicious Implementation Contract

Deploy a new contract designed to clash with the proxy's storage layout.

Detailed Instructions

With knowledge of the implementation slot, you must now write and deploy a malicious logic contract. This contract's state variable declarations must be arranged to cause a storage collision, ensuring your malicious variable occupies the same slot as the proxy's implementation pointer. The goal is to create a function, often within the initialize or a similar public method, that writes a new attacker-controlled address to that slot. Crucially, your contract must also include a delegatecall fallback or a function that preserves the proxy's ability to receive calls, or it will break the proxy's functionality and be detected.

  • Sub-step 1: Write a new Solidity contract with a variable at storage slot 0 that will align with the proxy's implementation slot.
  • Sub-step 2: Include a public function (e.g., attack()) that uses sstore to write your address to the critical slot.
  • Sub-step 3: Ensure the contract has a fallback() or receive() function that performs a delegatecall to an internal logic address to maintain proxy compatibility.
solidity
// Malicious Implementation Sketch contract MaliciousImplementation { // This address variable is deliberately placed at storage slot 0. address public hijacker; // The slot for the implementation address in the proxy. bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; function attack() external { // Overwrite the implementation pointer in the proxy's storage. assembly { sstore(IMPLEMENTATION_SLOT, caller()) } } fallback() external payable { // Minimal fallback to avoid immediate breakage. assembly { return(0, 0) } } }

Tip: Test the storage layout locally using solc --storage-layout to verify the collision before deployment.

3

Trigger the Upgrade Mechanism

Exploit a privileged function or trick the proxy admin to upgrade to your malicious contract.

Detailed Instructions

To replace the current logic contract, you must trigger the proxy's upgrade mechanism. This path varies by the proxy's admin controls. For a transparent proxy, you would need to compromise the admin's private keys or exploit a flaw in the upgradeTo function's access control. For a UUPS proxy, the upgrade function resides in the logic contract itself; you must find a way to call upgradeToAndCall on the current implementation, potentially through a previously undiscovered vulnerability or a malicious proposal in a governance system. The attacker's address must be passed as the newImplementation argument. This step often involves social engineering, governance attack vectors, or exploiting time-lock vulnerabilities.

  • Sub-step 1: Analyze the proxy admin or owner address using cast call to check permissions.
  • Sub-step 2: If a timelock is present, monitor the queue for upgrade proposals you can front-run or manipulate.
  • Sub-step 3: Craft the transaction data calling upgradeToAndCall(address(yourMaliciousContract), attackCalldata).
bash
# Example: Simulating a call to a UUPS upgrade function cast call <PROXY_ADDRESS> \ "upgradeToAndCall(address,bytes)" \ <MALICIOUS_IMPL_ADDRESS> \ $(cast calldata "attack()")

Tip: The most common failure point is insufficient permissions; thorough reconnaissance of the admin setup is critical.

4

Execute the Storage Overwrite and Assume Control

Call the malicious function to hijack the implementation slot and validate control.

Detailed Instructions

After the proxy points to your malicious contract, you must execute the function that performs the critical storage overwrite. This is typically the attack() or initialize() function you embedded. This call will use delegatecall context, meaning it executes in the proxy's storage. The sstore operation will write your attacker-controlled Ethereum address directly into the implementation slot, permanently changing the proxy's logic contract reference. You must then verify the hijack was successful by reading the implementation slot again; it should now return your address. At this point, you have full control and can deploy a final payload contract with any desired malicious logic (e.g., a drainer) and upgrade the proxy to it.

  • Sub-step 1: Send a transaction to the proxy address calling the malicious function (attack()).
  • Sub-step 2: Use getStorageAt to read the implementation slot and confirm it now holds your address.
  • Sub-step 3: Deploy a final "Drainer" contract with functions to extract all assets and update the proxy to this new contract.
solidity
// Final Drainer Contract contract Drainer { function sweep(address token, address to) external { // Transfer all balance of this contract (the proxy) to 'to'. IERC20(token).transfer(to, IERC20(token).balanceOf(address(this))); } // ... other drain functions for ETH, NFTs, etc. }

Tip: After the hijack, act quickly before the compromise is detected. The entire proxy state, including user funds, is now under your control.

Vulnerability Comparison by Proxy Pattern

Comparison of security risks and characteristics across common upgradeable contract implementations.

FeatureTransparent ProxyUUPS (EIP-1822/1967)Beacon Proxy

Admin Function Location

Proxy Contract

Implementation Contract

Beacon Contract

Upgrade Call Overhead

~25k gas (admin check)

~21k gas (delegatecall)

~5k gas (beacon query)

Storage Collision Risk

High (manual slot management)

High (manual slot management)

Low (fixed proxy storage)

Implementation Initialization

Separate initializer function

Constructor or initializer

Constructor or initializer

Attack Surface for Hijacking

Proxy admin privilege escalation

Implementation selfdestruct/upgradeTo

Beacon contract compromise

Gas Cost for User Calls

Baseline + ~2.4k gas

Baseline

Baseline + ~1.2k gas

Common Vulnerability Example

admin() function selector clash

Unprotected upgradeTo() function

Malicious beacon update

Real-World Case Studies and Post-Mortems

Understanding the Exploit Paths

Upgradeable contracts are compromised through specific, well-documented vectors. The primary risk is unauthorized access to the proxy admin role, which grants the ability to change the contract's logic address. Another critical vector is a malicious implementation contract that appears legitimate but contains hidden backdoors, often introduced via a compromised dependency or a malicious team member.

Common Exploitation Methods

  • Admin Key Compromise: Private keys for the proxy admin wallet are leaked, phished, or controlled by a malicious insider, allowing an attacker to point the proxy to their own contract.
  • Initialization Flaws: Improper use of initializer functions can leave the implementation contract in an uninitialized state, permitting an attacker to call the initializer and become the owner.
  • Transparent Proxy Pitfalls: Using non-standard proxy patterns or incorrect function selectors can lead to function clashing, where an admin function is accidentally exposed to a regular user.

Real Protocol Context

The Parity Wallet hack in 2017, while not a standard upgradeable proxy, is a seminal case of initialization vulnerability where a user became the owner of the library contract, subsequently "suiciding" it and freezing hundreds of millions in ETH.

Implementing Secure Upgrade Patterns

Process overview

1

Use a Transparent Proxy Pattern

Separate logic and storage to prevent selector clashing.

Detailed Instructions

Deploy a Transparent Proxy contract that delegates all calls to a logic contract while maintaining its own storage. This prevents a critical vulnerability where a malicious actor could call an admin function on the proxy if their address matches the function selector of a public function in the logic contract. Use OpenZeppelin's TransparentUpgradeableProxy.

  • Sub-step 1: Deploy your initial logic contract (Implementation V1).
  • Sub-step 2: Deploy the TransparentUpgradeableProxy, passing the logic contract address and the initial admin address as constructor arguments.
  • Sub-step 3: Interact with your application using the proxy's address, not the logic contract's address.
solidity
// Import and deploy a TransparentUpgradeableProxy import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; address logicV1 = 0x...; address initialAdmin = msg.sender; bytes memory initData = ""; // Optional initialization call TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( logicV1, initialAdmin, initData );

Tip: The admin can upgrade the proxy but cannot call logic contract functions through the proxy, eliminating the selector clash risk.

2

Implement a Timelock Controller

Enforce a mandatory delay for all administrative actions.

Detailed Instructions

Integrate a Timelock Controller to govern your upgradeable proxy. This adds a mandatory waiting period between when an upgrade proposal is submitted and when it can be executed, giving users time to review changes or exit the system. Use OpenZeppelin's TimelockController contract.

  • Sub-step 1: Deploy a TimelockController with a minimum delay (e.g., 2 days). Assign proposer and executor roles.
  • Sub-step 2: Transfer the admin role of your TransparentUpgradeableProxy from an EOA to the Timelock contract address.
  • Sub-step 3: All upgrade proposals must now be scheduled through the Timelock, which queues them for future execution.
solidity
// Example: Scheduling an upgrade via Timelock bytes32 salt = keccak256("Upgrade to V2"); uint256 delay = timelock.getMinDelay(); bytes memory callData = abi.encodeWithSelector( ITransparentUpgradeableProxy.upgradeTo.selector, newLogicAddress ); timelock.schedule( proxyAddress, 0, // value callData, bytes32(0), // predecessor salt, delay );

Tip: The delay period is a critical security parameter; 48-72 hours is common for major protocol upgrades.

3

Establish a Robust Testing and Verification Pipeline

Ensure new logic is compatible and secure before deployment.

Detailed Instructions

Before any on-chain upgrade, you must rigorously test the new logic contract against the proxy's existing storage layout. A mismatch can lead to storage collisions and permanent data corruption. Use tools like OpenZeppelin Upgrades Plugins for Hardhat or Foundry.

  • Sub-step 1: Run the validateUpgrade function from the plugin, providing the old and new implementation addresses. This checks for storage layout compatibility.
  • Sub-step 2: Deploy the new logic contract to a testnet or local fork. Execute a full suite of integration tests that simulate user interactions through the proxy.
  • Sub-step 3: Verify the new contract's source code on a block explorer. Use the --via-ir optimizer setting if necessary to match deployed bytecode.
bash
# Example Hardhat command to validate an upgrade npx hardhat upgrade \ --proxy-address 0x1234... \ --new-implementation-address 0xabcd... \ --network goerli

Tip: Always test upgrades on a forked mainnet environment to catch integration issues with live data and external dependencies.

4

Execute the Upgrade via Governance

Perform the final upgrade operation through the secured process.

Detailed Instructions

With the new logic verified and the timelock delay elapsed, the upgrade can be executed. This is a privileged operation that must be called by the Timelock executor. The process atomically points the proxy to the new implementation contract.

  • Sub-step 1: After the timelock delay passes, any address with the executor role can call execute on the Timelock controller with the scheduled operation ID.
  • Sub-step 2: The Timelock contract will call upgradeTo(newImplementation) on the proxy. Verify the transaction succeeded and the proxy's implementation address updated.
  • Sub-step 3: Immediately conduct post-upgrade sanity checks. Call a non-state-changing view function on the proxy to confirm the new logic is active.
solidity
// Timelock executes the queued upgrade timelock.execute( proxyAddress, 0, callData, bytes32(0), // predecessor salt ); // Verify the upgrade (address currentImpl) = IERC1967(proxyAddress).implementation(); require(currentImpl == newLogicAddress, "Upgrade failed");

Tip: Have a prepared rollback script and a verified previous implementation version ready in case critical bugs are discovered post-upgrade.

5

Renounce Admin Control for Finalization

Optionally transfer admin rights to a decentralized entity or burn them.

Detailed Instructions

For maximum decentralization and to eliminate any central point of failure, consider renouncing the admin role. This makes the proxy's implementation immutable. This is a irreversible step, so ensure the code is thoroughly audited and the system is stable.

  • Sub-step 1: If using a Timelock, transfer the Timelock's admin role to a decentralized autonomous organization (DAO) or a multi-signature wallet controlled by community stakeholders.
  • Sub-step 2: As a stronger alternative, call changeAdmin(address(0)) on a UUPS (Universal Upgradeable Proxy Standard) proxy to burn the admin role entirely. For Transparent Proxies, you can transfer admin to the zero address.
  • Sub-step 3: Verify the admin change by attempting to call an admin-only function (like upgradeTo) from the old admin address; it should revert.
solidity
// For a UUPS-style upgradeable contract, the admin is part of the logic contract. // The admin can renounce in the logic contract's implementation. function renounceAdmin() public onlyAdmin { _setAdmin(address(0)); } // After renouncing, this will revert function attemptUpgrade() external { // This call will fail if admin is address(0) _upgradeTo(newImplementation); }

Tip: Renouncing admin control is a strong trust signal to users but removes your ability to patch future vulnerabilities. Weigh this decision carefully.

SECTION-FAQ

Frequently Asked Technical Questions

Ready to Start Building?

Let's bring your Web3 vision to life.

From concept to deployment, ChainScore helps you architect, build, and scale secure blockchain solutions.