# 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.