Tools Workflow: Slither + Foundry + Echidna
A professional auditor's toolbox is carefully chosen and deeply practiced. Using the right tool for the right phase of an audit multiplies your output dramatically — but using tools randomly, without a workflow, wastes time and creates false confidence. This lesson presents the complete four-phase tooling workflow used by top competitive auditors, with specific commands, configurations, and time allocations for a 7-day contest audit.
Automated tools find the bugs that can be found automatically. These are almost never the highest-value findings in a competitive audit — those require human understanding of protocol logic. Use tools to clear the "known pattern" backlog quickly so your manual review time focuses on the logic bugs that tools cannot find. Tools should take ~30% of your time; human review should take ~50%; PoC writing should take ~20%.
Phase 1: First Pass with Slither
# Install Slither and its dependencies
$ pip3 install slither-analyzer
$ pip3 install crytic-compile
# For Foundry-based repos (most common)
$ cd target-repo && forge build
$ slither . --foundry-compile-all
# For Hardhat repos
$ npm install && npx hardhat compile
$ slither .
# Step 1: Architecture overview BEFORE looking for bugs
$ slither . --print human-summary
# Output: List of all contracts, functions, variables
# Shows: which functions modify state, which are view
# Step 2: Call graph — who calls what
$ slither . --print call-graph
$ dot -Tpng call-graph.dot -o call-graph.png
# Step 3: Inheritance diagram — understand contract hierarchy
$ slither . --print inheritance-graph
# Step 4: Full detector run, save output
$ slither . --json slither-output.json 2>&1 | tee slither-report.txt
# Step 5: Filter to High/Medium only to reduce noise
$ slither . --exclude-low --exclude-informational --exclude-optimization
// Slither will report many findings. Not all are valid.
// High true-positive rate detectors (always investigate):
// - reentrancy-eth, reentrancy-no-eth
// - unchecked-transfer (SafeERC20 not used)
// - arbitrary-send-eth
// - controlled-delegatecall
// - suicidal (selfdestruct by anyone)
// - uninitialized-state-variable
// Common false positives (investigate but often not valid):
// - reentrancy-benign: flags nonReentrant functions incorrectly sometimes
// - divide-before-multiply: flags correct precision scaling
// - tautology: flags intentional defensive checks
// Mark false positives with Slither's ignore comments:
// // slither-disable-next-line divide-before-multiply
uint256 result = (a / PRECISION) * b; // intentional scaling
Phase 2: Manual Review with Foundry
// foundry.toml — fork configuration
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[rpc_endpoints]
mainnet = "${ETH_RPC_URL}" // Alchemy or Infura
arbitrum = "${ARB_RPC_URL}"
polygon = "${POLYGON_RPC_URL}"
[etherscan]
mainnet = { key = "${ETHERSCAN_KEY}" }
// Fork test template for investigating a suspicious function
contract InvestigationTest is Test {
IProtocol target;
function setUp() public {
// Fork mainnet at a recent block
vm.createSelectFork("mainnet");
// Point to deployed protocol
target = IProtocol(0x1234...);
// Label addresses for readable traces
vm.label(address(target), "TargetProtocol");
vm.label(0xA0b8..., "USDC");
}
// Run with: forge test --match-test testInvestigate -vvvv
// -vvvv shows full call traces including reverts
}
// vm.prank(address) — next call comes from this address
vm.prank(attacker);
vault.withdraw(1000e18); // executes as attacker
// vm.startPrank(address) / vm.stopPrank() — multiple calls as same address
vm.startPrank(attacker);
token.approve(address(vault), type(uint256).max);
vault.deposit(1000e18);
vm.stopPrank();
// vm.deal(address, amount) — set ETH balance
vm.deal(attacker, 100 ether);
// deal(token, address, amount) — set ERC20 balance
deal(address(USDC), attacker, 1_000_000e6); // 1M USDC
// vm.warp(timestamp) — set block.timestamp
vm.warp(block.timestamp + 7 days); // skip 7 days
// vm.roll(blockNumber) — set block.number
vm.roll(block.number + 100); // advance 100 blocks
// vm.expectRevert() — assert next call reverts with specific message
vm.expectRevert("Insufficient balance");
vault.withdraw(1000e18);
// vm.expectEmit() — assert next call emits specific event
vm.expectEmit(true, true, false, true);
emit Withdrawn(attacker, 1000e18);
vault.withdraw(1000e18);
// vm.load / vm.store — direct storage manipulation for testing
// Read slot 0 of a contract
bytes32 slot0 = vm.load(address(vault), bytes32(uint256(0)));
// Override: set the owner slot directly
vm.store(address(vault), ownerSlot, bytes32(uint256(uint160(attacker))));
Phase 3: Fuzzing with Echidna and Foundry Invariant Tests
// Foundry invariant test — runs random sequences of function calls
// and checks that your invariant holds after every call
contract VaultInvariantTest is Test {
Vault vault;
Handler handler;
function setUp() public {
vault = new Vault();
handler = new Handler(vault);
// Only fuzz calls to the handler — handler calls vault
targetContract(address(handler));
}
// Invariant: total assets always >= total shares (no share inflation)
function invariant_totalAssets_geq_totalShares() public {
assertGe(
vault.totalAssets(),
vault.totalSupply(),
"Invariant violated: totalAssets < totalSupply"
);
}
// Invariant: no user can withdraw more than they deposited (ignoring yield)
function invariant_noTheft() public {
for (uint256 i; i < handler.actorCount(); i++) {
address actor = handler.actors(i);
assertLe(
handler.withdrawnByActor(actor),
handler.depositedByActor(actor) + vault.earned(actor),
"Actor withdrew more than deposited + earned"
);
}
}
}
// Run: forge test --match-contract VaultInvariantTest
// Configure: [invariant] in foundry.toml
// runs = 1000 (number of random sequences)
// depth = 100 (max calls per sequence)
// echidna-test.sol — Echidna property file
pragma solidity ^0.8.19;
import "./Vault.sol";
contract EchidnaVaultTest {
Vault vault;
address[] actors;
constructor() {
vault = new Vault();
actors.push(address(0x1));
actors.push(address(0x2));
}
// Echidna tests all functions named echidna_* as invariants
// Returns true if invariant holds, false if violated
function echidna_balance_never_negative() public view returns (bool) {
return address(vault).balance >= 0; // Always true for uint
}
function echidna_totalShares_matches_sum() public view returns (bool) {
uint256 sum = 0;
for (uint256 i; i < actors.length; i++) {
sum += vault.balanceOf(actors[i]);
}
return sum == vault.totalSupply();
}
}
// Run: echidna . --contract EchidnaVaultTest --config echidna.yaml
// echidna.yaml
// testLimit: 50000
// seqLen: 100
// coverage: true
Supporting Tools Reference
| Tool | Purpose | When to Use | Install Command |
|---|---|---|---|
| Slither | Static analysis, pattern detection | Phase 1 — every audit | pip3 install slither-analyzer |
| Foundry | Testing, fork tests, PoC writing | Phase 2 — every audit | curl -L foundry.paradigm.xyz | bash |
| Echidna | Property-based fuzzing | Phase 3 — complex state machines | github.com/crytic/echidna releases |
| 4naly3er | Automated report generation | Phase 1 — quick first-pass report | npx 4naly3er src/ |
| aderyn | Rust-based static analysis | Phase 1 — run alongside Slither | cargo install aderyn |
| Tenderly | Transaction simulation, debugging | Reproducing mainnet transactions | tenderly.co (web-based) |
| Semgrep | Custom pattern matching | Checking a specific pattern across large codebase | pip3 install semgrep |
| Halmos | Symbolic execution | Phase 4 — proving properties exhaustively | pip3 install halmos |
Full 7-Day Audit Workflow
// Day 1: Context and architecture (8 hours)
// Hours 1-2: Read README, docs, prior audits
// Hours 3-4: Run Slither printers (human-summary, call-graph, inheritance)
// Hours 5-6: Read test suite, note coverage gaps
// Hours 7-8: List all entry points, trace asset flows, draft threat model
// Day 2: Automated analysis (6 hours)
// Hours 1-2: Full Slither detector run, triage findings
// Hours 3-4: 4naly3er and aderyn, note non-overlapping findings
// Hours 5-6: Run existing test suite, check for failures
// Day 3-4: Deep manual review (16 hours)
// Focus on: high-value functions, coverage gaps, suspicious Slither flags
// Use: threat model to guide priorities
// Document: every suspicious finding in notes
// Day 5-6: PoC development and fuzzing (14 hours)
// Hours 1-4: Write invariant fuzz tests for core protocol properties
// Hours 5-10: Write PoC tests for each lead from manual review
// Hours 11-14: Investigate any invariant violations found by fuzzing
// Day 7: Report writing and finalization (8 hours)
// Hours 1-4: Write all finding reports (Title through Mitigation)
// Hours 5-6: Review all findings — are severities correct?
// Hours 7-8: Final checklist pass, submit
// Time allocation summary:
// Tools/automation: ~30% (17 hours)
// Manual review: ~50% (27 hours)
// PoC + reports: ~20% (11 hours)
In a competitive audit, spending 6 days on one potential Critical is less valuable than finding 3 Highs and 5 Mediums. Build a triage habit: spend no more than 2 hours investigating any single lead before deciding to either escalate it (write the PoC now) or flag it for later and move on. The lowest-hanging fruit is often found by methodically checking the same pattern across all functions, not by going deep on one function early.
#!/bin/bash
# audit-init.sh — run at start of every new audit
REPO_PATH=$1
cd $REPO_PATH
## 1. Build the project
forge build 2&>1 | tail -5
## 2. Run existing tests
forge test --summary 2&>1 | tail -10
## 3. Coverage report
forge coverage --report summary 2&>1 | grep -E "sol|%"
## 4. Slither — architecture first
slither . --print human-summary 2&>1 | tee slither-summary.txt
## 5. Slither — full detector run
slither . --exclude-low --exclude-informational --json slither.json 2&>1 | tee slither-findings.txt
## 6. Count external functions (entry points)
grep -r "function.*external" src/ | grep -v "view\|pure" | wc -l
## 7. Open everything for review
echo "Setup complete. External function count above. Start with slither-findings.txt"
Tenderly: Transaction Simulation for Live Protocols
// Tenderly lets you simulate transactions against mainnet state
// Essential for: reproducing past exploits, testing fixes on live contracts
// Use case 1: Replay a mainnet transaction to understand it
// Go to Tenderly.co → Transaction Simulator
// Paste the exploit transaction hash
// Tenderly shows: full call trace, state changes, emitted events
// Much more readable than raw Etherscan trace
// Use case 2: Test your exploit PoC on mainnet before writing the Foundry test
// Fork mainnet at latest block
// Deploy your attacker contract in simulation
// Execute the attack — see if it works and what the exact outcome is
// Use the output to write the Foundry test accurately
// Use case 3: Verify a protocol fix works
// Simulate applying the patch to the live contract
// Re-run the exploit — does it now revert?
// Tenderly CLI (for automation):
$ npm install -g @tenderly/cli
$ tenderly login
$ tenderly simulate tx 0xabc... --network mainnet
// Foundry's built-in debug output (alternative to Tenderly):
$ forge test --match-test testExploit -vvvv 2>&1 | head -200
// -vvvv shows: call depth, function calls, storage reads/writes
// This is usually sufficient for PoC development without Tenderly
Semgrep: Custom Pattern Detection
# Custom Semgrep rule: detect missing Chainlink staleness check
# File: rules/chainlink-staleness.yml
rules:
- id: missing-chainlink-staleness-check
patterns:
- pattern: |
(, $PRICE, , , ) = $FEED.latestRoundData();
...
return $PRICE;
- pattern-not: |
(, $PRICE, , $UPDATED_AT, ) = $FEED.latestRoundData();
require(block.timestamp - $UPDATED_AT <= $THRESHOLD);
message: >
Chainlink latestRoundData() result used without staleness check.
Price data could be hours old.
severity: WARNING
languages: [solidity]
# Run against target codebase:
$ semgrep --config rules/chainlink-staleness.yml src/
// More useful custom rules to write:
// - ecrecover without address(0) check
// - transfer() without return value check (non-SafeERC20 usage)
// - block.timestamp used for randomness
// - delegatecall to user-controlled address
// - tx.origin authentication
Building Your Personal Toolkit
| Script / Template | Purpose | Time to Build |
|---|---|---|
| audit-init.sh | One command to clone, build, run Slither, generate coverage — run at start of every audit | 30 minutes |
| finding-template.md | Pre-filled report template with all 7 sections — never start from blank | 15 minutes |
| InvariantTest.t.sol template | Handler + invariant test skeleton for common protocol types | 1-2 hours |
| ForkTest.t.sol template | Pre-configured fork test with labeling and common deal() calls | 30 minutes |
| personal-checklist.md | Your accumulated checklist from all audits — add one item per missed finding | Ongoing |
| Custom Slither detectors | Protocol-pattern-specific detectors you write after finding recurring issues | 2-4 hours each |
| Custom Semgrep rules | YAML rules for patterns you check manually but could automate | 30 min each |
Triage Workflow: From Raw Tool Output to Investigation Leads</html2>
Raw tool output is not actionable — it requires triage to separate genuine findings from false positives and low-priority noise. A structured triage workflow prevents the common mistake of spending hours investigating a Slither false positive while missing a critical logic bug.
// Step 1: Categorize each finding as Investigate / Likely FP / Known Issue
$ slither . 2>&1 | tee slither-raw.txt
$ wc -l slither-raw.txt # How big is the output?
// Step 2: Sort by severity — focus on High before Medium before Low
$ slither . --json slither-output.json 2>/dev/null
$ cat slither-output.json | jq '.results.detectors[] | select(.impact=="High") | .check'
// Step 3: False positive quick-filter — these are usually FP
// - "reentrancy-no-eth" on pure accounting functions (no ETH transfer)
// - "arbitrary-send-eth" on functions with access control
// - "controlled-array-length" on functions that only the owner can call
// - "tx-origin" on functions that correctly check msg.sender first
// Step 4: Genuine Slither findings worth investigating (not FP)
// - "reentrancy-eth" on public functions that send ETH
// - "uninitialized-local" — can indicate missing initialization bug
// - "incorrect-equality" — dangerous strict equality check (block.number ==, balance ==)
// - "suicidal" — delegatecall + selfdestruct combination
// - "unchecked-lowlevel" — .call() without checking return value
// Step 5: Cross-reference with 4naly3er output
// 4naly3er often finds different patterns than Slither
// Findings in BOTH tools = high confidence it is real, investigate first
// Findings in only one tool = still investigate, but check the other first
// Step 6: Build investigation queue
// Format: [tool] [finding] [file:line] [triage status]
// [slither] reentrancy-eth Vault.sol:145 INVESTIGATE
// [4naly3er] missing-events Protocol.sol:89 LOW PRIORITY
// [slither] arbitrary-send-eth Owner.sol:45 LIKELY FP (onlyOwner)
Sometimes Slither flags code that you have manually verified as safe. Do not simply mark it as a false positive — understand WHY Slither flags it. Slither uses pattern matching and may be overly conservative. But occasionally, Slither's pattern matching reveals a subtlety you missed in manual review. When in doubt: write a test that exercises the flagged code path and confirm safety programmatically. "I looked at it and it seems fine" is not a valid reason to dismiss a tool finding — "I wrote a test that proves it cannot be exploited" is.
Key Takeaways
- Tools should consume 30% of audit time — not 80% and not 0%. They clear the known-pattern backlog so human time focuses on logic bugs.
- Slither's printers (human-summary, call-graph, inheritance) provide architectural understanding faster than reading code — always run these first.
- Foundry's cheatcodes (vm.prank, vm.deal, vm.warp, vm.store) give you surgical control for reproducing and proving vulnerabilities in fork tests.
- Invariant fuzzing with Foundry or Echidna systematically finds property violations that manual review misses — write invariants before you start manual review.
- Tenderly's transaction simulation lets you reproduce mainnet exploits and test fixes without deploying contracts — use it when Foundry traces are insufficient.
- Custom Semgrep rules automate recurring manual checks — each rule you write saves hours across future audits.
- The 7-day time allocation (30% tools, 50% manual, 20% PoC+reports) is a starting point — adjust based on codebase size and complexity.
- Build a personal audit starter script that runs your standard tool suite automatically on any new codebase — consistency beats heroics.