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

Vulnérabilités Delegatecall — Collisions de Storage et Pièges des Proxies

Comprenez comment fonctionne delegatecall, pourquoi les collisions de layout de storage se produisent dans les patterns de proxy, et apprenez à implémenter des contrats upgradeables sécurisés en utilisant EIP-1967.

# Vulnérabilités Delegatecall — Collisions de Storage et Pièges des Proxies

delegatecall est l'une des fonctionnalités les plus puissantes de Solidity — et l'une des plus dangereuses. Bien qu'il permette des contrats upgradeables et des patterns avancés, une mauvaise utilisation peut mener à une corruption de storage catastrophique ou à une prise de contrôle complète du contrat.

Dans cet article, nous allons explorer comment delegatecall fonctionne sous le capot, examiner les vulnérabilités de collision de storage, et apprendre à implémenter des patterns de proxy sécurisés.

Comment Fonctionne Delegatecall

Call Normal vs Delegatecall

call normal :

  • Exécute le code dans le contexte du contrat appelé
  • Utilise le storage du contrat appelé
  • msg.sender = appelant

delegatecall :

  • Exécute le code dans le contexte du contrat appelant
  • Utilise le storage du contrat appelant
  • msg.sender = appelant original (préservé)
  • msg.value préservé

Exemple Visuel

Contrat A:          Contrat B:

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

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

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

  • Exécute le code de B
  • Modifie le storage de B
  • B.storage[0] = 999
  • A.storage inchangé

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

  • Exécute le code de B
  • Modifie le storage de A (switch de contexte !)
  • A.storage[0] = 999
  • B.storage inchangé

La Vulnérabilité de Collision de Storage

L'aspect le plus dangereux de delegatecall est que le storage est accédé par position de slot, pas par nom de variable.

Exemple Vulnérable

// Contrat Logic

contract Logic {

address public owner; // Slot 0

function setOwner(address _owner) external {

owner = _owner; // Écrit dans le slot 0

}

}

// Contrat Proxy

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

}

}

}

L'Attaque

// Déploiement

Logic logic = new Logic();

Proxy proxy = new Proxy();

// L'utilisateur appelle proxy.setOwner(attackerAddress)

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

// Ce qui se passe :

// Logic.setOwner écrit dans le slot 0 (variable owner)

// MAIS s'exécute dans le contexte de Proxy

// Donc écrit dans le slot 0 de Proxy (variable implementation !)

// Résultat : l'attaquant contrôle maintenant l'adresse implementation

// L'attaquant peut pointer vers un contrat malveillant

Impact : Prise de contrôle complète du contrat. L'attaquant peut voler tous les fonds.

Règles de Layout de Storage

Solidity assigne les slots de storage séquentiellement :

contract Example {

uint256 a; // Slot 0

uint256 b; // Slot 1

address c; // Slot 2

bool d; // Slot 3 (packé avec e si possible)

uint8 e; // Slot 3

}

Règle : Pour que delegatecall fonctionne en sécurité, le layout de storage doit correspondre exactement entre les contrats proxy et logic.

Patterns de Proxy Sécurisés

1. EIP-1967 Transparent Proxy Pattern

Solution : Stocker les variables spécifiques au proxy dans des slots réservés loin du storage normal.

// Contrat Proxy

contract TransparentProxy {

// Slot de storage avec l'adresse de l'implémentation actuelle

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

}

}

}

Pourquoi ça marche :

  • Les variables de proxy stockées dans des slots comme 0x360894a13ba... (numéro de slot extrêmement élevé)
  • Le contrat logic utilise les slots normaux (0, 1, 2, ...)
  • Pas de collision possible

2. Universal Upgradeable Proxy Standard (UUPS)

La logique d'upgrade est dans le contrat d'implémentation, pas le proxy.

// Proxy minimal

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

}

}

}

// Implémentation avec logique d'upgrade

contract UUPSImplementation {

bytes32 private constant IMPLEMENTATION_SLOT =

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

address public owner; // Storage régulier

modifier onlyOwner() {

require(msg.sender == owner);

_;

}

function upgradeTo(address newImplementation) external onlyOwner {

assembly {

sstore(IMPLEMENTATION_SLOT, newImplementation)

}

}

}

