$3f
Ce skill couvre les vulnérabilités spécifiques aux smart contracts EVM et les outils pour les détecter. Aucune autre catégorie de bug ne se traduit aussi rapidement en perte irrécupérable de fonds.
Règle de base : un smart contract déployé en mainnet est immuable (sauf upgrade). Audit AVANT, pas après.
L'attaque fondatrice (The DAO 2016 → ETH/ETC fork). Une fonction qui appelle un contrat externe AVANT d'updater son state peut être ré-entrée par le callee.
// VULNERABLE
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}(""); // ← attaquant peut ré-entrer ici
require(ok);
balances[msg.sender] = 0; // Trop tard
}
// SAFE: CEI + ReentrancyGuard
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // Effects AVANT
(bool ok, ) = msg.sender.call{value: amount}(""); // Interaction APRÈS
require(ok);
}
// VULNERABLE — phishing
modifier onlyOwner() {
require(tx.origin == owner);
_;
}
// SAFE
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
// VULNERABLE — silent failure
recipient.call{value: amount}("");
// SAFE
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "Transfer failed");
Le mempool est public. Une transaction avec une commission élevée peut passer devant. Exemple : un MEV bot voit un swap qui va déplacer le prix → il achète avant et vend après (sandwich).
Mitigations :
minOut paramètre dans les swaps// VULNERABLE
function lottery() external {
if (block.timestamp % 2 == 0) {
// payout
}
}
Les miners peuvent ajuster block.timestamp de quelques secondes. Pour de l'aléatoire vrai → Chainlink VRF.
// VULNERABLE
function permit(address user, uint256 amount, bytes calldata signature) external {
bytes32 hash = keccak256(abi.encodePacked(user, amount));
address signer = recover(hash, signature);
require(signer == user);
_doSomething(amount); // Peut être rejoué !
}
// SAFE: nonce + chain ID + contract address (EIP-712)
function permit(address user, uint256 amount, uint256 nonce, bytes calldata signature) external {
require(nonce == nonces[user]++, "Invalid nonce");
bytes32 domain = keccak256(abi.encode(EIP712_DOMAIN, address(this), block.chainid));
bytes32 hash = keccak256(abi.encodePacked("\x19\x01", domain, keccak256(abi.encode(user, amount, nonce))));
require(recover(hash, signature) == user);
_doSomething(amount);
}
pip install slither-analyzer
slither . --filter-paths "lib|test"
Sortie type : detection de reentrancy, integer overflow, uninitialized state, missing zero address checks, etc.
# .github/workflows/security.yml
- name: Slither
uses: crytic/[email protected]
with:
target: 'src/'
fail-on: medium
pip install mythril
myth analyze src/MyContract.sol --solv 0.8.26
Plus lourd que Slither, plus profond. Bon pour les audits ponctuels.
// echidna_test.sol
contract Test is MyToken {
function echidna_total_supply_never_exceeds_max() public view returns (bool) {
return totalSupply() <= maxSupply;
}
function echidna_balance_sum_equals_total_supply() public view returns (bool) {
// ...
return true;
}
}
echidna . --contract Test --test-mode property
Spec en CVL (Certora Verification Language) qui prouve mathematiquement des propriétés.
mapping(address => bytes32) public commitments;
mapping(address => uint256) public commitBlock;
function commit(bytes32 hash) external {
commitments[msg.sender] = hash;
commitBlock[msg.sender] = block.number;
}
function reveal(uint256 value, bytes32 salt) external {
require(block.number > commitBlock[msg.sender] + 5, "Wait 5 blocks");
require(keccak256(abi.encodePacked(value, salt, msg.sender)) == commitments[msg.sender], "Invalid");
// ... process value ...
}
function swap(uint256 amountIn, uint256 minAmountOut, address tokenOut) external {
uint256 amountOut = _calcSwap(amountIn, tokenOut);
require(amountOut >= minAmountOut, "Slippage too high");
// ... execute ...
}
Le user envoie sa tx via le RPC https://rpc.flashbots.net/ au lieu du mempool public. La tx est forwarded directement aux validators sans passer par le mempool → MEV bots ne peuvent pas la voir.
// VULNERABLE: prix d'oracle = balance de DEX
function priceOf(address token) public view returns (uint256) {
return ERC20(token).balanceOf(uniswapPair) * 1e18 / ERC20(WETH).balanceOf(uniswapPair);
}
Un attaquant flash-loan 1M$, swap dans Uniswap → fait varier le prix → liquide des positions au prix manipulé → remboursement du flash-loan dans le même block.
Mitigations :
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract MyContract is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// ...
}
}
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
// Constructor d'un contrat important :
// _grantRole(DEFAULT_ADMIN_ROLE, address(timelock));
// _grantRole(DEFAULT_ADMIN_ROLE, address(0)); // Renounce direct admin
Toute action admin doit passer par un multisig (Gnosis Safe 3-of-5 par exemple) qui propose la tx au Timelock, et le timelock applique après 48h. Les users ont 48h pour réagir si le multisig est compromis.
// V1
contract MyContractV1 {
uint256 public a; // slot 0
uint256 public b; // slot 1
}
// V2 — BAD
contract MyContractV2 {
uint256 public b; // slot 0 ← collision, b lit la valeur de a
uint256 public a; // slot 1
}
// V2 — GOOD
contract MyContractV2 {
uint256 public a; // slot 0
uint256 public b; // slot 1
uint256 public c; // slot 2 (nouveau)
}
OpenZeppelin Upgrades plugin détecte ces collisions automatiquement.
Le proxy a une fonction admin() qui peut entrer en collision avec une fonction du contrat implémentation. Solutions :
^0.8.0 → fixer la version exactementexternal/public ont nonReentrant quand appropriétx.origin jamais utiliséblock.timestamp / blockhash jamais comme source d'aléa# .github/workflows/security.yml