Slither: Static Analysis for Solidity Smart Contracts

Slither: Static Analysis for Solidity Smart Contracts

Most smart contract bugs are not exotic zero-days. They are well-understood vulnerability patterns — reentrancy, unchecked return values, integer overflow, dangerous delegatecall — that show up repeatedly because developers write the same mistakes in different projects. Slither, the static analysis framework from Trail of Bits, exists specifically to catch these patterns automatically before your code ever reaches a blockchain.

This guide covers Slither end to end: installation, running the built-in detectors, interpreting and triaging results, writing custom detectors for project-specific patterns, and integrating Slither into CI so vulnerabilities get caught on every pull request.

What Slither Does

Slither works by converting Solidity source code into an intermediate representation called SlithIR, then running a collection of detectors against that representation. Detectors look for specific patterns — a state variable written after an external call, a public function that modifies state without an access check, a transfer return value that is never checked.

The output is a list of findings grouped by severity: High, Medium, Low, and Informational. Unlike a runtime fuzzer, Slither does not execute your code — it reasons about it structurally. This means it runs in milliseconds and catches an entire class of bugs that would require millions of fuzzing iterations to trigger.

Slither is complementary to fuzzing and unit testing, not a replacement for them. It finds different things: structural issues, known bad patterns, missing checks. It does not find logic bugs that require understanding your business rules.

Installation

Slither requires Python 3.8+ and a Solidity compiler. The recommended installation method is pip:

pip3 install slither-analyzer

You also need solc (the Solidity compiler) installed. The easiest approach is solc-select:

pip3 install solc-select
solc-select install 0.8.24
solc-select use 0.8.24

For projects using Hardhat or Foundry, Slither can use their configurations directly:

# Hardhat project
slither . --hardhat-compile

# Foundry project  
slither . --foundry-compile-all

Verify the installation:

slither --version
# slither 0.10.x

Running Your First Analysis

Navigate to your project root and run:

slither .

Slither will compile your contracts (or use existing compilation artifacts) and output findings to stdout. A typical first run on a real project produces a lot of output — often dozens of findings. Don't be alarmed. Most will be low-severity or false positives from third-party contracts.

For a Hardhat project with OpenZeppelin contracts, you almost always want to filter to your own code:

slither . --filter-paths "node_modules,lib" --hardhat-compile

For a Foundry project:

slither . --filter-paths "lib/" --foundry-compile-all

Understanding the Output

Each finding follows this format:

