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

Pièges de l'Access Control — Au-delà de onlyOwner

onlyOwner n'est que le début. Découvrez l'accès basé sur les rôles, les patterns multi-sig et les erreurs courantes.

# Pièges de l'Access Control — Au-delà de onlyOwner

Le modifier onlyOwner est le premier pattern de contrôle d'accès que tout développeur Solidity apprend. Mais c'est aussi l'un des plus dangereux en production.

Le Problème du Single Point of Failure

// ❌ Dangereux en production

contract Token {

address public owner;

modifier onlyOwner() {

require(msg.sender == owner, "Not owner");

_;

}

function mint(address to, uint256 amount) external onlyOwner {

_mint(to, amount);

}

}

Risques :

  • Clé privée compromise → perte totale du contrôle
  • Clé perdue → contrat gelé pour toujours
  • Insider attack → l'owner peut tout faire
  • Pas de séparation des privilèges

Solution 1 : Role-Based Access Control (RBAC)

OpenZeppelin AccessControl permet de définir des rôles granulaires :

import "@openzeppelin/contracts/access/AccessControl.sol";

contract VaultWithRoles is AccessControl {

bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");

bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");

bool public paused;

constructor() {

_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

_grantRole(PAUSER_ROLE, msg.sender);

}

function withdraw(uint256 amount) external onlyRole(WITHDRAWER_ROLE) {

require(!paused, "Paused");

// logique de retrait

}

function pause() external onlyRole(PAUSER_ROLE) {

paused = true;

}

function grantWithdrawer(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {

grantRole(WITHDRAWER_ROLE, account);

}

}

Avantages :

  • Séparation des privilèges (withdrawal ≠ pause ≠ upgrade)
  • Plusieurs adresses par rôle
  • Révocation granulaire

Solution 2 : Two-Step Ownership Transfer

Ne jamais transférer l'ownership en une seule transaction :

contract SafeOwnable {

address public owner;

address public pendingOwner;

event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

modifier onlyOwner() {

require(msg.sender == owner, "Not owner");

_;

}

function transferOwnership(address newOwner) external onlyOwner {

require(newOwner != address(0), "Invalid address");

pendingOwner = newOwner;

}

function acceptOwnership() external {

require(msg.sender == pendingOwner, "Not pending owner");

address oldOwner = owner;

owner = pendingOwner;

pendingOwner = address(0);

emit OwnershipTransferred(oldOwner, owner);

}

}

Protection : évite les typos fatales (0x123... au lieu de 0x124...).

Solution 3 : Multisig via Gnosis Safe

Pour les contrats critiques, utilisez un multisig 3-of-5 ou 4-of-7 :

contract Treasury {

address public gnosisSafe; // 4-of-7 multisig

modifier onlyGovernance() {

require(msg.sender == gnosisSafe, "Not governance");

_;

}

function emergencyWithdraw(address token, uint256 amount)

external

onlyGovernance

{

// Nécessite 4 signatures sur 7

IERC20(token).transfer(gnosisSafe, amount);

}

}

Solution 4 : Timelock pour les Actions Critiques

Ajoutez un délai entre proposition et exécution :

contract TimelockController {

mapping(bytes32 => uint256) public queuedTransactions;

uint256 public constant DELAY = 2 days;

event TransactionQueued(bytes32 indexed txHash, uint256 executeTime);

function queueTransaction(

address target,

bytes memory data

) external onlyOwner returns (bytes32) {

bytes32 txHash = keccak256(abi.encode(target, data, block.timestamp));

queuedTransactions[txHash] = block.timestamp + DELAY;

emit TransactionQueued(txHash, queuedTransactions[txHash]);

return txHash;

}

function executeTransaction(

address target,

bytes memory data,

uint256 queuedAt

) external onlyOwner {

bytes32 txHash = keccak256(abi.encode(target, data, queuedAt));

uint256 executeTime = queuedTransactions[txHash];

require(executeTime != 0, "Not queued");

require(block.timestamp >= executeTime, "Timelock not expired");

delete queuedTransactions[txHash];

(bool success,) = target.call(data);

require(success, "Execution failed");

}

}

Avantage : la communauté a 48h pour réagir à une action malveillante.

Pièges Courants à Éviter

1. Oublier de Retirer les Droits du Déployer

// ❌ Le déployer garde tous les rôles !

constructor() {

_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

_grantRole(MINTER_ROLE, governance);

}

// ✅ Révocation explicite

constructor(address governance) {

_grantRole(DEFAULT_ADMIN_ROLE, governance);

_grantRole(MINTER_ROLE, governance);

renounceRole(DEFAULT_ADMIN_ROLE, msg.sender); // ⚠️ Irréversible !

}

2. Missing Access Control sur les Fonctions Critiques

// ❌ Oubli du modifier → n'importe qui peut mint

function mint(address to, uint256 amount) external {

_mint(to, amount);

}

Utilisez Slither :

slither . --detect missing-zero-check,unprotected-upgrade

3. Pas d'Events sur les Changements de Rôles

// ✅ Toujours émettre des events

function grantRole(bytes32 role, address account) public override {

super.grantRole(role, account);

emit RoleGranted(role, account, msg.sender);

}

Les events permettent le monitoring off-chain (Tenderly, Forta).

Best Practices en Production

  • Démarrer avec un multisig : jamais un EOA seul en production
  • Ajouter un timelock : minimum 24-48h pour les actions critiques
  • Séparer les rôles : PAUSER ≠ UPGRADER ≠ WITHDRAWER
  • Tester la révocation : vérifiez que renounceRole fonctionne
  • Monitorer les events : alertes en temps réel sur les changements de rôles
  • Documentation claire : qui a quel rôle et pourquoi
  • Exemple Complet : DeFi Protocol

    contract DeFiProtocol is AccessControl {
    

    bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE");

    bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");

    bytes32 public constant STRATEGIST_ROLE = keccak256("STRATEGIST_ROLE");

    address public immutable timelockController;

    bool public paused;

    constructor(address _timelock, address[] memory guardians) {

    timelockController = _timelock;

    // Le timelock a le rôle admin

    _grantRole(DEFAULT_ADMIN_ROLE, _timelock);

    // 3 guardians peuvent pauser en cas d'urgence

    for (uint256 i = 0; i < guardians.length; i++) {

    _grantRole(GUARDIAN_ROLE, guardians[i]);

    }

    // Le déployer n'a aucun rôle permanent

    }

    function emergencyPause() external onlyRole(GUARDIAN_ROLE) {

    paused = true;

    emit EmergencyPause(msg.sender);

    }

    function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {

    // Nécessite une transaction timelock (48h de délai)

    paused = false;

    }

    }

    Conclusion

    onlyOwner est un point de départ pour le prototypage, mais pas une solution de production. Adoptez RBAC + multisig + timelock pour sécuriser vos contrats critiques.

    Ressources :

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement