Sécurité·9 min de lecture·Par Solingo

Collisions de Storage Slots dans les Contracts Upgradeable

Les contracts upgradeable partagent le storage avec le proxy. Layout mal fait = fonds perdus.

# Collisions de Storage Slots dans les Contracts Upgradeable

Les proxies = superpuissance. Mais un layout storage cassé peut effacer votre TVL.

Le Modèle : Delegatecall

// Proxy (déployé une fois, adresse fixe)

contract Proxy {

address public implementation; // slot 0

fallback() external payable {

_delegate(implementation);

}

function _delegate(address impl) internal {

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()) }

}

}

}

// Implementation V1

contract TokenV1 {

mapping(address => uint256) public balances; // slot 0 (!)

function transfer(address to, uint256 amount) external {

balances[msg.sender] -= amount;

balances[to] += amount;

}

}

Problème : Proxy.implementation (slot 0) et TokenV1.balances (slot 0) se chevauchent.

// User call : proxy.transfer(alice, 100)

// Via delegatecall, code s'exécute dans Proxy storage

// balances[msg.sender] -= 100

// → écrit dans slot 0

// → overwrite Proxy.implementation !

// Résultat : implementation address = garbage

// Proxy bricked, fonds inaccessibles

Slots Standards : EIP-1967

Pour éviter collision, réserver des slots spécifiques.

// EIP-1967 : storage slots calculés via keccak256

contract ERC1967Proxy {

// bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)

bytes32 private constant IMPLEMENTATION_SLOT =

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

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

assembly {

impl := sload(IMPLEMENTATION_SLOT)

}

}

function _setImplementation(address newImpl) internal {

assembly {

sstore(IMPLEMENTATION_SLOT, newImpl)

}

}

}

// Maintenant implementation est à un slot improbable

// TokenV1 peut utiliser les slots 0, 1, 2... sans risque

Slots réservés EIP-1967 :

  • Implementation : keccak256("eip1967.proxy.implementation") - 1
  • Admin : keccak256("eip1967.proxy.admin") - 1
  • Beacon : keccak256("eip1967.proxy.beacon") - 1

Exemple de Collision : Upgrade Cassé

// V1 : 2 variables

contract TokenV1 {

uint256 public totalSupply; // slot 0

address public owner; // slot 1

}

// V2 : on ajoute une variable... AU DÉBUT (❌ ERREUR)

contract TokenV2 {

bool public paused; // slot 0 (!)

uint256 public totalSupply; // slot 1

address public owner; // slot 2

}

// Après upgrade :

// - totalSupply (était slot 0) → maintenant slot 1

// - Storage slot 0 (contenait totalSupply) → interprété comme paused

// - Si totalSupply = 1000000, paused = true (car non-zero)

// - owner (était slot 1) → maintenant slot 2 (= 0, perdu !)

Règle d'or : JAMAIS réordonner/supprimer des variables lors d'un upgrade. Seulement append.

Pattern : Unstructured Storage

OpenZeppelin pattern pour éviter collisions.

abstract contract Initializable {

// Slot calculé (pas de collision possible)

bytes32 private constant INITIALIZED_SLOT = keccak256("openzeppelin.initializable");

modifier initializer() {

bool initialized;

assembly {

initialized := sload(INITIALIZED_SLOT)

}

require(!initialized, "Already initialized");

_;

assembly {

sstore(INITIALIZED_SLOT, true)

}

}

}

contract TokenV1 is Initializable {

uint256 public totalSupply;

function initialize(uint256 _totalSupply) external initializer {

totalSupply = _totalSupply;

}

}

Le flag initialized est à un slot calculé → jamais de collision avec les vars normales.

Pattern : Diamond Storage (ERC-7201)

Pour libraries ou facets (Diamond pattern).

// Chaque facet/library a son propre namespace

