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

Contrôle d'Accès en Solidity — Patterns et Bonnes Pratiques

Des modificateurs simples aux systèmes RBAC complexes : apprenez à sécuriser vos fonctions de smart contract et éviter les exploits de permissions non autorisées.

# Contrôle d'Accès en Solidity — Patterns et Bonnes Pratiques

Le contrôle d'accès mal implémenté est l'une des vulnérabilités les plus courantes et les plus coûteuses en développement de smart contracts. Des fonctions d'administration exposées, des vérifications de permissions manquantes, ou une logique de rôle défectueuse peuvent permettre aux attaquants de drainer des fonds, de prendre le contrôle de contrats, ou de manipuler l'état critique.

Dans ce guide, nous allons explorer les patterns de contrôle d'accès — du simple onlyOwner aux systèmes RBAC (Role-Based Access Control) sophistiqués — et apprendre à les implémenter de manière sécurisée.

Pourquoi le Contrôle d'Accès est Important

Les smart contracts sont immuables et gèrent souvent des actifs de grande valeur. Sans contrôle d'accès approprié :

  • N'importe qui peut appeler des fonctions d'administration
  • Les attaquants peuvent modifier les paramètres critiques
  • Les fonds peuvent être retirés par des parties non autorisées
  • La logique métier peut être contournée

Cas réel : Le hack Parity Wallet (2017) a gelé $150M en ETH parce qu'une fonction initWallet non protégée permettait à quiconque de devenir propriétaire.

Pattern 1 : Modificateur Ownable Simple

Le pattern le plus basique : un seul propriétaire avec des privilèges administratifs.

contract Ownable {

address public owner;

constructor() {

owner = msg.sender; // Déployeur devient propriétaire

}

modifier onlyOwner() {

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

_;

}

function transferOwnership(address newOwner) public onlyOwner {

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

owner = newOwner;

}

}

contract MyContract is Ownable {

uint256 public criticalValue;

function setCriticalValue(uint256 _value) external onlyOwner {

criticalValue = _value;

}

}

Implémentation OpenZeppelin Ownable

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

contract MyContract is Ownable {

// Le constructeur d'Ownable définit msg.sender comme propriétaire

function adminFunction() external onlyOwner {

// Seul le propriétaire peut appeler ceci

}

}

Améliorations d'OpenZeppelin :

  • Transfert de propriété en deux étapes (Ownable2Step)
  • Renonciation à la propriété
  • Events pour les changements de propriétaire
import "@openzeppelin/contracts/access/Ownable2Step.sol";

contract SecureContract is Ownable2Step {

function transferOwnership(address newOwner) public override onlyOwner {

_transferOwnership(newOwner); // Nécessite acceptation du nouveau propriétaire

}

function renounceOwnership() public override onlyOwner {

_transferOwnership(address(0)); // Supprime le propriétaire

}

}

Pattern 2 : Control d'Accès Basé sur les Rôles (RBAC)

Pour des systèmes plus complexes, plusieurs rôles avec différentes permissions.

Implémentation Manuelle Basique

contract RoleBasedAccess {

mapping(address => bool) public admins;

mapping(address => bool) public moderators;

address public owner;

constructor() {

owner = msg.sender;

admins[msg.sender] = true;

}

modifier onlyOwner() {

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

_;

}

modifier onlyAdmin() {

require(admins[msg.sender], "Not admin");

_;

}

modifier onlyModerator() {

require(moderators[msg.sender], "Not moderator");

_;

}

function addAdmin(address _admin) external onlyOwner {

admins[_admin] = true;

}

function addModerator(address _mod) external onlyAdmin {

moderators[_mod] = true;

}

function adminFunction() external onlyAdmin {

// Logique admin

}

function modFunction() external onlyModerator {

// Logique modérateur

}

}

OpenZeppelin AccessControl

La bibliothèque AccessControl d'OpenZeppelin fournit un système RBAC robuste.

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

contract AdvancedDAO is AccessControl {

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

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

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

constructor() {

// Le déployeur obtient le rôle admin par défaut

_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

_grantRole(ADMIN_ROLE, msg.sender);

// Définir la hiérarchie des rôles

_setRoleAdmin(MODERATOR_ROLE, ADMIN_ROLE);

_setRoleAdmin(VOTER_ROLE, MODERATOR_ROLE);

}

function createProposal(string memory description)

external

onlyRole(VOTER_ROLE)

{

// Logique de création de proposition

}

function moderateContent(uint256 proposalId)

external

onlyRole(MODERATOR_ROLE)

{

// Logique de modération

}

function setSystemParameter(uint256 value)

external

onlyRole(ADMIN_ROLE)

{

// Logique admin

}

function grantVoterRole(address account) external {

grantRole(VOTER_ROLE, account); // Vérifie automatiquement que msg.sender a MODERATOR_ROLE

}

}

