DEV Community

Naren karthi
Naren karthi

Posted on

How to Audit a Smart Contract Like a Pro (Complete Guide + Tools)

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, multi-layered process combining adversarial mindset, automated static/fuzz/symbolic analysis, mathematical invariant verification, and deep manual review. This guide gives you the exact workflow, commands, CI configs, and checklists used by top auditors at firms like Trail of Bits, OpenZeppelin, and competitive audit platforms (Code4rena, Sherlock).


Table of Contents

  1. The Auditor’s Mindset: Assume Malice
  2. Environment Setup & Toolchain
  3. Static Analysis: Beyond slither .
  4. Fuzzing & Symbolic Execution: Proving Correctness
  5. Invariant Testing: Math Over Intuition
  6. The Manual Review Checklist: The "Critical 12"
  7. Reporting & Communication
  8. Breaking In: Bounty Platforms & Career Path
  9. Appendix: Complete CI/CD Pipeline

1. The Auditor’s Mindset: Assume Malice

Junior auditors read code to understand what it does. Pro auditors read code to discover what it can be forced to do.

Core Principles

  1. Every External Call is a Trap: call, delegatecall, staticcall, send, transfer, ERC20 transfer/transferFrom, ERC721 safeTransferFrom — all hand execution control to untrusted code. Assume reentrancy until proven impossible.
  2. Every Variable is Tainted: msg.sender, msg.value, block.timestamp, block.number, calldata, return values of external calls, storage slots readable by proxies — treat as attacker-controlled until validated.
  3. State Changes Are Not Atomic Across Calls: The EVM is single-threaded, but your logic isn't. The state at line 10 is not the state at line 12 if line 11 is an external call.
  4. Composability is Attack Surface: Your contract doesn't live in a vacuum. It interacts with Uniswap V3, Chainlink, LayerZero, EigenLayer. You must audit the integration, not just the contract.
  5. Gas Griefing & DoS are Valid Criticals: If an attacker can brick a core function (mint, withdraw, vote) by forcing an OOG error or bloating a loop, that is a High severity finding.

The "Adversarial Read" Workflow

Don't read top-to-bottom. Read by Attack Vector:

  1. Entry Points: constructor, initialize, fallback, receive, all public/external functions.
  2. Trust Boundaries: Where does onlyOwner / onlyRole / onlyOperator live? Where are they missing?
  3. Value Flow: Trace msg.value and token.balanceOf(address(this)) through every branch.
  4. State Transitions: Map the state machine. Can I reach Withdrawn without Deposited? Can I skip Cooldown?

2. Environment Setup & Toolchain

Before writing a single test, standardize your environment. Reproducibility is non-negotiable.

Core Stack (2024 Standard)

Category Tool Purpose
Framework Foundry (Forge/Cast/Anvil) Testing, Fuzzing, Debugging, Deployment
Static Analysis Slither, Solhint, Wake Pattern detection, Style, Dataflow
Symbolic Execution Mythril, Halmos Path exploration, SMT solving
Invariant Fuzzing Echidna, Foundry (Invariant Tests) Stateful property verification
Formal Verification Certora Prover, Dafny Mathematical proofs (High value targets)
Coverage Forge Coverage, LCOV Branch/Line coverage reporting
Diffing Git, Slither Diff, Solidity Scan Audit scope definition

Installation (Reproducible via Nix/Docker)

# 1. Foundry (Pinned version)
curl -L https://foundry.paradigm.xyz | bash
foundryup -v nightly-<SHA> # Pin to specific commit for audit reproducibility

# 2. Slither & Python Tooling (Use pipx for isolation)
pipx install slither-analyzer[detectors]
pipx install wake  # Modern alternative from Ackee Blockchain

# 3. Echidna (Haskell - use Docker/Nix)
# Docker is easiest:
docker pull trailofbits/echidna

# 4. Mythril
pipx install mythril

# 5. Certora (Requires license/key)
# pipx install certora-cli

# 6. Solhint
npm install -g solhint @solidity-parser/parser
Enter fullscreen mode Exit fullscreen mode

Project Structure for Auditing

audit-project/
├── src/                 # Target contracts (Read-only ideally, or fork)
├── test/
│   ├── unit/            # Standard unit tests
│   ├── fuzz/            # Foundry fuzz tests (stateless)
│   ├── invariant/       # Foundry invariant tests (stateful)
│   └── echidna/         # Echidna properties (.sol files)
├── script/              # Deployment/Interaction scripts
├── slither.config.json  # Slither config (triage suppressions)
├── .solhint.json        # Linting rules
├── foundry.toml         # Foundry config (fuzz runs, optimizer)
├── echidna.config.yaml  # Echidna config
└── .github/workflows/   # CI Pipeline (see Appendix)
Enter fullscreen mode Exit fullscreen mode

3. Static Analysis: Beyond slither .

Running slither . and scrolling through 500 "Low" findings is not auditing. You need configuration, triage, and CI integration.

3.1 Slither: Configuration for Signal-to-Noise

Create slither.config.json at root. Commit this file.

{
  "filter_paths": "node_modules",
  "exclude_dependencies": true,
  "detectors_to_exclude": [
    "unused-state",           // Noise in complex protocols
    "external-function",      // Public vs External gas diff usually irrelevant for security
    "low-level-calls",        // Too noisy; review manually
    "naming-convention",      // Style, not security
    "constant-function-asm"   // Rarely exploitable
  ],
  "detectors_to_include": [
    "reentrancy-eth",
    "reentrancy-no-eth",
    "reentrancy-benign",
    "arbitrary-send",
    "arbitrary-send-erc20",
    "arbitrary-send-erc721",
    "controlled-delegatecall",
    "delegatecall-forward-value",
    "unchecked-send",
    "unchecked-transfer",
    "weak-prng",
    "tx-origin",
    "shadowing-state",
    "shadowing-local",
    "shadowing-builtin",
    "uninitialized-state",
    "uninitialized-local",
    "storage-array-taint",
    "missing-zero-check",
    "divide-before-multiply",
    "erc20-interface",
    "erc721-interface",
    "incorrect-equality"
  ],
  "printers": [
    "call-graph",
    "contract-summary",
    "human-summary",
    "vars-and-auth",
    "data-dependency"
  ],
  "output": "json",
  "json_output": "slither-report.json",
  "sarif_output": "slither-report.sarif"
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Run specific high-signal detectors first:

# Run ONLY critical detectors for initial triage
slither . --detect reentrancy-eth,reentrancy-no-eth,arbitrary-send,controlled-delegatecall,unchecked-send,weak-prng,tx-origin --json critical-report.json
Enter fullscreen mode Exit fullscreen mode

3.2 Wake: The Modern Dataflow Engine

Wake (by Ackee) outperforms Slither on taint analysis and cross-contract dataflow.

# Generate call graph with taint tracking
wake print call_graph --source src --target "ContractName.functionName"

# Detect SQL-injection style taint (user input -> storage -> external call)
wake detect data_flow --source src --sink "call.value" --sanitizer "require"
Enter fullscreen mode Exit fullscreen mode

3.3 Solhint: Enforce Secure Patterns

.solhint.jsonEnforce in CI. Fail build on warnings.

{
  "extends": "solhint:recommended",
  "plugins": [],
  "rules": {
    "compiler-version": ["error", "^0.8.20"], // Pin compiler
    "func-visibility": ["error", { "ignoreConstructors": true }],
    "no-empty-blocks": "error",
    "no-unused-vars": "error",
    "not-rely-on-time": "error",          // block.timestamp manipulation
    "not-rely-on-block-hash": "error",    // blockhash manipulation
    "avoid-low-level-calls": "error",     // Force abstraction
    "avoid-sha3": "warn",                 // Use keccak256
    "avoid-throw": "error",               // Use revert/error
    "avoid-tx-origin": "error",           // Critical: Phishing vector
    "check-send-result": "error",         // Check return bool
    "const-name-snakecase": "error",
    "func-name-mixedcase": "error",
    "max-line-length": ["error", 120],
    "no-console": "error",                // Remove console.log
    "no-global-import": "error",
    "payable-fallback": "error",
    "reason-string": ["error", { "maxLength": 64 }], // Gas optimization
    "var-name-mixedcase": "error"
  }
}
Enter fullscreen mode Exit fullscreen mode

3.4 CI Integration: Gatekeeping

Never merge without clean static analysis. See Appendix for full GitHub Actions YAML.


4. Fuzzing & Symbolic Execution: Proving Correctness

Unit tests verify expected behavior. Fuzzing verifies unexpected behavior. Symbolic execution verifies all paths.

4.1 Foundry Stateless Fuzzing (Property-Based Testing)

Target: Pure functions, math libraries, single-transaction logic.
Config (foundry.toml):

[profile.ci]
fuzz_runs = 25000        # High confidence for CI
fuzz_seed = 0xDEADBEEF   # Deterministic seeds for reproducibility
invariant_runs = 5000
invariant_depth = 50
max_test_revert_reason_size = 10000

[fuzz]
# Dictionary for address generation (improves coverage of access control)
dictionary_weight = 40
# Include known addresses: zero, owner, protocol contracts, common tokens
dictionary = [
  "0x0000000000000000000000000000000000000000",
  "0x000000000000000000000000000000000000dEaD",
  "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" # WETH
]
Enter fullscreen mode Exit fullscreen mode

Example Fuzz Test (Math Library):

// test/fuzz/MathFuzz.t.sol
import "forge-std/Test.sol";
import "src/lib/Math.sol";

contract MathFuzzTest is Test {
    Math math = new Math();

    // Property: mulDiv(a, b, c) == (a * b) / c  (with rounding checks)
    function testFuzz_MulDiv_Rounding(uint256 a, uint256 b, uint256 c) public {
        vm.assume(c != 0);
        vm.assume(a <= type(uint128).max); // Prevent overflow in reference calc
        vm.assume(b <= type(uint128).max);

        uint256 result = math.mulDiv(a, b, c);
        uint256 expected = (a * b) / c; // Solidity native rounding (floor)

        // Allow off-by-one for rounding modes if specified, else strict equality
        assertEq(result, expected, "mulDiv rounding mismatch");
    }

    // Property: sqrt(x)^2 <= x < (sqrt(x)+1)^2
    function testFuzz_Sqrt_Bounds(uint256 x) public {
        uint256 root = math.sqrt(x);
        assertLe(root * root, x);
        assertLt(x, (root + 1) * (root + 1));
    }
}
Enter fullscreen mode Exit fullscreen mode

Run: forge test --match-contract MathFuzzTest --fuzz-runs 100000 -vvv

4.2 Foundry Stateful Invariant Fuzzing

Target: Complex state machines (AMMs, Vaults, Governance).
Mechanism: Forge calls random sequences of public/external functions (defined via invariant keyword) and checks invariant_* functions after every call.

// test/invariant/VaultInvariant.t.sol
import "forge-std/Test.sol";
import "src/Vault.sol";
import "src/mocks/MockERC20.sol";

contract VaultInvariantTest is Test {
    Vault vault;
    MockERC20 asset;
    MockERC20 shares;
    address admin = makeAddr("admin");
    address user1 = makeAddr("user1");
    address user2 = makeAddr("user2");
    address attacker = makeAddr("attacker");

    function setUp() public {
        asset = new MockERC20("Asset", "AST", 18);
        shares = new MockERC20("Shares", "SHR", 18);
        vault = new Vault(address(asset), address(shares));

        // Mint assets to users
        asset.mint(user1, 10000 ether);
        asset.mint(user2, 10000 ether);
        asset.mint(attacker, 10000 ether);

        // Approve vault for all
        vm.startPrank(user1); asset.approve(address(vault), type(uint256).max); vm.stopPrank();
        vm.startPrank(user2); asset.approve(address(vault), type(uint256).max); vm.stopPrank();
        vm.startPrank(attacker); asset.approve(address(vault), type(uint256).max); vm.stopPrank();
    }

    // ========== INVARIANTS (Run after EVERY fuzzed call) ==========

    function invariant_TotalAssetsTracked() public {
        // Vault totalAssets() must equal underlying balance + managed strategies
        // Simplified: totalAssets == asset.balanceOf(address(vault))
        assertEq(vault.totalAssets(), asset.balanceOf(address(vault)), "Total assets mismatch");
    }

    function invariant_SharePriceMonotonic() public {
        // Price per share must never decrease (assuming no fees/losses in this mock)
        // Requires tracking previous state -> Use persistent storage or cheatcodes
        // Forge provides `vm.getState()` / `vm.setState()` for snapshotting, 
        // but for invariant fuzzing, we usually check *relationships* not history.
        // Better: Convert to shares and back != loss (excluding fees)
        uint256 deposit = 100 ether;
        uint256 sharesReceived = vault.previewDeposit(deposit);
        uint256 assetsReturned = vault.previewRedeem(sharesReceived);
        // Allow small rounding loss (1 wei)
        assertLe(assetsReturned, deposit);
        assertGe(deposit - assetsReturned, 1); 
    }

    function invariant_NoNegativeBalances() public {
        assertGe(asset.balanceOf(address(vault)), 0); // Useless for uint
        // Real check: User balance accounting
        // sum(user.shares) == totalSupply()
        // This requires iterating users -> Use a tracking mapping in test contract
    }

    // ========== FUZZED OPERATIONS (Forge picks random caller/args) ==========

    function deposit(uint256 assets, address receiver) external {
        vm.prank(receiver);
        vault.deposit(assets, receiver);
    }

    function redeem(uint256 shares, address receiver, address owner) external {
        vm.prank(owner);
        vault.redeem(shares, receiver, owner);
    }

    // Exclude view functions, internal, constructor
    // Forge automatically fuzzes all `external`/`public` functions 
    // NOT starting with `invariant_` or `setUp`/`tearDown`.
}
Enter fullscreen mode Exit fullscreen mode

Run: `forge

SmartContracts #Security #Audit #Solidity

Top comments (0)