Avantages :

  • Proxy plus simple (pas de logique d'upgrade)
  • Déploiement moins cher
  • Autorisation d'upgrade dans l'implémentation

Inconvénient : Si l'implémentation a un bug dans la logique d'upgrade, le contrat est bloqué.

3. Pattern Storage Gap

Pour les contrats d'implémentation, réserver du storage pour les versions futures.

contract ImplementationV1 {

address public owner; // Slot 0

uint256 public totalSupply; // Slot 1

// Réserver 50 slots pour variables futures

uint256[50] private __gap;

}

contract ImplementationV2 {

address public owner; // Slot 0 (préservé)

uint256 public totalSupply; // Slot 1 (préservé)

// Nouvelle variable utilise l'espace gap

uint256 public newFeature; // Slot 2

// Gap réduit de 1

uint256[49] private __gap;

}

Pourquoi : Assure que les variables futures ne décalent pas le layout de storage existant.

Vulnérabilités Courantes

1. Proxy Non Initialisé

contract Logic {

address public owner;

constructor() {

owner = msg.sender; // S'exécute seulement dans le contrat logic, PAS le proxy

}

}

Correction : Utiliser une fonction initialize() au lieu du constructor.

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

contract Logic is Initializable {

address public owner;

function initialize() public initializer {

owner = msg.sender; // S'exécute dans le contexte du proxy

}

}

2. Selfdestruct dans Logic

contract Logic {

function destroy() external {

selfdestruct(payable(msg.sender)); // DÉTRUIT LE CONTRAT LOGIC

}

}

Si le contrat logic est détruit, tous les proxies pointant vers lui deviennent inutilisables.

Correction : Ne jamais utiliser selfdestruct dans les contrats logic. Utiliser le pattern Pausable à la place.

3. Mismatch de Layout de Storage

// V1

contract LogicV1 {

uint256 public a;

uint256 public b;

}

// V2 - MAUVAIS

contract LogicV2 {

uint256 public b; // DÉPLACÉ AU SLOT 0

uint256 public a; // DÉPLACÉ AU SLOT 1

// Les données sont maintenant corrompues

}

// V2 - BON

contract LogicV2 {

uint256 public a; // Toujours slot 0

uint256 public b; // Toujours slot 1

uint256 public c; // Nouveau slot 2

}

4. Collisions de Sélecteur de Fonction

// Proxy a fonction admin

contract Proxy {

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

}

// Logic a aussi fonction avec même sélecteur (extrêmement rare mais possible)

contract Logic {

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

}

Correction : Utiliser le pattern TransparentProxy où les appels admin sont séparés.

Implémentation OpenZeppelin

L'approche la plus sûre est d'utiliser des bibliothèques battle-tested :

// Proxy

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

// Implémentation

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;

}

// Gap de storage pour versions futures

uint256[50] private __gap;

}

Déploiement :

// Déployer implémentation

MyTokenV1 implementation = new MyTokenV1();

// Déployer proxy

TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

address(implementation),

admin, // Adresse ProxyAdmin

abi.encodeWithSignature("initialize()")

);

// Interagir via proxy

MyTokenV1 token = MyTokenV1(address(proxy));

Tester les Contrats Upgradeables

// Test Foundry

function testUpgrade() public {

// Déployer V1

MyTokenV1 implV1 = new MyTokenV1();

TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

address(implV1),

admin,

""

);

MyTokenV1 token = MyTokenV1(address(proxy));

token.initialize();

// Vérifier comportement V1

assertEq(token.totalSupply(), 1000000);

// Déployer V2

MyTokenV2 implV2 = new MyTokenV2();

// Upgrade

vm.prank(admin);

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

// Vérifier comportement V2

MyTokenV2 tokenV2 = MyTokenV2(address(proxy));

assertEq(tokenV2.totalSupply(), 1000000); // Préservé

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

}

Bonnes Pratiques

Utiliser patterns établis — EIP-1967, UUPS, ou OpenZeppelin

Ne jamais réordonner les variables de storage — toujours ajouter à la fin

Utiliser des gaps de storage — réserver slots pour versions futures

Initialiser, ne pas construire — les constructeurs ne s'exécutent pas dans le contexte du proxy

Éviter selfdestruct — ça détruit la logique pour tous les proxies

Tester les upgrades — vérifier la préservation du storage

Documenter le layout de storage — le rendre explicite

Utiliser Initializable — empêcher la double-initialisation

Auditer avant upgrade — les erreurs sont permanentes

Conclusion

delegatecall est une arme à double tranchant. Il permet des patterns puissants comme l'upgradeabilité, mais exige un soin extrême avec le layout de storage.

Points clés à retenir :

  • delegatecall exécute le code dans le contexte de l'appelant (storage, msg.sender, msg.value)
  • Le storage est accédé par position de slot, pas par nom de variable
  • Storage mal aligné = corruption de données ou prise de contrôle
  • Utiliser les patterns EIP-1967 ou UUPS avec slots de storage réservés
  • Ne jamais réordonner les variables dans les upgrades
  • Utiliser les implémentations battle-tested d'OpenZeppelin

Maîtrisez les patterns de proxy sur Solingo — nos exercices interactifs incluent des simulations de collision de storage, des scénarios d'upgrade, et des vérifications automatisées pour vous aider à construire des contrats upgradeables sécurisés.

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement