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

Echidna — Fuzzing Basé sur les Propriétés pour Smart Contracts

Laissez un fuzzer générer des milliers de transactions aléatoires pour casser vos invariants. Trouvez les bugs avant les hackers.

# Echidna — Fuzzing Basé sur les Propriétés pour Smart Contracts

Les tests unitaires vérifient des scénarios connus. Le fuzzing teste l'inattendu. Echidna excelle à cela.

Qu'est-ce que le Fuzzing ?

Un fuzzer génère des entrées aléatoires pour trouver des inputs qui cassent vos invariants.

Exemple : vous avez un coffre-fort ERC-4626. Invariant :

> "La valeur totale des parts ne peut jamais dépasser la valeur des actifs"

Un fuzzer va tenter des milliers de séquences :

deposit(1 ether)

withdraw(0.5 ether)

deposit(10000 ether)

redeem(9999 shares)

// ... jusqu'à trouver une séquence qui casse l'invariant

Installation

# Via Docker (recommandé)

docker pull trailofbits/eth-security-toolbox

# Ou Homebrew

brew install echidna

Premier Invariant

Testons un contrat de staking simple :

// StakingPool.sol

contract StakingPool {

mapping(address => uint256) public balances;

uint256 public totalStaked;

function stake() external payable {

balances[msg.sender] += msg.value;

totalStaked += msg.value;

}

function withdraw(uint256 amount) external {

require(balances[msg.sender] >= amount);

balances[msg.sender] -= amount;

totalStaked -= amount;

payable(msg.sender).transfer(amount);

}

}

Invariant : totalStaked == address(this).balance

Test Echidna

// StakingPoolEchidna.sol

import "./StakingPool.sol";

contract StakingPoolEchidna is StakingPool {

// Propriété : préfixe "echidna_"

function echidna_balance_matches_total() public view returns (bool) {

return totalStaked == address(this).balance;

}

}

Configuration

# echidna.yaml

testMode: assertion

testLimit: 10000

deployer: "0x10000"

sender: ["0x10000", "0x20000", "0x30000"]

Exécution

echidna StakingPoolEchidna.sol --contract StakingPoolEchidna --config echidna.yaml

# Résultat :

echidna_balance_matches_total: failed!

Call sequence:

1. stake() (value: 1 ether)

2. withdraw(1 ether)

3. stake() (value: 2 ether)

→ totalStaked = 2 ether, balance = 2 ether ✓ (pas de bug ici)

Exemple : Bug de Reentrancy

// VulnerableVault.sol

contract VulnerableVault {

mapping(address => uint256) public balances;

function deposit() external payable {

balances[msg.sender] += msg.value;

}

function withdraw() external {

uint256 amount = balances[msg.sender];

(bool success,) = msg.sender.call{value: amount}("");

require(success);

balances[msg.sender] = 0; // ❌ Après le transfer !

}

}

// Attacker.sol

contract Attacker {

VulnerableVault vault;

uint256 public attackCount;

constructor(VulnerableVault _vault) {

vault = _vault;

}

function attack() external payable {

vault.deposit{value: msg.value}();

vault.withdraw();

}

receive() external payable {

if (attackCount < 5 && address(vault).balance > 0) {

attackCount++;

vault.withdraw(); // Reentrancy !

}

}

}

Test Echidna pour Reentrancy

contract VaultEchidna {

VulnerableVault vault = new VulnerableVault();

function echidna_no_drain() public view returns (bool) {

// Le contrat ne doit jamais être complètement vidé

// si des utilisateurs ont encore des balances

uint256 balance = address(vault).balance;

uint256 userBalance = vault.balances(address(this));

return userBalance == 0 || balance >= userBalance;

}

function deposit() public payable {

vault.deposit{value: msg.value}();

}

function withdraw() public {

vault.withdraw();

}

receive() external payable {

// Tente un reentrancy

if (address(vault).balance > 0) {

try vault.withdraw() {} catch {}

}

}

}

echidna VulnerableVault.sol --contract VaultEchidna

# Résultat :

echidna_no_drain: failed!

Call sequence:

1. deposit() (value: 5 ether)

2. withdraw()

→ Reentrancy détecté ! Balance = 0, userBalance = 5 ether

Patterns Avancés

1. Invariants Multi-Contrats

contract DEXEchidna {

TokenA tokenA = new TokenA();

TokenB tokenB = new TokenB();

DEX dex = new DEX(tokenA, tokenB);

function echidna_constant_product() public view returns (bool) {

// Invariant x * y = k pour AMM

uint256 reserveA = tokenA.balanceOf(address(dex));

uint256 reserveB = tokenB.balanceOf(address(dex));

uint256 k = dex.K();

return reserveA * reserveB >= k;

}

}

2. Vérification d'Assertions

// Utilisez assert() dans votre code

function criticalOperation() internal {

uint256 before = totalSupply;

// ... logique

assert(totalSupply >= before); // Echidna va tenter de casser ceci

}

3. Corpus Optimization

Echidna garde les séquences intéressantes :

# echidna.yaml

corpusDir: "corpus"

coverage: true

Après un run, regardez corpus/ pour voir les transactions qui ont augmenté la couverture.

Fuzzing vs Tests Unitaires

| Aspect | Tests Unitaires | Echidna |

|--------|-----------------|---------|

| Scénarios | Connus | Aléatoires |

| Coverage | Partiel | Exhaustif |

| Maintenance | Élevée | Faible |

| Détecte l'inattendu | ❌ | ✅ |

Best practice : combinez les deux.

Limites

  • Temps : 10K tests = ~5 min
  • Complexité : difficile de tester des workflows multi-étapes très spécifiques
  • Oracle problem : vous devez connaître vos invariants

Outils Complémentaires

  • Foundry fuzz : intégré, plus simple mais moins puissant
  • Medusa : fork d'Echidna avec UI
  • Slither : analyse statique (combiné avec Echidna = combo gagnant)

Workflow Recommandé

  • Écrivez vos invariants
  • Lancez Echidna overnight (100K+ tests)
  • Analysez les failures
  • Fixez les bugs
  • Réitérez
  • # CI/CD
    

    echidna . --contract MyContractEchidna --test-limit 50000 --format text

    Ressources

    Echidna a trouvé des bugs dans Compound, Balancer, et des dizaines d'autres protocoles majeurs. Ne le sautez pas.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement