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, 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

  1. The Adversarial Mindset: Assume Malice
  2. Static Analysis: The CI Gatekeepers
  3. Property-Based Fuzzing: Stress Testing Logic
  4. Invariant Testing: Mathematical Guarantees
  5. Manual Review Checklist: The Human Layer
  6. Going Pro: Bounty Platforms & Reputation
  7. 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

  1. Every External Call is a Trap: call, delegatecall, staticcall, transfer, send, ERC20 transfer/transferFrom, ERC721 safeTransferFrom. Assume they revert, return false, re-enter your contract, or call a malicious contract.
  2. 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.
  3. State Changes are Permanent (Until They Aren't): selfdestruct (pre-Shanghai), delegatecall to upgradable proxies, CREATE2 collisions. State is fluid.
  4. Composability is a Weapon: Your contract interacts with Uniswap, AAVE, Chainlink. If they change (fee tier, pause, oracle deviation), you break.
  5. Gas is an Attack Vector: Loops over unbounded arrays, expensive storage writes, require vs assert gas 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 M deposits → onERC721Received re-enters withdraw → manipulates totalAssets() 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)