Contract.functionName(contract.sol#42) uses arbitrary from in transferFrom: ERC20.transferFrom(from,to,amount) (contracts/MyToken.sol#87)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#arbitrary-from-in-transferfrom

The format gives you:

  1. The affected contract and function
  2. The file and line number
  3. A link to the detector documentation explaining the vulnerability

Severity levels:

  • High: Likely exploitable vulnerabilities. Fix before deploying.
  • Medium: Potentially exploitable under certain conditions. Review carefully.
  • Low: Best practice violations or patterns that could cause issues.
  • Informational: Code quality issues, missing events, optimization opportunities.

The Most Important Detectors

Slither ships with 90+ detectors. These are the ones that matter most for production contracts:

reentrancy-eth: Detects functions that make external calls before updating state, enabling reentrancy attacks.

// Vulnerable pattern Slither will catch:
function withdraw(uint amount) external {
    require(balances[msg.sender] >= amount);
    (bool success, ) = msg.sender.call{value: amount}(""); // External call FIRST
    balances[msg.sender] -= amount; // State update AFTER — reentrancy risk
    require(success);
}

arbitrary-send-eth: Catches functions that send ETH to a caller-controlled address without sufficient access controls.

unchecked-transfer: Flags ERC-20 transfer and transferFrom calls where the return value is not checked. This matters for non-standard tokens like USDT that return false instead of reverting.

uninitialized-local: Detects local variables used before assignment.

suicidal: Flags contracts where anyone can call selfdestruct.

delegatecall-loop: Catches dangerous patterns where delegatecall is used inside a loop.

Triage: Dealing with False Positives

Slither is not perfect. It produces false positives, particularly for:

  • Checked external calls: Slither may flag a call as reentrancy-risky even if you use a reentrancy guard
  • Intentional patterns: Some low-level patterns that look dangerous are intentional (proxy contracts, for example)
  • Third-party code: Library code often has patterns that trigger detectors

The correct way to handle false positives is with inline annotations, not by disabling detectors globally:

// slither-disable-next-line reentrancy-eth
function safeWithdraw(uint amount) external nonReentrant {
    // nonReentrant guard makes this safe, but Slither doesn't know that
    (bool success, ) = msg.sender.call{value: amount}("");
    balances[msg.sender] -= amount;
    require(success);
}

You can also disable detectors for an entire file:

// slither-disable-start reentrancy-eth
// ... contract code ...
// slither-disable-end reentrancy-eth

For project-wide suppressions (use sparingly), create a .slither.config.json:

{
  "detectors_to_exclude": "naming-convention",
  "filter_paths": "node_modules,lib,test"
}

Writing Custom Detectors

The real power of Slither is extensibility. When you have project-specific invariants — "this function must always emit an event", "only addresses in the whitelist mapping can call these functions" — you can write detectors to enforce them.

A custom detector is a Python class that inherits from AbstractDetector:

# detectors/no_direct_eth_transfer.py
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.core.cfg.node import NodeType

class NoDirectEthTransfer(AbstractDetector):
    """
    Detect functions that use transfer() or send() instead of call{value:}()
    Our contracts should use call() for forward-compatible gas handling.
    """

    ARGUMENT = "no-direct-eth-transfer"
    HELP = "Use call{value:}() instead of transfer() or send()"
    IMPACT = DetectorClassification.MEDIUM
    CONFIDENCE = DetectorClassification.HIGH

    WIKI = "https://docs.myproject.com/security/eth-transfers"
    WIKI_TITLE = "Avoid transfer() and send()"
    WIKI_DESCRIPTION = "transfer() and send() forward only 2300 gas, which may break with EIP-1884."
    WIKI_EXPLOIT_SCENARIO = "After EIP-1884, transfer() calls to contracts that read storage will fail."
    WIKI_RECOMMENDATION = "Use call{value: amount}('') instead."

    def _detect(self):
        results = []
        
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                for node in function.nodes:
                    for ir in node.irs:
                        # Look for HIGH_LEVEL_CALL to transfer or send
                        if hasattr(ir, 'function') and ir.function:
                            if ir.function.name in ['transfer', 'send']:
                                # Check if it's an ETH transfer (no ERC-20)
                                if hasattr(ir, 'value') and ir.value:
                                    info = [
                                        function,
                                        " uses transfer() or send() at ",
                                        node,
                                        "\n",
                                    ]
                                    results.append(self.generate_result(info))
        
        return results

Register it in your Slither config:

{
  "custom_detectors_path": "./detectors"
}

Run with your custom detector:

slither . --detect no-direct-eth-transfer

A more practical custom detector checks for missing access control on sensitive functions:

class MissingAccessControl(AbstractDetector):
    ARGUMENT = "missing-access-control"
    HELP = "Sensitive functions lack access control modifiers"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    # Functions that should have access control
    SENSITIVE_PREFIXES = ["set", "update", "upgrade", "pause", "unpause", "withdraw", "mint", "burn"]

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions_and_modifiers_declared:
                if not function.is_constructor and function.visibility in ["public", "external"]:
                    name_lower = function.name.lower()
                    is_sensitive = any(name_lower.startswith(p) for p in self.SENSITIVE_PREFIXES)
                    
                    if is_sensitive:
                        # Check for common access control modifiers
                        has_access_control = any(
                            m.name in ["onlyOwner", "onlyAdmin", "onlyRole", "requiresAuth", "authorized"]
                            for m in function.modifiers
                        )
                        
                        if not has_access_control:
                            info = [
                                "Function ",
                                function,
                                " appears sensitive but lacks access control modifiers\n",
                            ]
                            results.append(self.generate_result(info))
        
        return results

CI Integration

Running Slither on every pull request is straightforward. Here's a GitHub Actions workflow:

# .github/workflows/slither.yml
name: Slither Analysis

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main]

jobs:
  slither:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm ci

      - name: Run Slither
        uses: crytic/slither-action@v0.4.0
        id: slither
        with:
          node-version: 20
          slither-args: "--filter-paths node_modules --exclude naming-convention,solc-version"
          sarif: results.sarif
          fail-on: high

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif

The fail-on: high setting makes the CI step fail if any high-severity findings are detected, blocking the merge. The SARIF upload integrates results into GitHub's Security tab, where you can track, dismiss, and annotate findings.

For Foundry projects, adjust the action config:

- name: Run Slither
  uses: crytic/slither-action@v0.4.0
  with:
    slither-args: >
      --filter-paths lib/
      --foundry-compile-all
      --exclude naming-convention
    fail-on: medium

Generating Machine-Readable Output

For integration with other tools or custom reporting, Slither supports JSON and SARIF output:

# JSON output
slither . --json slither-report.json --filter-paths node_modules

# SARIF for GitHub/GitLab integration  
slither . --sarif slither-report.sarif --filter-paths node_modules

# Summary only (for humans in CI logs)
slither . --print human-summary --filter-paths node_modules

The JSON report is useful for building dashboards or tracking findings over time:

import json

with open("slither-report.json") as f:
    report = json.load(f)

high_findings = [
    r for r in report["results"]["detectors"]
    if r["impact"] == "High"
]

print(f"High severity findings: {len(high_findings)}")
for finding in high_findings:
    print(f"  [{finding['check']}] {finding['description'][:100]}")

Combining Slither with Unit Tests

Slither and unit tests are complementary, not competing. The ideal workflow:

  1. Slither runs first (in CI pre-commit or on PR): catches structural issues, known bad patterns, missing checks. Fast — runs in seconds.
  2. Unit tests run next: verify your business logic is correct. These test what your code should do.
  3. Fuzzing runs overnight or on merge: Echidna or Foundry's fuzzer tries to break invariants that neither Slither nor hand-written tests cover.

A vulnerability that Slither catches never needs a unit test to expose it — but writing unit tests that attempt to exploit the vulnerability Slither found is valuable for confirming the fix is correct:

describe("Reentrancy fix verification", () => {
  it("should not be vulnerable to reentrancy after fix", async () => {
    const attacker = await deployReentrancyAttacker(vault.address);
    
    // Deposit 1 ETH from attacker
    await attacker.deposit({ value: ethers.parseEther("1") });
    
    // Attempt reentrancy attack — should fail or not drain funds
    await expect(attacker.attack()).to.not.changeEtherBalance(
      vault,
      ethers.parseEther("-10") // Should not drain beyond attacker's deposit
    );
  });
});

Slither Printers: Code Analysis Beyond Security

Slither's --print option generates various code analysis reports that are useful outside of security auditing:

# Print the call graph to a dot file
slither . --print call-graph

# List all functions with their visibility and modifiers
slither . --print function-summary

# Show data dependencies (what state variables does each function read/write)
slither . --print data-dependency

# Contract inheritance summary
slither . --print inheritance-graph

# Find functions that are never called internally (potential dead code)
slither . --print unused-state

The call graph in particular is invaluable for auditing complex systems with many contracts — you can visualize which contracts call which, find unexpected dependency chains, and identify functions that should be restricted but aren't.

Keeping Analysis Current with Continuous Monitoring

Running Slither in CI catches new vulnerabilities as code changes. But deployed contracts also interact with an evolving ecosystem — new reentrancy vectors from protocol integrations, changed external contract behavior, newly discovered detector patterns from Slither updates.

HelpMeTest provides a layer of continuous behavioral monitoring that complements static analysis: while Slither catches what your code looks like, behavioral monitoring tracks what your deployed contracts actually do in production. Together they give you defense-in-depth across both the development phase and post-deployment.

Summary

Slither should be a standard part of every Solidity development workflow. The setup cost is low — a GitHub Actions workflow and a pip install — but the value is high: it catches entire classes of well-documented vulnerabilities automatically, before review, before audit, before deployment.

The key practices:

  • Run Slither against your own code only — filter out node_modules and lib/
  • Treat high-severity findings as blocking
  • Use inline // slither-disable-next-line annotations for confirmed false positives, not global disables
  • Write custom detectors for project-specific invariants
  • Combine with unit tests and fuzzing for comprehensive coverage

A project that regularly runs Slither, fixes high and medium findings, and has reviewed low findings has addressed the most common smart contract vulnerability patterns before any human auditor looks at the code.

Read more

Start now free