Fonctionnalités clés :

  • grantRole(bytes32 role, address account) : Assigner un rôle
  • revokeRole(bytes32 role, address account) : Révoquer un rôle
  • hasRole(bytes32 role, address account) : Vérifier un rôle
  • renounceRole(bytes32 role, address account) : Renoncer à un rôle
  • _setRoleAdmin(bytes32 role, bytes32 adminRole) : Hiérarchie de rôles

Pattern 3 : Liste Blanche (Allowlist)

Restreindre l'accès à un ensemble spécifique d'adresses.

contract Allowlist {

mapping(address => bool) public allowedAddresses;

modifier onlyAllowed() {

require(allowedAddresses[msg.sender], "Not allowed");

_;

}

function addToAllowlist(address _address) external {

// Logique de contrôle d'accès pour ajouter

allowedAddresses[_address] = true;

}

function removeFromAllowlist(address _address) external {

allowedAddresses[_address] = false;

}

function restrictedFunction() external onlyAllowed {

// Seules les adresses autorisées peuvent appeler

}

}

Cas d'usage :

  • Lancements privés / accès early
  • Programmes KYC/AML
  • Accès restreint aux partenaires

Pattern 4 : Timelock / Delay d'Exécution

Imposer un délai entre la proposition d'une action admin et son exécution.

contract TimelockController {

uint256 public constant DELAY = 2 days;

struct PendingAction {

uint256 executeAfter;

bool executed;

}

mapping(bytes32 => PendingAction) public pendingActions;

function proposeAction(bytes32 actionId) external onlyOwner {

require(pendingActions[actionId].executeAfter == 0, "Already proposed");

pendingActions[actionId] = PendingAction({

executeAfter: block.timestamp + DELAY,

executed: false

});

}

function executeAction(bytes32 actionId) external onlyOwner {

PendingAction storage action = pendingActions[actionId];

require(action.executeAfter != 0, "Not proposed");

require(block.timestamp >= action.executeAfter, "Too early");

require(!action.executed, "Already executed");

action.executed = true;

// Exécuter l'action

}

}

OpenZeppelin TimelockController :

import "@openzeppelin/contracts/governance/TimelockController.sol";

contract GovernedContract {

TimelockController public timelock;

constructor(address timelockAddress) {

timelock = TimelockController(payable(timelockAddress));

}

function criticalFunction() external {

require(msg.sender == address(timelock), "Must go through timelock");

// Logique critique

}

}

Pattern 5 : Multisig / Gnosis Safe

Nécessiter plusieurs signatures pour les actions critiques.

contract SimpleMultisig {

address[] public owners;

uint256 public required;

mapping(uint256 => mapping(address => bool)) public confirmations;

uint256 public transactionCount;

constructor(address[] memory _owners, uint256 _required) {

require(_owners.length >= _required, "Invalid required count");

owners = _owners;

required = _required;

}

function submitTransaction(address to, uint256 value, bytes memory data)

external

returns (uint256)

{

uint256 txId = transactionCount++;

// Stocker la transaction

return txId;

}

function confirmTransaction(uint256 txId) external {

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

confirmations[txId][msg.sender] = true;

if (isConfirmed(txId)) {

executeTransaction(txId);

}

}

function isConfirmed(uint256 txId) public view returns (bool) {

uint256 count = 0;

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

if (confirmations[txId][owners[i]]) count++;

}

return count >= required;

}

function isOwner(address account) public view returns (bool) {

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

if (owners[i] == account) return true;

}

return false;

}

function executeTransaction(uint256 txId) internal {

// Exécuter la transaction

}

}

En production, utilisez Gnosis Safe — battle-tested et audité.

Vulnérabilités de Contrôle d'Accès Courantes

1. Fonction d'Initialisation Non Protégée

// ❌ VULNÉRABLE

contract Vulnerable {

address public owner;

function initialize(address _owner) external {

owner = _owner; // N'importe qui peut appeler !

}

}

Correction :

