2026-02-27·15 min read

Slither beyond defaults: writing custom detectors for your protocol

Slither beyond defaults: writing custom detectors for your protocol

Everyone runs slither . and reads the output. That's fine for a first pass. But if that's all you're doing, you're leaving a lot on the table. Slither's real power is in its extensibility — you can write custom detectors in Python that check for protocol-specific vulnerability patterns that no generic tool will ever catch.

I've written dozens of custom detectors for audits. Here's everything I've learned about doing it well.

Why custom detectors?

Slither ships with ~90 built-in detectors. They catch common issues: reentrancy, uninitialized storage, shadowing, etc. Solid baseline stuff.

But DeFi protocols don't break in generic ways. They break in protocol-specific ways:

  • A lending protocol might have an invariant that collateral factor can never exceed 90%
  • A DEX might need to verify that fee calculations always round in the protocol's favor
  • A governance contract might require that voting power snapshots happen before token transfers

No built-in detector checks for those. But a custom detector can, and it'll catch violations across every code path automatically.

Slither architecture: the 30-second version

Slither works in layers:

  1. Solidity source → parsed by solc
  2. AST → Slither builds its own internal representation
  3. SlithIR → A simplified intermediate representation (like SSA form)
  4. Detectors → Python classes that analyze the IR and report findings
  5. Printers → Python classes that output information (call graphs, inheritance, etc.)

Your custom detector operates at layer 4. You get access to all contracts, functions, state variables, and the SlithIR instructions. You traverse this data, look for patterns, and emit findings.

Setting up your environment

Install Slither with the development extras:

pip install slither-analyzer

For custom detector development, I recommend cloning the repo so you can reference existing detectors:

git clone https://github.com/crytic/slither.git
ls slither/slither/detectors/

That detectors directory is your reference library. Every built-in detector follows the same pattern you'll use.

Anatomy of a detector

Every detector is a Python class that inherits from AbstractDetector. Here's the skeleton:

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.core.declarations import Function

class MyCustomDetector(AbstractDetector):
    ARGUMENT = "my-custom-check"  # CLI flag name
    HELP = "Checks for my custom pattern"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.HIGH

    WIKI = "https://example.com/wiki"
    WIKI_TITLE = "My Custom Check"
    WIKI_DESCRIPTION = "Describes what this detector checks"
    WIKI_EXPLOIT_SCENARIO = "Example of the vulnerability"
    WIKI_RECOMMENDATION = "How to fix it"

    def _detect(self):
        results = []
        # Your analysis logic here
        return results

The _detect method is where the magic happens. It returns a list of findings, each one an Output object.

Understanding SlithIR

Before you write a detector, you need to understand SlithIR. It's the intermediate representation that makes analysis tractable. Instead of dealing with raw Solidity AST nodes (which are messy), you work with clean typed instructions.

Key SlithIR instruction types:

from slither.slithir.operations import (
    HighLevelCall,    # External contract calls
    LowLevelCall,     # .call(), .delegatecall()
    InternalCall,     # Internal function calls
    Transfer,         # ETH transfers
    Assignment,       # Variable assignments
    Binary,           # Arithmetic/comparison ops
    Condition,        # Branch conditions
    Return,           # Return statements
    SolidityCall,     # Built-in calls (require, assert, etc.)
)

To explore what SlithIR looks like for a contract:

slither src/MyContract.sol --print slithir

This dumps the IR for every function. Study this output before writing detectors — it shows you exactly what data you'll have to work with.

Example 1: detecting missing fee-on-transfer handling

Let's write a real detector. Many protocols forget to account for tokens that charge a fee on transfer. They call transferFrom and assume they received the full amount, but fee-on-transfer tokens deliver less.

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.slithir.operations import HighLevelCall, Assignment
from slither.core.declarations import Function, Contract
from slither.core.variables.state_variable import StateVariable


class FeeOnTransferCheck(AbstractDetector):
    ARGUMENT = "fee-on-transfer"
    HELP = "Detects missing balance checks after token transfers"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://example.com/fee-on-transfer"
    WIKI_TITLE = "Missing fee-on-transfer accounting"
    WIKI_DESCRIPTION = (
        "Detects patterns where transferFrom is called but the actual "
        "received amount is not verified via balance checks."
    )
    WIKI_EXPLOIT_SCENARIO = (
        "A vault calls transferFrom(user, vault, amount) and credits "
        "the user with 'amount' shares, but a fee-on-transfer token "
        "only delivers amount - fee to the vault."
    )
    WIKI_RECOMMENDATION = (
        "Check balanceOf before and after transferFrom, and use the "
        "difference as the actual received amount."
    )

    def _detect(self):
        results = []

        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                if function.is_constructor or function.is_fallback:
                    continue

                transfer_from_calls = []
                has_balance_check = False

                for node in function.nodes:
                    for ir in node.irs:
                        # Look for transferFrom calls
                        if (
                            isinstance(ir, HighLevelCall)
                            and hasattr(ir, "function_name")
                            and ir.function_name == "transferFrom"
                        ):
                            transfer_from_calls.append(node)

                        # Look for balanceOf calls (indicates proper accounting)
                        if (
                            isinstance(ir, HighLevelCall)
                            and hasattr(ir, "function_name")
                            and ir.function_name == "balanceOf"
                        ):
                            has_balance_check = True

                # If we found transferFrom without balanceOf, flag it
                if transfer_from_calls and not has_balance_check:
                    for node in transfer_from_calls:
                        info = [
                            function,
                            " calls transferFrom without checking actual received amount\n",
                            "\tNode: ",
                            node,
                            "\n",
                        ]
                        res = self.generate_result(info)
                        results.append(res)

        return results

