How to Audit a Smart Contract Like a Pro: Complete Guide + Tools
TL;DR: Professional auditing isn't running Slither and calling it a day. It is a rigorous, layered methodology: Adversarial Mindset → Automated Static Analysis → Property-Based Fuzzing → Formal Invariant Verification → Deep Manual Review → Economic Incentive Alignment. This guide walks you through the exact workflow, tooling configurations, and checklists used by top-tier audit firms (OpenZeppelin, Trail of Bits, Spearbit) and competitive audit platforms (Code4rena, Sherlock).
Table of Contents
- The Adversarial Mindset: Assume Malice
- Static Analysis: The CI Gatekeepers
- Property-Based Fuzzing: Stress Testing Logic
- Invariant Testing: Mathematical Guarantees
- Manual Review Checklist: The Human Layer
- Going Pro: Bounty Platforms & Reputation
- Appendix: The Auditor's Starter Repo
1. The Adversarial Mindset: Assume Malice
Before you open a single file, internalize this: You are not a reviewer; you are an attacker. The developer wrote the code to make it work. Your job is to prove it breaks.
Core Axioms
- Every External Call is a Trap:
call,delegatecall,staticcall,transfer,send, ERC20transfer/transferFrom, ERC721safeTransferFrom. Assume they revert, returnfalse, re-enter your contract, or call a malicious contract. - Every Variable is Tainted:
msg.sender,msg.value,block.timestamp,block.basefee, calldata, return values from external contracts, storage slots. Never trust input without validation. - State Changes are Permanent (Until They Aren't):
selfdestruct(pre-Shanghai),delegatecallto upgradable proxies,CREATE2collisions. State is fluid. - Composability is a Weapon: Your contract interacts with Uniswap, AAVE, Chainlink. If they change (fee tier, pause, oracle deviation), you break.
- Gas is an Attack Vector: Loops over unbounded arrays, expensive storage writes,
requirevsassertgas costs. Denial of Service (DoS) via block gas limit is real.
The "Shadow Deployment" Mental Model
When reading a function, simulate the worst-case execution path simultaneously:
- Happy Path: Alice deposits 100 USDC → mints shares → emits event.
- Shadow Path: Malicious Contract
Mdeposits →onERC721Receivedre-enterswithdraw→ manipulatestotalAssets()via donation → mints shares for free → drains vault.
Pro Tip: Print the contract. Use a red pen. Circle every external call. Draw arrows for state changes before and after that call. If state changes after an external call, write "REENTRANCY VECTOR" in caps.
2. Static Analysis: The CI Gatekeepers
Static analysis catches the "low-hanging fruit" (reentrancy, unchecked returns, shadowing) instantly. Run these in CI on every PR. If the pipeline passes, you earn the right to run fuzzers.
The Holy Trinity: Slither, Mythril, Solhint
A. Slither (Trail of Bits) — The Workhorse
100+ detectors. Fast. Python API for custom detectors.
Install: pip3 install slither-analyzer (requires crytic-compile & solc-select).
CI Config (.github/workflows/slither.yml):
name: Slither Static Analysis
on: [push, pull_request]
jobs:
slither:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Install Slither
run: pip3 install slither-analyzer
- name: Compile Contracts
run: forge build
- name: Run Slither (SARIF Output for GitHub Security Tab)
run: |
slither . \
--json-types detectors \
--sarif results.sarif \
--exclude-informational \
--exclude-low \
--detect reentrancy,unchecked-transfer,unchecked-lowlevel,shadowing-state,shadowing-local,tx-origin,assembly,delegatecall,uninitialized-state,constant-function-asm,erc20-interface,erc721-interface
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
Critical Detectors to Enforce (Fail Build):
-
reentrancy-eth,reentrancy-no-eth,reentrancy-benign(Review benign manually) -
unchecked-lowlevel-calls,unchecked-send,unchecked-transfer -
shadowing-state,shadowing-local,shadowing-builtin -
tx-origin,assembly(Flag for manual review) -
erc20-interface,erc721-interface(Standard compliance)
Custom Detector Example (Python): Detect transferOwnership without timelock.
# detectors/ownership_timelock.py
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.slithir.operations import HighLevelCall
class OwnershipWithoutTimelock(AbstractDetector):
ARGUMENT = "ownership-timelock"
HELP = "Ownership transfer without timelock"
IMPACT = DetectorClassification.HIGH
CONFIDENCE = DetectorClassification.MEDIUM
def _detect(self):
results = []
for contract in self.compilation_unit.contracts:
for func in contract.functions:
if "transferOwnership" in func.name or "setOwner" in func.name:
# Check if caller is timelock or governance
has_timelock_check = any(
"onlyRole" in mod.name or "onlyOwner" in mod.name
for mod in func.modifiers
)
if not has_timelock_check:
info = [f"Function {func.name} in {contract.name} transfers ownership without timelock enforcement.\n"]
results.append(self.generate_result(info))
return results
Run custom: slither . --detect ownership-timelock --config-file slither.config.json
B. Mythril (ConsenSys) — Symbolic Execution
Finds deep paths Slither misses (integer overflows in complex loops, assertion violations). Slow. Run nightly, not on every PR.
Docker CI (.github/workflows/mythril.yml):
name: Mythril Symbolic Analysis
on:
schedule: [cron: '0 2 * * *'] # Nightly
workflow_dispatch:
jobs:
mythril:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Run Mythril
uses: mythril/mythril-action@v1
with:
args: analyze --solc-version 0.8.20 --execution-timeout 300 --max-depth 50 --solver-timeout 10000 -o json -o markdown -o text --bin-runtime .
Key Flags: --max-depth 50 (loop unrolling), --solver-timeout 10000 (Z3 timeout ms). Focus on SWC-101 (Integer Overflow), SWC-107 (Reentrancy), SWC-113 (DoS with Failed Call).
C. Solhint — Style & Security Hygiene
Enforces pragma locking, explicit visibility, no console.log, payable fallback.
Config (.solhint.json):
{
"extends": "solhint:recommended",
"plugins": [],
"rules": {
"compiler-version": ["error", "^0.8.20"],
"func-visibility": ["error", { "ignoreConstructors": true }],
"no-unused-vars": "error",
"not-rely-on-time": "warn",
"avoid-low-level-calls": "warn",
"reason-string": ["error", { "maxLength": 64 }],
"var-name-mixedcase": "error",
"const-name-snakecase": "error",
"event-name-camelcase": "error",
"func-name-mixedcase": "error",
"no-console": "error",
"no-empty-blocks": "error",
"private-vars-leading-underscore": "error"
}
}
CI Step: npx solhint 'contracts/**/*.sol'
3. Property-Based Fuzzing: Stress Testing Logic
Static analysis sees syntax. Fuzzing sees semantics under chaotic input. Foundry (Forge) is the industry standard for Rust-speed fuzzing. Echidna (Haskell) is the gold standard for stateful invariant fuzzing.
A. Foundry Fuzzing: forge test --fuzz-runs 10000
Foundry fuzzes function arguments. You write invariants as assert/require inside test functions.
Setup (foundry.toml):
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[fuzz]
runs = 10000 # Default runs per test
max_test_rejects = 10000
dictionary_weight = 40
include_storage = true
include_push_bytes = true
Invariant Test Pattern (test/Invariants.t.sol):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract VaultInvariantTest is Test {
Vault vault;
ERC20 asset;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address attacker = makeAddr("attacker");
function setUp() public {
asset = ERC20(address(new MockERC20("Asset", "AST", 18)));
vault = new Vault(address(asset), "Vault Share", "vAST");
// Seed liquidity
asset.mint(address(this), 1_000_000e18);
asset.approve(address(vault), type(uint256).max);
vault.deposit(1_000_000e18, address(this));
// Distribute to fuzz actors
asset.mint(alice, 100_000e18);
asset.mint(bob, 100_000e18);
asset.mint(attacker, 100_000e18);
}
// 1. Accounting Invariant: Shares * Price == Assets (within precision)
function invariant_TotalAssetsEqualsSharesTimesPrice() public {
uint256 totalAssets = vault.totalAssets();
uint256 totalSupply = vault.totalSupply();
uint256 price = vault.pricePerShare(); // 1e18 precision
// Allow 1 wei rounding error per share (standard ERC4626 tolerance)
assertApproxEqAbs(totalAssets, (totalSupply * price) / 1e18, totalSupply);
}
// 2. Fuzz Deposit/Withdraw Roundtrip
function testFuzz_DepositWithdrawRoundtrip(uint256 amount, address user) public {
// Foundry auto-generates 'amount' and 'user' (from --fuzz-runs)
vm.assume(amount > 0 && amount <= 100_000e18); // Bound input
vm.assume(user == alice || user == bob || user == attacker);
vm.startPrank(user);
asset.approve(address(vault), amount);
uint256 sharesBefore = vault.balanceOf(user);
uint256 assetsBefore = asset.balanceOf(user);
vault.deposit(amount, user);
uint256 sharesReceived = vault.balanceOf(user) - sharesBefore;
// Withdraw immediately
vault.redeem(sharesReceived, user, user);
uint256 assetsAfter = asset.balanceOf(user);
// Allow slippage/fees if protocol has them, else exact
assertApproxEqAbs(assetsBefore, assetsAfter, 1);
vm.stopPrank();
}
// 3. Fuzz Reentrancy Attack Vector
function testFuzz_ReentrancyOnWithdraw(address attackerContract) public {
vm.assume(attackerContract != address(vault) && attackerContract != address(asset));
// Deploy malicious contract via cheatcode if needed, or assume pre-deployed
// This requires a setup where attackerContract has a fallback calling vault.withdraw
// See "Reentrancy.t.sol" for full implementation
}
}
Run Command:
# High intensity fuzzing (CI Nightly)
forge test --fuzz-runs 100000 -vvv --match-contract VaultInvariantTest
# Quick PR check
forge test --fuzz-runs 1000 --match-contract VaultInvariantTest
B. Echidna: Stateful Property Testing (The Heavy Artillery)
Echidna generates sequences of transactions (not just single calls) to break invariants. It finds bugs requiring 5+ tx steps (e.g., "Deposit → Borrow → Liquidate → Repay → Withdraw" sequence breaking accounting).
Install: docker pull trailofbits/echidna (or cabal install echidna)
Config (echidna.yaml):
# Echidna config for Vault
filterBlacklist: true
testMode: "assertion"
assertionChecks: true
coverage: true
corpusDir: "echidna_corpus"
timeout: 300 # seconds
seqLen: 50 # Max transaction sequence length
estimateGas: true
shrinkArgs: true
# Custom fuzzing dictionary for addresses/values
# generators:
# - type: "address"
# values: ["0x...alice", "0x...bob", "0x...attacker"]
Echidna Test Contract (test/EchidnaVault.sol):
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract EchidnaVault is Test {
Vault vault;
ERC20 asset;
address[] users;
constructor() {
asset = ERC20(address(new MockERC20("Asset", "AST", 18)));
vault = new Vault(address(asset), "Vault Share", "vAST");
// Create 10 fuzz actors
for (uint i = 0; i < 10; i++) {
address u = address(uint160(i + 1));
users.push(u);
asset.mint(u, 1_000_000e18);
}
// Seed vault
asset.mint(address(this), 10_000_000e18);
asset.approve(address(vault), type(uint256).max);
vault.deposit(10_000_000e18, address(this));
}
// Echidna calls this automatically before each sequence
function echidna_init() public {
// Reset state if needed, or rely on constructor
}
// INVARIANT 1: Total Assets >= Sum of User Balances (Solvency)
function echidna_solvency_check() public view returns (bool) {
uint256 sumBalances = 0;
for (uint i = 0; i < users.length; i++) {
sumBalances += vault.balanceOf(users[i]);
}
// Vault shares represent claim on assets. Total supply should match assets.
return vault.totalAssets() >= (vault.totalSupply() * vault.pricePerShare()) / 1e18;
}
// INVARIANT 2: No User Balance Exceeds Total Supply
function echidna_balance_bounds() public view returns (bool) {
for (uint i = 0; i < users.length; i++) {
if (vault.balanceOf(users[i]) > vault.totalSupply()) return false;
}
return true;
}
// INVARIANT 3: Price Per Share Monotonicity (No yield loss on deposit/withdraw)
// Only valid if no fees/reb
#SmartContracts #Security #Audit #Solidity
Top comments (0)