// ✅ SÉCURISÉ

contract Secure {

address public owner;

bool private initialized;

function initialize(address _owner) external {

require(!initialized, "Already initialized");

owner = _owner;

initialized = true;

}

}

Ou utilisez le pattern Initializable d'OpenZeppelin pour les proxies.

2. Vérification Manquante de tx.origin vs msg.sender

// ❌ VULNÉRABLE au phishing

function withdraw() external {

require(tx.origin == owner, "Not owner"); // JAMAIS utiliser tx.origin !

// ...

}

Correction :

// ✅ SÉCURISÉ

function withdraw() external {

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

// ...

}

Pourquoi tx.origin est dangereux :

Si le propriétaire appelle un contrat malveillant, ce contrat peut appeler votre fonction withdraw() et tx.origin sera toujours le propriétaire.

3. Oubli de Modificateur

// ❌ VULNÉRABLE

function setAdmin(address newAdmin) external {

admin = newAdmin; // Pas de vérification !

}

Correction :

// ✅ SÉCURISÉ

function setAdmin(address newAdmin) external onlyOwner {

admin = newAdmin;

}

4. État Incohérent Après Transfert de Propriété

// ❌ VULNÉRABLE

function transferOwnership(address newOwner) external onlyOwner {

owner = newOwner; // Ancien propriétaire perd l'accès immédiatement

}

Correction : Transfert en 2 Étapes

// ✅ SÉCURISÉ

address public pendingOwner;

function transferOwnership(address newOwner) external onlyOwner {

pendingOwner = newOwner;

}

function acceptOwnership() external {

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

owner = pendingOwner;

pendingOwner = address(0);

}

Tests de Contrôle d'Accès

Avec Foundry

// test/AccessControl.t.sol

import "forge-std/Test.sol";

import "../src/MyContract.sol";

contract AccessControlTest is Test {

MyContract public myContract;

address public owner = address(1);

address public user = address(2);

function setUp() public {

vm.prank(owner);

myContract = new MyContract();

}

function testOnlyOwnerCanCallAdminFunction() public {

vm.prank(user);

vm.expectRevert("Not owner");

myContract.adminFunction();

vm.prank(owner);

myContract.adminFunction(); // Devrait réussir

}

}

Avec Hardhat

const { expect } = require("chai");

describe("Access Control", function () {

let contract, owner, user;

beforeEach(async function () {

[owner, user] = await ethers.getSigners();

const Contract = await ethers.getContractFactory("MyContract");

contract = await Contract.deploy();

});

it("Should revert if non-owner calls admin function", async function () {

await expect(

contract.connect(user).adminFunction()

).to.be.revertedWith("Not owner");

});

it("Should allow owner to call admin function", async function () {

await expect(

contract.connect(owner).adminFunction()

).to.not.be.reverted;

});

});

Bonnes Pratiques

Utilisez les bibliothèques éprouvées (OpenZeppelin Ownable, AccessControl)

Implémentez le transfert de propriété en 2 étapes pour éviter les accidents

N'utilisez JAMAIS tx.origin pour l'authentification

Utilisez des events pour tous les changements de rôles

Documentez qui devrait avoir quels rôles

Utilisez timelock pour les opérations critiques

Envisagez multisig pour les protocoles de haute valeur

Testez à la fois les chemins autorisés ET non autorisés

Auditez le code de contrôle d'accès en priorité

Minimisez le nombre de fonctions admin

Outils d'Analyse

  • Slither : Détecte les fonctions non protégées
  • Mythril : Analyse de contrôle d'accès
  • Echidna : Fuzzing des permissions
slither contracts/MyContract.sol --detect unprotected-upgrade

Conclusion

Le contrôle d'accès est la première ligne de défense dans la sécurité des smart contracts. En utilisant des patterns établis comme Ownable, AccessControl, et des fonctionnalités comme timelock et multisig, vous pouvez protéger significativement vos contrats contre les accès non autorisés.

Points clés :

  • Utilisez les bibliothèques OpenZeppelin éprouvées
  • Implémentez des transferts en 2 étapes
  • Testez à la fois les accès autorisés et non autorisés
  • Documentez les permissions des rôles
  • Envisagez timelock/multisig pour les opérations critiques

Chez Solingo, pratiquez l'implémentation de systèmes de contrôle d'accès à travers des exercices interactifs basés sur des patterns de protocoles DeFi réels.

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement