How to Audit a Smart Contract Like a Pro
Complete Guide + Tool‑belt
Target audience: Solidity developers, security engineers, auditors‑in‑training, and anyone who wants to move from “code‑review” to a full‑blown professional audit.
What you’ll get:
- A mind‑set that forces you to see every line of code as a possible attack surface.
- A static‑analysis workflow (Slither, Mythril, Solhint) that you can drop into any CI pipeline.
- A fuzz‑testing strategy (Foundry, Echidna) with concrete command‑lines and tuning tips.
- A method for defining and automating invariants that catch subtle arithmetic or state‑consistency bugs.
- A manual‑review checklist covering the classic and the emerging categories of vulnerabilities.
- A quick‑start on bounty platforms (Code4rena, Sherlock, Immunefi) and how to turn audit work into reputation and cash.
Table of Contents
- The Auditing Mindset – “Assume Malicious”
- [Static Analysis – The First Line of Defense]
- Slither (100+ detectors)
- Mythril (symbolic execution)
- Solhint (style & best‑practice linter)
- CI Integration (GitHub Actions, GitLab CI, Foundry CI)
- [Dynamic & Fuzz Testing]
- Foundry’s built‑in fuzzer
- Echidna invariant testing
- Running a “1 M‑run” campaign – when is it enough?
- [Invariants – Turning Business Rules into Automated Checks]
- Writing invariants in Solidity (Echidna)
- Writing invariants in Foundry (cheatcodes)
- Example invariants for ERC‑20, ERC‑721, and custom logic
- [Manual Review Checklist – The Auditor’s “Cheat Sheet”]
- Re‑entrancy & Call‑stack depth
- Access control & role management
- Oracle & price‑feed manipulation
- Arithmetic precision & rounding errors
- Upgradeability & proxy pitfalls
- Gas‑related DoS, “unbounded loops”, and “out‑of‑gas” vectors
- Tokenomics & economic attacks (mint caps, slippage, flash‑loan abuse)
- Cross‑chain & bridge considerations
- [Bounty & Bug‑Finding Platforms]
- Code4rena – competition format
- Sherlock – “continuous bounty” model
- Immunefi – high‑value “critical” contracts
- How to build a reputation, write a solid report, and negotiate payouts
- [Putting It All Together – End‑to‑End Audit Playbook]
- [Further Reading & Community Resources]
1. Mindset: Assume Malicious – Every External Call Is a Trap, Every Variable Is Tainted
Auditing is not a code‑style review. It’s a mental exercise where you deliberately look for ways an attacker could subvert the intended behavior. The most powerful mental model is:
“Everything that can be influenced by an external actor is already under the attacker’s control.”
1.1. Taint‑Propagation Basics
| Source of Taint | Example | Why it matters |
|---|---|---|
msg.sender |
address owner = msg.sender; |
The caller can be any EOA or contract, possibly malicious. |
msg.value |
require(msg.value >= price); |
An attacker can send 0 ether, overflow, or a malicious amount. |
tx.origin |
require(tx.origin == admin); |
Bad – can be spoofed through a contract chain. |
External read (call, staticcall, delegatecall) |
price = IOracle(oracle).getPrice(); |
The oracle contract may be compromised or return manipulated data. |
Public storage variables (including public getters) |
public mapping(address => uint) balances; |
Anyone can read, but also indirectly influence via functions that modify them. |
| Return data from low‑level calls | (bool ok, bytes memory ret) = target.call(...); |
If the called contract reverts or returns malformed data, your logic may behave unexpectedly. |
Constructor arguments & immutable variables |
constructor(address _oracle) { oracle = _oracle; } |
If the constructor is called by a malicious deployer, the stored address can be a trap. |
msg.data |
bytes calldata payload = msg.data; |
Manipulated calldata can cause parsing bugs. |
Rule of thumb: If a variable ever receives data that originated outside the contract’s own code, treat it as *tainted and trace its flow to every place it is used.*
1.2. “Every External Call Is a Trap”
-
Check the return value – do you
requiresuccess? -
Guard against re‑entrancy – use the checks‑effects‑interactions pattern or OpenZeppelin’s
ReentrancyGuard. -
Limit gas – if you need to interact with an untrusted contract, consider
call{gas: 5000}to prevent DoS via out‑of‑gas. -
Validate the callee – is the address whitelisted? Is it a contract or an EOA? Use
extcodesizeorAddress.isContract.
Pro tip: Write a tiny helper library
SafeExternalCallthat centralizes these checks. When you seetarget.call(...)in the codebase, you instantly know you need to check the helper for proper use.
1.3. “Every Variable Is Tainted”
Even internal variables like
uint256 totalSupply;can become tainted if they are later derived from a user‑controlled source (e.g.,totalSupply = totalSupply + mintedAmount;wheremintedAmountis supplied by amint(address to, uint256 amount)that lacks proper access control).The moment you see a
publicorexternalfunction that updates a state variable, you must ask: Who is allowed to call it? If the access control is insufficient, the variable becomes an attack vector.
1.4. Quick‑Check Mental Checklist
| Situation | Question to Ask |
|---|---|
address external = msg.sender; |
Can msg.sender be a contract that later calls back? |
uint256 x = abi.decode(data, (uint256)); |
What if data is malformed? Will it revert or produce a bogus x? |
target.call(abi.encodeWithSignature(...)) |
Do we check success? Do we limit gas? Do we verify the target address? |
balances[msg.sender] += amount; |
Is amount bounded? Is there a mint cap? |
owner = msg.sender; in the constructor |
Who deployed the contract? |
price = IOracle(oracle).price(); |
Can the oracle be front‑run or compromised? |
Keep this table on a sticky note while you code‑review – it forces you to ask the malicious version of every operation.
2. Static Analysis – The First Line of Defense
Static analysis runs without executing the contract, scanning the abstract syntax tree (AST) or bytecode for known patterns. Think of it as the “lint + security scanner” that catches low‑ hanging fruit before you even launch a fuzzer.
2.1. Slither – 100+ Built‑In Detectors
Slither is the de‑facto standard for Solidity static analysis. It parses the Solidity source and produces an easy‑to‑read report.
2.1.1. Installation
# Using pip (recommended)
python3 -m pip install slither-analyzer
# Or via Docker (useful for CI isolation)
docker pull trailofbits/eth-security-toolbox
2.1.2. Running Slither
# Basic scan
slither src/**/*.sol
# Output as JSON (good for CI parsers)
slither src/**/*.sol --json ./reports/slither-report.json
# Limit to a specific detector (e.g., reentrancy)
slither src/**/*.sol --detect reentrancy
2.1.3. Example Output (excerpt)
[Reentrancy] Potential reentrancy vulnerability in ContractA._withdraw()
└─ src/ContractA.sol:78:23
└─ call to external contract `msg.sender.call{value: amount}("")` without reentrancy guard.
2.1.4. Extending Slither
You can write custom detectors in Python. A minimal custom detector that flags any delegatecall without a whitelist:
# detectors/delegatecall_without_whitelist.py
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
class DelegatecallWithoutWhitelist(AbstractDetector):
ARGUMENT = "delegatecall-whitelist"
HELP = "Detect delegatecall to non‑whitelisted address"
IMPACT = DetectorClassification.LOW
CONFIDENCE = DetectorClassification.MEDIUM
def _detect(self):
results = []
for contract in self.compilation_unit.contracts:
for f in contract.functions:
for node in f.nodes:
if node.type == "DELEGATECALL":
# simplistic check: look for a preceding require(isWhitelisted(addr))
if not any("isWhitelisted" in n.source_code for n in node.incoming_nodes):
results.append(self.generate_result([node]))
return results
Add to Slither:
slither src/**/*.sol --detectors detectors/delegatecall_without_whitelist.py
2.2. Mythril – Symbolic Execution
Mythril goes a step further: it symbolically executes the contract bytecode, trying to find paths that violate certain constraints (e.g., integer overflow, unchecked send). It is slower than Slither but can uncover deeper logical bugs.
2.2.1. Installation
# Install via pip
pip install mythril
# Or Docker
docker pull mythril/myth
2.2.2. Running Mythril
# Scan a Solidity file (Mythril compiles under the hood)
myth analyze src/Token.sol --solc-version 0.8.20 --max-depth 128
# Scan compiled bytecode (more deterministic)
myth analyze --bin-runtime <path-to-runtime-bytecode> --max-gas 8000000
2.2.3. Interpreting Results
Mythril reports "issues" with a confidence level:
[Warning] Integer Overflow (PC 0x2f) 0x5a5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5
Mythril also supports custom detectors written in Python. For example, a detector that flags any selfdestruct call after a certain block number (anti‑rug‑pull check).
2.3. Solhint – Linter & Style Guide
A tidy codebase makes static analysis easier and reduces human error. Solhint enforces community style guidelines and flags patterns that are prone to bugs (e.g., missing pragma solidity ^0.8.0;).
2.3.1. Installation
npm i -g solhint
2.3.2. Running Solhint
solhint "src/**/*.sol"
# Use a custom config (solhint.json) to enable/disable specific rules
solhint -c ./solhint.json "src/**/*.sol"
2.3.3. Sample solhint.json
{
"extends": "solhint:recommended",
"rules": {
"compiler-version": ["error", "^0.8.0"],
"func-visibility": ["error", {"ignoreConstructors": true}],
"no-empty-blocks": "warn",
"avoid-low-level-calls": "error",
"max-line-length": ["warn", 120]
}
}
2.4. CI Integration – Run Every Commit
Static analysis should be automated. Below are ready‑to‑copy snippets for GitHub Actions, GitLab CI, and a generic Makefile.
2.4.1. GitHub Actions (.github/workflows/security.yml)
name: Security Checks
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python (for Slither & Mythril)
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Slither & Mythril
run: |
pip install slither-analyzer mythril
- name: Install Solhint
run: npm i -g solhint
# ---------- Slither ----------
- name: Run Slither
run: |
slither src/**/*.sol --json reports/slither.json
continue-on-error: true # allow the job to continue for other checks
# ---------- Mythril ----------
- name: Run Mythril (limited depth for CI speed)
run: |
myth analyze src/**/*.sol --max-depth 64 || true
# ---------- Solhint ----------
- name: Run Solhint
run: |
solhint "src/**/*.sol" || true
# ---------- Upload Artifacts ----------
- name: Upload reports
uses: actions/upload-artifact@v4
with:
name: security-reports
path: reports/
Why continue-on-error? You want the CI to surface warnings and let the pipeline proceed (e.g., to run tests). The final gate can be a separate “review required” step.
2.4.2. GitLab CI (.gitlab-ci.yml)
stages:
- static
static-analysis:
stage: static
image: python:3.11
before_script:
- pip install slither-analyzer mythril solc-select
- npm i -g solhint
- solc-select install 0.8.20
- solc-select use 0.8.20
script:
- slither src/**/*.sol --json slither-report.json || true
- myth analyze src/**/*.sol --max-depth 64 || true
- solhint "src/**/*.sol" || true
artifacts:
paths:
- slither-report.json
expire_in: 1 week
2.4.3. Makefile for Local Development
.PHONY: lint slither myth all
# -------------------------------------------------
# Lint (Solhint)
lint:
npm i -g solhint
solhint "src/**/*.sol"
# -------------------------------------------------
# Slither
slither:
slither src/**/*.sol --json ./reports/slither.json
# -------------------------------------------------
# Mythril (symbolic)
myth:
myth analyze src/**/*.sol --max-depth 64
# -------------------------------------------------
# Run everything
all: lint slither myth
Run locally with make all. The same targets can be referenced from CI containers.
3. Fuzzing – From Random Inputs to Real‑World Confidence
Static analysis finds known patterns. Fuzzing explores unknown paths by feeding massive numbers of random (or guided) inputs to the EVM.
3.1. Foundry’s Built‑In Fuzzer
Foundry (forge) is a fast Solidity testing framework with an integrated fuzzer.
3.1.1. Install Foundry
bash
curl
#SmartContracts #Security #Audit #Solidity
Top comments (0)