# 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.valuepré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 :
delegatecallexé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.