This detector walks through every function, looks for transferFrom calls, and checks whether the function also calls balanceOf. It's a heuristic, not every transferFrom without balanceOf is a bug, but it catches the pattern effectively.

Example 2: checking for unprotected state changes

Here's a more advanced detector that checks whether state-modifying functions have access control:

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.slithir.operations import SolidityCall, Binary, Condition
from slither.core.declarations import Function
from slither.core.expressions import CallExpression


class UnprotectedStateChange(AbstractDetector):
    ARGUMENT = "unprotected-state"
    HELP = "Detects state changes in functions without access control"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://example.com/unprotected-state"
    WIKI_TITLE = "Unprotected state modification"
    WIKI_DESCRIPTION = "Functions that modify critical state variables without access control checks."
    WIKI_EXPLOIT_SCENARIO = "Anyone can call the function and modify protocol parameters."
    WIKI_RECOMMENDATION = "Add appropriate access control modifiers (onlyOwner, onlyAdmin, etc.)."

    # State variables that should be protected
    CRITICAL_PATTERNS = [
        "fee", "rate", "price", "oracle", "admin", "owner",
        "paused", "limit", "threshold", "config"
    ]

    def _has_access_control(self, function):
        """Check if function has msg.sender checks or access control modifiers."""
        # Check modifiers
        access_modifiers = ["onlyOwner", "onlyAdmin", "onlyRole", "onlyGovernance"]
        for modifier in function.modifiers:
            if any(am in modifier.name for am in access_modifiers):
                return True

        # Check for require(msg.sender == ...) patterns
        for node in function.nodes:
            for ir in node.irs:
                if isinstance(ir, SolidityCall):
                    if "require" in str(ir.function) or "revert" in str(ir.function):
                        # Check if msg.sender is referenced in this node
                        if "msg.sender" in str(node):
                            return True
        return False

    def _modifies_critical_state(self, function):
        """Check if function writes to critical state variables."""
        modified_vars = []
        for var in function.state_variables_written:
            var_name = var.name.lower()
            for pattern in self.CRITICAL_PATTERNS:
                if pattern in var_name:
                    modified_vars.append(var)
                    break
        return modified_vars

    def _detect(self):
        results = []

        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                if (
                    function.is_constructor
                    or function.is_fallback
                    or function.visibility in ["internal", "private"]
                ):
                    continue

                critical_vars = self._modifies_critical_state(function)
                if critical_vars and not self._has_access_control(function):
                    for var in critical_vars:
                        info = [
                            function,
                            " modifies critical state variable ",
                            var,
                            " without access control\n",
                        ]
                        res = self.generate_result(info)
                        results.append(res)

        return results

This detector looks for a pattern I see in almost every audit: someone adds a setter function for a critical parameter and forgets the onlyOwner modifier. Generic reentrancy detectors won't catch this. Custom detectors will.

Running custom detectors

Save your detector as a Python file and point Slither at it:

slither src/ --detect my-custom-check --plugin-dir ./custom_detectors/

Or if you've structured it as a proper Python package:

# In your custom_detectors/ directory, create:
# __init__.py
# fee_on_transfer.py
# unprotected_state.py

slither src/ --plugin-dir ./custom_detectors/

Slither auto-discovers detector classes in the plugin directory. No registration needed, just inherit from AbstractDetector and the framework picks it up.

Traversing the AST

Sometimes SlithIR isn't enough and you need the raw AST. Slither gives you access:

for contract in self.compilation_unit.contracts_derived:
    # Contract-level info
    print(contract.name)
    print(contract.state_variables)
    print(contract.functions)
    print(contract.modifiers)
    print(contract.inheritance)

    for function in contract.functions:
        # Function-level info
        print(function.name)
        print(function.visibility)
        print(function.parameters)
        print(function.return_type)
        print(function.state_variables_read)
        print(function.state_variables_written)
        print(function.external_calls_as_expressions)

        # Node-level (control flow graph)
        for node in function.nodes:
            print(node.type)           # NodeType enum
            print(node.expression)     # The Solidity expression
            print(node.irs)            # SlithIR instructions
            print(node.sons)           # Successor nodes in CFG
            print(node.fathers)        # Predecessor nodes in CFG

The control flow graph (node.sons / node.fathers) is useful for path-sensitive analysis. If you need to check whether a require always executes before a state write, traverse the CFG from the write node backward and verify the require is on every path.

Printer plugins

Printers output structured information rather than vulnerability findings. They're great for generating audit documentation or feeding data into other tools.

from slither.printers.abstract_printer import AbstractPrinter


class StateVariableSummary(AbstractPrinter):
    ARGUMENT = "state-summary"
    HELP = "Prints a summary of all state variables and their access patterns"

    WIKI = "https://example.com/state-summary"

    def output(self, filename):
        info = ""
        for contract in self.slither.contracts_derived:
            info += f"\nContract: {contract.name}\n"
            info += "=" * 40 + "\n"

            for var in contract.state_variables:
                readers = [f.name for f in contract.functions if var in f.state_variables_read]
                writers = [f.name for f in contract.functions if var in f.state_variables_written]

                info += f"  {var.type} {var.name}\n"
                info += f"    Read by: {', '.join(readers) if readers else 'nobody'}\n"
                info += f"    Written by: {', '.join(writers) if writers else 'nobody'}\n"

        self.info(info)
        res = self.generate_output(info)
        return res

Run it:

slither src/ --print state-summary --plugin-dir ./custom_detectors/

This kind of output is gold during audits. You immediately see which functions touch which state, who can write to critical variables, and whether any state variables are written but never read (dead code).

CI/CD integration

Custom detectors really shine in CI. Set them up to run on every PR:

# .github/workflows/slither.yml
name: Slither Analysis
on: [push, pull_request]
jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Slither
        run: pip install slither-analyzer

      - name: Run built-in detectors
        run: slither src/ --json slither-report.json

      - name: Run custom detectors
        run: slither src/ --plugin-dir ./custom_detectors/ --json custom-report.json

      - name: Check for high-severity findings
        run: |
          HIGH_COUNT=$(cat custom-report.json | jq '[.results.detectors[] | select(.impact == "High")] | length')
          if [ "$HIGH_COUNT" -gt 0 ]; then
            echo "Found $HIGH_COUNT high-severity issues"
            exit 1
          fi

Now every PR gets checked against both generic and protocol-specific rules. When someone introduces a new function that modifies feeRate without access control, CI fails before it hits main.

Writing effective detectors: practical tips

Start with the finding, work backward. Don't think "what can I detect?" Think "what bug am I looking for?" Then figure out what code pattern that bug looks like in SlithIR.

Look at existing detectors. The built-in reentrancy detector is a masterclass in path-sensitive analysis. The unchecked-transfer detector shows how to track call return values. Read them. Copy their patterns.

Keep false positive rate low. A detector that fires on 50 findings with 45 false positives is worse than useless, people will ignore it. Better to miss some true positives than to drown developers in noise. Tune your CONFIDENCE level to match your actual detection precision.

Test your detector. Write a Solidity file with known-vulnerable and known-safe patterns. Verify your detector catches the former and ignores the latter.

# test_detector.py
from slither import Slither

def test_fee_on_transfer():
    slither = Slither("test/vulnerable.sol")
    detector = FeeOnTransferCheck(slither, slither.compilation_units[0], slither.logger)
    results = detector._detect()
    assert len(results) > 0, "Should detect missing fee-on-transfer check"

def test_fee_on_transfer_safe():
    slither = Slither("test/safe.sol")
    detector = FeeOnTransferCheck(slither, slither.compilation_units[0], slither.logger)
    results = detector._detect()
    assert len(results) == 0, "Should not flag safe pattern"

Complementary approaches

Custom Slither detectors catch static patterns. They're fast and precise but can't reason about runtime state. Pair them with:

  • Fuzzing to test dynamic properties that static analysis can't reach
  • Formal verification to prove invariants across all execution paths
  • Manual review for business logic issues that no tool catches

Think of custom detectors as your first line of defense, the cheap, fast check that catches the obvious stuff before you bring in the heavier tools.

What to build next

If you're working on a DeFi protocol, here are high-value custom detectors to consider:

  1. Rounding direction checker, verify that all divisions round in the protocol's favor
  2. Oracle staleness detector, flag price oracle reads without freshness checks
  3. Reentrancy with state, check for cross-function reentrancy via shared state variables
  4. Flash loan callback verification, ensure callback functions validate the caller
  5. Approval race condition, detect approve patterns vulnerable to front-running

Each one is 50-100 lines of Python. Small investment, big payoff.

The default detectors are a starting point, not the finish line. Build the detectors your protocol needs, wire them into CI, and catch bugs before they cost money.

Get a professional security audit

Try Recon Pro

Related Posts

Related Glossary Terms

Get protocol-specific security analysis