# 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.valuepreserved
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:
delegatecallexecutes 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.