library CounterLib {

// Namespace unique

bytes32 constant COUNTER_STORAGE_POSITION = keccak256("my.app.counter");

struct CounterStorage {

uint256 count;

address admin;

}

function getStorage() internal pure returns (CounterStorage storage s) {

bytes32 position = COUNTER_STORAGE_POSITION;

assembly {

s.slot := position

}

}

function increment() internal {

CounterStorage storage s = getStorage();

s.count++;

}

}

// Usage

contract Facet {

function increment() external {

CounterLib.increment();

}

function getCount() external view returns (uint256) {

return CounterLib.getStorage().count;

}

}

Chaque library a son storage isolé. Pas de collision entre facets.

Outils de Détection

1. Foundry : vm.load

// Test : vérifier que implementation est au bon slot

function testImplementationSlot() public {

bytes32 slot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

address impl = address(uint160(uint256(vm.load(address(proxy), slot))));

assertEq(impl, address(tokenV1));

}

2. OpenZeppelin : Storage Layout Hash

# Générer layout avant upgrade

npx hardhat storage-layout TokenV1 > layout-v1.json

# Après upgrade, comparer

npx hardhat storage-layout TokenV2 > layout-v2.json

diff layout-v1.json layout-v2.json

# Output doit montrer seulement APPEND, pas de changes

3. Slither

slither . --detect uninitialized-storage,storage-array

# Output :

# TokenV2.paused (slot 0) collides with TokenV1.totalSupply (slot 0)

Incidents Réels

Audius (2022)

// Proxy admin collision

// Attacker overwrote admin via storage collision

// → prit contrôle du proxy

// → upgraded vers malicious implementation

// → drainé $6M

Cause : admin pas à un slot EIP-1967, collision avec state var.

Furucombo (2021)

// Implementation slot overwritten

// Attacker appelé une fonction qui écrivait dans slot 0

// → implementation pointait vers attacker contract

// → attacker upgraded logic

// → $15M perdus

Checklist Sécurité Upgrades

  • [ ] Proxy utilise EIP-1967 slots (implementation, admin, beacon)
  • [ ] Jamais de réordonnancement de variables
  • [ ] Seulement append de nouvelles variables
  • [ ] Storage layout comparé avant/après (hardhat storage-layout)
  • [ ] Tests vérifiant les slots critiques (vm.load)
  • [ ] Slither run (--detect storage)
  • [ ] Gap réservé pour futures vars (uint256[50] __gap;)

Pattern : Storage Gap

// V1 : réserver de la place pour futures vars

contract TokenV1 {

uint256 public totalSupply;

address public owner;

// Gap : 48 slots réservés

uint256[48] private __gap;

}

// V2 : consommer le gap

contract TokenV2 is TokenV1 {

bool public paused; // Utilise __gap[0]

uint256[47] private __gap; // Gap réduit

}

// Layout :

// slot 0 : totalSupply

// slot 1 : owner

// slot 2 : paused (était __gap[0])

// slot 3-49 : __gap[1-47]

Le gap permet d'ajouter vars sans casser le layout existant.

Exemple Complet : Upgrade Safe

// V1

contract VaultV1 {

uint256 public totalDeposits;

mapping(address => uint256) public deposits;

uint256[48] private __gap; // Reserve

}

// V2 : ajout fee tracking

contract VaultV2 is VaultV1 {

uint256 public totalFees; // Utilise __gap[0]

mapping(address => uint256) public fees; // Utilise __gap[1]

uint256[46] private __gap; // Gap réduit

}

// Test

function testUpgradeSafety() public {

// Deploy V1

VaultV1 v1 = new VaultV1();

v1.deposit{value: 100 ether}();

uint256 depositsBefore = v1.deposits(address(this));

// Upgrade to V2

proxy.upgradeTo(address(new VaultV2()));

VaultV2 v2 = VaultV2(address(proxy));

// Verify storage intact

assertEq(v2.deposits(address(this)), depositsBefore);

assertEq(v2.totalDeposits(), 100 ether);

}

Verdict

Les storage collisions = bug silencieux. Pas de revert, juste de la data corrompue.

Utilisez EIP-1967, jamais réordonner, toujours tester. Un upgrade cassé = fin du protocol.

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement