Securite·8 min de lecture·Par Solingo

Delegatecall Vulnerabilities — Storage Collisions and Proxy Pitfalls

Understand how delegatecall works, why storage layout collisions occur in proxy patterns, and learn to implement safe upgradeable contracts using EIP-1967.

# Delegatecall Vulnerabilities — Storage Collisions and Proxy Pitfalls

delegatecall is one of Solidity's most powerful features — and one of its most dangerous. While it enables upgradeable contracts and advanced patterns, misuse can lead to catastrophic storage corruption or complete contract takeover.

In this article, we'll explore how delegatecall works under the hood, examine storage collision vulnerabilities, and learn to implement safe proxy patterns.

How Delegatecall Works

Normal Call vs Delegatecall

Normal call:

  • Executes code in the called contract's context
  • Uses called contract's storage
  • msg.sender = caller

delegatecall:

  • Executes code in the calling contract's context
  • Uses calling contract's storage
  • msg.sender = original caller (preserved)
  • msg.value preserved

Visual Example

Contract A:          Contract B:

storage[0] = 100 storage[0] = 200

storage[1] = 500 storage[1] = 300

A.call(B.setX(999)):

  • Executes B's code
  • Modifies B's storage
  • B.storage[0] = 999
  • A.storage unchanged

A.delegatecall(B.setX(999)):

  • Executes B's code
  • Modifies A's storage (context switch!)
  • A.storage[0] = 999
  • B.storage unchanged

The Storage Collision Vulnerability

The most dangerous aspect of delegatecall is that storage is accessed by slot position, not variable name.

Vulnerable Example

// Logic contract

contract Logic {

address public owner; // Slot 0

function setOwner(address _owner) external {

owner = _owner; // Writes to slot 0

}

}

// Proxy contract

contract Proxy {

address public implementation; // Slot 0

fallback() external payable {

address impl = implementation;

assembly {

calldatacopy(0, 0, calldatasize())

let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

returndatacopy(0, 0, returndatasize())

switch result

case 0 { revert(0, returndatasize()) }

default { return(0, returndatasize()) }

}

}

}

The Attack

// Deploy

Logic logic = new Logic();

Proxy proxy = new Proxy();

// User calls proxy.setOwner(attackerAddress)

// via fallback -> delegatecall to logic.setOwner()

// What happens:

// Logic.setOwner writes to slot 0 (owner variable)

// BUT executes in Proxy's context

// So writes to Proxy's slot 0 (implementation variable!)

// Result: attacker now controls implementation address

// Attacker can point to malicious contract

Impact: Complete contract takeover. Attacker can steal all funds.

Storage Layout Rules

Solidity assigns storage slots sequentially:

contract Example {

uint256 a; // Slot 0

uint256 b; // Slot 1

address c; // Slot 2

bool d; // Slot 3 (packed with e if possible)

uint8 e; // Slot 3

}

Rule: For delegatecall to work safely, storage layout must match exactly between proxy and logic contracts.

Safe Proxy Patterns

1. EIP-1967 Transparent Proxy Pattern

Solution: Store proxy-specific variables in reserved slots far from normal storage.

// Proxy contract

contract TransparentProxy {

// Storage slot with the address of the current implementation

// keccak256("eip1967.proxy.implementation") - 1

bytes32 private constant IMPLEMENTATION_SLOT =

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

// keccak256("eip1967.proxy.admin") - 1

bytes32 private constant ADMIN_SLOT =

0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

constructor(address _logic, address _admin) {

_setImplementation(_logic);

_setAdmin(_admin);

}

function _setImplementation(address newImplementation) private {

bytes32 slot = IMPLEMENTATION_SLOT;

assembly {

sstore(slot, newImplementation)

}

}

function _setAdmin(address newAdmin) private {

bytes32 slot = ADMIN_SLOT;

assembly {

sstore(slot, newAdmin)

}

}

function _implementation() internal view returns (address impl) {

bytes32 slot = IMPLEMENTATION_SLOT;

assembly {

impl := sload(slot)

}

}

fallback() external payable {

address impl = _implementation();

assembly {

calldatacopy(0, 0, calldatasize())

let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

returndatacopy(0, 0, returndatasize())

switch result

case 0 { revert(0, returndatasize()) }

default { return(0, returndatasize()) }

}

}

}

Why it works:

  • Proxy variables stored in slots like 0x360894a13ba... (extremely high slot number)
  • Logic contract uses normal slots (0, 1, 2, ...)
  • No collision possible

2. Universal Upgradeable Proxy Standard (UUPS)

Upgrade logic is in the implementation contract, not the proxy.

// Minimal proxy

contract UUPSProxy {

bytes32 private constant IMPLEMENTATION_SLOT =

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

constructor(address _implementation) {

assembly {

sstore(IMPLEMENTATION_SLOT, _implementation)

}

}

fallback() external payable {

assembly {

let impl := sload(IMPLEMENTATION_SLOT)

calldatacopy(0, 0, calldatasize())

let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

returndatacopy(0, 0, returndatasize())

switch result

case 0 { revert(0, returndatasize()) }

default { return(0, returndatasize()) }

}

}

}

// Implementation with upgrade logic

contract UUPSImplementation {

bytes32 private constant IMPLEMENTATION_SLOT =

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

address public owner; // Regular storage

modifier onlyOwner() {

require(msg.sender == owner);

_;

}

function upgradeTo(address newImplementation) external onlyOwner {

assembly {

sstore(IMPLEMENTATION_SLOT, newImplementation)

}

}

}

Benefits:

  • Simpler proxy (no upgrade logic)
  • Cheaper deployment
  • Upgrade authorization in implementation

Drawback: If implementation has bug in upgrade logic, contract is stuck.

3. Storage Gap Pattern

For implementation contracts, reserve storage for future versions.

contract ImplementationV1 {

address public owner; // Slot 0

uint256 public totalSupply; // Slot 1

// Reserve 50 slots for future variables

uint256[50] private __gap;

}

contract ImplementationV2 {

address public owner; // Slot 0 (preserved)

uint256 public totalSupply; // Slot 1 (preserved)

// New variable uses gap space

uint256 public newFeature; // Slot 2

// Gap reduced by 1

uint256[49] private __gap;

}

Why: Ensures future variables don't shift existing storage layout.

Common Vulnerabilities

1. Uninitialized Proxy

contract Logic {

address public owner;

constructor() {

owner = msg.sender; // Only runs in logic contract, NOT proxy

}

}

Fix: Use initialize() function instead of constructor.

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Logic is Initializable {

address public owner;

function initialize() public initializer {

owner = msg.sender; // Runs in proxy context

}

}

2. Selfdestruct in Logic

contract Logic {

function destroy() external {

selfdestruct(payable(msg.sender)); // DESTROYS LOGIC CONTRACT

}

}

If logic contract is destroyed, all proxies pointing to it become unusable.

Fix: Never use selfdestruct in logic contracts. Use Pausable pattern instead.

3. Storage Layout Mismatch

// V1

contract LogicV1 {

uint256 public a;

uint256 public b;

}

// V2 - BAD

contract LogicV2 {

uint256 public b; // MOVED TO SLOT 0

uint256 public a; // MOVED TO SLOT 1

// Data is now corrupted

}

// V2 - GOOD

contract LogicV2 {

uint256 public a; // Still slot 0

uint256 public b; // Still slot 1

uint256 public c; // New slot 2

}

4. Function Selector Collisions

// Proxy has admin function

contract Proxy {

function upgradeTo(address newImpl) external { /*...*/ }

}

// Logic also has function with same selector (extremely rare but possible)

contract Logic {

function upgradeTo(address user) external { /*...*/ }

}

Fix: Use TransparentProxy pattern where admin calls are separated.

OpenZeppelin Implementation

The safest approach is using battle-tested libraries:

// Proxy

import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

// Implementation

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyTokenV1 is Initializable, OwnableUpgradeable {

uint256 public totalSupply;

mapping(address => uint256) public balances;

function initialize() public initializer {

__Ownable_init();

totalSupply = 1000000;

}

function transfer(address to, uint256 amount) external {

balances[msg.sender] -= amount;

balances[to] += amount;

}

// Storage gap for future versions

uint256[50] private __gap;

}

Deployment:

// Deploy implementation

MyTokenV1 implementation = new MyTokenV1();

// Deploy proxy

TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

address(implementation),

admin, // ProxyAdmin address

abi.encodeWithSignature("initialize()")

);

// Interact via proxy

MyTokenV1 token = MyTokenV1(address(proxy));

Testing Upgradeable Contracts

// Foundry test

function testUpgrade() public {

// Deploy V1

MyTokenV1 implV1 = new MyTokenV1();

TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

address(implV1),

admin,

""

);

MyTokenV1 token = MyTokenV1(address(proxy));

token.initialize();

// Verify V1 behavior

assertEq(token.totalSupply(), 1000000);

// Deploy V2

MyTokenV2 implV2 = new MyTokenV2();

// Upgrade

vm.prank(admin);

ProxyAdmin(proxyAdmin).upgrade(proxy, address(implV2));

// Verify V2 behavior

MyTokenV2 tokenV2 = MyTokenV2(address(proxy));

assertEq(tokenV2.totalSupply(), 1000000); // Preserved

assertEq(tokenV2.newFeature(), 0); // New variable

}

Best Practices

Use established patterns — EIP-1967, UUPS, or OpenZeppelin

Never reorder storage variables — always append

Use storage gaps — reserve slots for future versions

Initialize, don't construct — constructors don't run in proxy context

Avoid selfdestruct — it destroys logic for all proxies

Test upgrades — verify storage preservation

Document storage layout — make it explicit

Use Initializable — prevent double-initialization

Audit before upgrade — mistakes are permanent

Conclusion

delegatecall is a double-edged sword. It enables powerful patterns like upgradeability, but demands extreme care with storage layout.

Key takeaways:

  • delegatecall executes code in caller's context (storage, msg.sender, msg.value)
  • Storage is accessed by slot position, not variable name
  • Misaligned storage = data corruption or takeover
  • Use EIP-1967 or UUPS patterns with reserved storage slots
  • Never reorder variables in upgrades
  • Use OpenZeppelin's battle-tested implementations

Master proxy patterns on Solingo — our interactive exercises include storage collision simulations, upgrade scenarios, and automated checks to help you build secure upgradeable contracts.

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement