Beyond the CLI: Hacking Smart Contracts with the Slither API

11 min read

May 4, 2025

Beyond the CLI: Hacking Smart Contracts with the Slither API

Table of contents

Introduction

In the previous chapter of this Web3 hacking series, I introduced one of the most widely used tools: Slither.
Today, we're diving deeper by exploring its API. This API is extremely powerful and allows us to build custom tools to detect vulnerabilities in Ethereum smart contracts.

However, there’s one small catch: the documentation isn’t great. Especially when it comes to examples. Because of that, I had to spend some time not only reading the official docs but also digging into Slither’s GitHub repository to better understand how to use it.

With that said, let's dive into this little research project!

Basic Recon

To interact with Slither's API more easily, we'll be using IPython3.
Since the documentation is limited, IPython’s introspection capabilities make it much quicker to explore available functions and attributes on the fly.

Our first task will be to enumerate the contracts detected in a project.
I’ll be using the contracts we developed a few weeks ago for this demonstration.
The syntax is pretty self-explanatory, so I’ll focus on commenting on the parts that I think will be most useful. 😄

Here's how to get started:

from slither.slither import Slither

slither = Slither('.')  # Load Slither in the current directory

for contract in slither.contracts:
    print(contract.name)

Output:

console
GrimoireOfEchoes
OracleOfWhispers

Behind the scenes, Slither builds a full object model of your Solidity code.
This is important to understand as we go forward, especially when building custom tools or detectors.

Here’s what you're working with under the hood:

  • slither.contracts: a list of all smart contracts found in the project. Each one is a Contract object.
  • contract.functions: all functions inside a contract including public, internal, and constructors.
  • contract.state_variables: lets you inspect the contract’s storage layout.
  • function.nodes: each function is broken down into control-flow blocks.
  • node.irs: every node contains IR (intermediate representation) instructions. This is where things like low-level calls, assignments, and expressions live.

As you explore the API, you'll be mostly traversing this structure jumping from contracts to functions, then diving into nodes and instructions when needed.

Let’s now try inspecting some of these pieces, starting with listing the functions of a specific contract.

Exploring Functions and Variables

We can list all the functions in a specific contract like this:

for contract in slither.contracts:
    if contract.name == "GrimoireOfEchoes":
        for function in contract.functions:
            print(function.name)

Output:

constructor
channelMana
notUsed
releaseEssence
amplifySpirits
invokeOracle

Similarly, we can enumerate state variables:

for contract in slither.contracts:
    if contract.name == "GrimoireOfEchoes":
        for var in contract.state_variables:
            print(var.name)

Output:

manaReservoir
corruptionIndex
forbiddenTithe
oracle

Finding Unused Variables

Using Slither’s API, we can check which variables are being read or written inside the contract:

contract = slither.contracts[0]

for var in contract.state_variables:
    print(f"Variable: {var.name}")
    readers = contract.get_functions_reading_from_variable(var)
    writers = contract.get_functions_writing_to_variable(var)
    print(f"Writers: {len(writers)}")
    print(f"Readers: {len(readers)}\n")

Output:

Variable: manaReservoir
Writers 3
Readers 3
Variable: corruptionIndex
Writers 1
Readers 1
Variable: forbiddenTithe
Writers 1
Readers 0
Variable: oracle
Writers 1
Readers 1

This is super useful for spotting unused variables, which you can later report as informational findings.

Checking Function Documentation

Another useful thing is to verify which functions are documented:

for function in contract.functions:
    print(f"Function {function.name} is documented? {function.has_documentation}")

Output:

Function constructor is documented ? False
Function channelMana is documented ? False
Function notUsed is documented ? False
Function releaseEssence is documented ? True
Function amplifySpirits is documented ? False
Function invokeOracle is documented ? False

Functions that lack proper documentation (especially NatSpec) can also be flagged as informational issue for clients.

Listing Function Parameters

You might also want to inspect function parameters:

for function in contract.functions:
    print(f"Function {function.name}")
    if function.parameters:
        print(f"Parameters: {', '.join(str(p) for p in function.parameters)}\n")
    else:
        print("No parameters\n")

Output:

Function constructor
Parameters: _oracle

Function channelMana
No parameters

Function notUsed
No parameters

Function releaseEssence
No parameters

Function amplifySpirits
Parameters: spirits

Function invokeOracle
No parameters

Finding Dead Internal Functions

Here's a small script I built to find internal or private functions that are never called:

def get_dead_internal_functions(slither, contract_names=None):
    dead_functions = set()

    for contract in slither.contracts:
        if contract_names and contract.name not in contract_names:
            continue

        internal_functions = {
            f.name for f in contract.functions if f.visibility in ["internal", "private"]
        }
        used_internal_functions = set()

        for entry_point in contract.functions:
            if entry_point.visibility in ["public", "external"]:
                for call in entry_point.all_internal_calls():
                    if hasattr(call, "function") and hasattr(call.function, "name"):
                        used_internal_functions.add(call.function.name)

        dead_functions.update(internal_functions - used_internal_functions)

    return dead_functions

Usage:

slither = Slither('.')
get_dead_internal_functions(slither, "GrimoireOfEchoes")

Improving Output with Rich

Raw output from Slither can be quite rough.
Luckily, we can greatly improve it by using the Python library Rich to create clean tables:

from rich.console import Console
from rich.table import Table

def list_contracts_and_files(slither):
    console = Console()
    table = Table(title="Contracts and Source Files", show_lines=True)
    table.add_column("Contract Name", style="bold cyan")
    table.add_column("File Path", style="magenta")

    for contract in slither.contracts:
        filename = str(contract.source_mapping.filename.short)
        table.add_row(contract.name, filename)

    console.print(table)
Rich output

Building a Custom Detector

Before jumping into the code, let’s take a moment to understand what a custom detector really is in Slither.

At its core, a detector is just a Python class that inspects the internal structure Slither builds when parsing your Solidity code. The cool part is that you don’t need to hack anything into Slither itself. The API gives you everything you need to analyze contracts, functions, variables, and even control flow.

Here’s what’s essential to know before writing one:

  • You’ll subclass AbstractDetector, the base class for all Slither detectors.
  • The core logic goes inside a method called _detect(), which Slither automatically runs.
  • From there, you’ll iterate over slither.contracts, access contract.functions, and dive into control flow or IR when needed.
  • To report an issue, you call self.generate_result(...) with the details you want to display.

So in short: you're just walking Slither’s internal object model, which we’ve already explored, and describing what to flag.

An example of a Custom Detector could be the next one:

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

class UnusedInternalFunctionDetector(AbstractDetector):
    ARGUMENT = "unused-internal"
    HELP = "Detects internal/private functions that are never used"
    IMPACT = DetectorClassification.LOW
    CONFIDENCE = DetectorClassification.HIGH

    WIKI = "x"

    WIKI_TITLE = "Unused internal or private functions"
    WIKI_DESCRIPTION = "Detects internal or private functions that are never used by any public or external functions in the contract."
    WIKI_EXPLOIT_SCENARIO = (
        "A contract has several internal functions written for reuse, "
        "but they are never actually called. This unnecessarily bloats the bytecode "
        "and may confuse future developers."
    )
    WIKI_RECOMMENDATION = (
        "Remove unused internal or private functions to simplify the contract and reduce bytecode size."
    )

    def _detect(self):
        results = []

        for contract in self.slither.contracts:
            internal_funcs = {
                f.name: f
                for f in contract.functions
                if f.visibility in ["internal", "private"]
            }

            used_funcs = {
                call.function.name
                for f in contract.functions
                if f.visibility in ["public", "external"]
                for call in f.all_internal_calls()
                if hasattr(call, "function") and hasattr(call.function, "name")
            }

            for name, func in internal_funcs.items():
                if name not in used_funcs:
                    info = [f"Unused internal function `{name}` in contract `{contract.name}`"]
                    results.append(self.generate_result(info, func))

        return results

You can register and run the detector like this:

from detectors.custom_detector import UnusedInternalFunctionDetector
from slither.slither import Slither

slither = Slither('.')
slither.register_detector(UnusedInternalFunctionDetector)
slither.run_detectors()

Improving Slither CLI Output

If you want even cleaner output for all Slither’s built-in detectors, you can dynamically register all detectors and print findings in a table.

Example:

import importlib
import inspect
from slither.slither import Slither
from slither.detectors import all_detectors
from slither.detectors.abstract_detector import AbstractDetector
from rich.console import Console
from rich.table import Table

console = Console()
slither = Slither(".")

for name in dir(all_detectors):
    obj = getattr(all_detectors, name)
    if inspect.isclass(obj) and issubclass(obj, AbstractDetector) and obj is not AbstractDetector:
        slither.register_detector(obj)

results = slither.run_detectors()

if results:
    table = Table(title="🔎 Slither Analysis Results", show_lines=True)
    table.add_column("Check", style="bold magenta")
    table.add_column("Impact", style="bold yellow")
    table.add_column("Confidence", style="green")
    table.add_column("Description", style="")

    for group in results:
        for issue in group:
            table.add_row(
                issue.get("check", "N/A"),
                issue.get("impact", "N/A"),
                issue.get("confidence", "N/A"),
                issue.get("description", "No description")
            )
    console.print(table)
else:
    console.print("[bold green]✅ No issues found!")

        ```
Improving the default output of Slither

Building a More Complex Detector: Gas Griefing

Gas griefing is a subtle but dangerous vulnerability pattern in Solidity smart contracts. It happens when a function performs a low-level external call (like .call()) and then updates the contract’s state only if the call succeeds.
This can be exploited by an attacker who forces the external call to fail repeatedly, for example by consuming too much gas or triggering a revert. As a result, the state-changing logic never runs, potentially locking funds or disrupting the contract’s behavior.

Since this type of pattern isn’t detected by default in Slither, writing a custom detector is a great way to identify it across large codebases.

Here’s what this custom detector does:

  • It goes through all public and external functions in the codebase.
  • It searches for low-level calls, using Slither’s intermediate representation (LowLevelCall).
  • Then it checks whether there are state changes that only occur if the call succeeds, which is a red flag.
  • If that conditional logic is found, the detector raises a finding with the relevant details.

To build this, we rely more heavily on Slither’s IR and control flow structures, especially the nodes inside each function and the IR instructions (irs) they contain. But the logic still follows the same pattern you’ve seen before: loop through contracts and functions, analyze what’s happening, and collect the results.

Let’s look at the code.

from typing import List

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.utils.output import Output
from slither.core.declarations import Function
from slither.slithir.operations.low_level_call import LowLevelCall
from typing import List
from slither.utils.output import Output


class GasGriefingDetector(AbstractDetector):
    ARGUMENT = "gas-griefing"
    HELP = "Detect gas griefing vulnerabilities due to conditional state logic after low-level calls"
    IMPACT = DetectorClassification.MEDIUM
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://example.com/wiki/gas-griefing"
    WIKI_TITLE = "Gas Griefing"
    WIKI_DESCRIPTION = (
        "Detects functions that modify state after a low-level call only if the call succeeded, "
        "which could allow griefing attacks if the external call consistently fails."
    )
    WIKI_EXPLOIT_SCENARIO = (
        "An attacker interacts with a function that increases a counter before calling an external oracle. "
        "If the call fails, the counter is never decremented, eventually corrupting state."
    )
    WIKI_RECOMMENDATION = (
        "Ensure external calls do not conditionally affect state in a way that could be abused "
        "by repeated failure or reverting of the external call."
    )


    def _detect(self) -> List[Output]:
        results = []

        for contract in self.contracts:
            for function in contract.functions_and_modifiers_declared:

                if function.is_implemented and function.visibility in {"public", "external"}:

                    low_level_calls = self._find_low_level_calls(function)
                    if low_level_calls and self._has_conditional_state_write(function):
                        info = [
                            f"Function '{function.full_name}' in contract '{contract.name}' may be vulnerable to gas griefing.\n",
                            "Detected low-level call(s) followed by conditional state modification.\n"
                        ]

                        for call in low_level_calls:
                            src_map = call.node.source_mapping

                            if src_map and src_map.lines:
                                filename = src_map.filename.short
                                line_number = src_map.lines[0]
                                code_snippet = function.nodes[0].source_mapping.content
                                info.append(f"  ↪ Low-level call at {filename}:{line_number}\n")
                            else:
                                info.append("  ↪ Low-level call at unknown location")

                        # Resultado para Slither
                        res = self.generate_result(info)
                        res.add(function)
                        for call in low_level_calls:
                            if code_snippet:
                                res.add(call.node, {"type": "low_level_call", "code": f"function {function.name}({" ".join(function.parameters)}) {function.visibility}\n{code_snippet.strip()}\n"})
                        results.append(res)
        return results

    def _find_low_level_calls(self, function: Function) -> List[LowLevelCall]:
        calls = []
        for node in function.nodes:
            for ir in node.irs:
                if isinstance(ir, LowLevelCall):
                    calls.append(ir)
        return calls

    def _has_conditional_state_write(self, function: Function) -> bool:
        """
        Checks if any node inside a conditional block modifies state
        """
        for node in function.nodes:
            if node.son_true or node.son_false:  # Is inside an if/else
                for ir in node.irs:
                    return True
        return False


After creating the detector, we first need to load it into Slither, and then we can use a simple script to run the detectors and print a clean table with the results using Rich.

def run_custom_detectors(slither, min_impact=None, min_confidence=None):
    from rich.console import Console
    from rich.table import Table
    from rich.panel import Panel

    console = Console()

    results = slither.run_detectors()

    # Set priority levels
    levels = {"low": 1, "medium": 2, "high": 3}
    impact_threshold = levels.get(min_impact, 0)
    confidence_threshold = levels.get(min_confidence, 0)

    def is_valid(issue):
        impact = levels.get(issue.get("impact", "").lower(), 0)
        confidence = levels.get(issue.get("confidence", "").lower(), 0)
        return impact >= impact_threshold and confidence >= confidence_threshold

    filtered_results = [
        [issue for issue in group if is_valid(issue)] for group in results
    ]
    filtered_results = [group for group in filtered_results if group]

    if filtered_results:
        table = Table(title="Custom Detector Results", show_lines=True)
        table.add_column("Check", style="bold magenta")
        table.add_column("Impact", style="bold yellow")
        table.add_column("Confidence", style="green")
        table.add_column("Description", style="")

        for group in filtered_results:
            for issue in group:
                table.add_row(
                    issue.get("check", "N/A"),
                    issue.get("impact", "N/A"),
                    issue.get("confidence", "N/A"),
                    issue.get("description", "No description"),
                )
        console.print(table)
    else:
        console.print(Panel("[bold green]✅ No issues found by custom detectors!", title="All Clear"))

Then, you can run the following functions to display the findings nicely:

from slither.slither import Slither
from detectors.custom_detector import GasGriefingDetector

slither = Slither('.')
slither.register_detector(GasGriefingDetector)
run_custom_detectors(slither)
Custom detector output

If we want an even better report, we can use Rich’s syntax highlighting to display the actual Solidity code of the vulnerable function directly in the terminal.

This works especially well if your custom detector includes additional fields in its results. For example, a snippet of the code where the issue occurs.
When calling generate_result(...), you can attach custom data (like the Solidity source) using the additional_fields argument. This way, your reporting function can extract that information and render it beautifully with Rich.

Here’s the improved version of the reporting function that takes advantage of this feature:

def run_custom_detectors(slither, min_impact=None, min_confidence=None):
    from rich.panel import Panel
    from rich.syntax import Syntax
    from rich.rule import Rule
    console = Console()

    results = slither.run_detectors()

    # Filtros en orden de prioridad
    levels = {"low": 1, "medium": 2, "high": 3}
    impact_threshold = levels.get(min_impact, 0)
    confidence_threshold = levels.get(min_confidence, 0)

    def is_valid(issue):
        impact = levels.get(issue.get("impact", "").lower(), 0)
        confidence = levels.get(issue.get("confidence", "").lower(), 0)
        return impact >= impact_threshold and confidence >= confidence_threshold

    filtered_results = [
        [issue for issue in group if is_valid(issue)] for group in results
    ]
    filtered_results = [group for group in filtered_results if group]

    if filtered_results:
        console.print(Rule("Custom Detector Report"))

        for group in filtered_results:
            for issue in group:
                console.print(f"[bold magenta]Check:[/] {issue.get('check', 'N/A')}")
                console.print(f"[bold yellow]Impact:[/] {issue.get('impact', 'N/A')}")
                console.print(f"[bold green]Confidence:[/] {issue.get('confidence', 'N/A')}")
                console.print(f"[bold]Description:[/] {issue.get('description', 'No description')}")
                
                # Search for 'code' in additional_fields
                elements = issue.get('elements', [])
                code_snippet = None
                for element in elements:
                    additional = element.get('additional_fields', {})
                    code_snippet = additional.get('code')
                    if code_snippet:
                        break
                if code_snippet:
                    console.print("\n[bold cyan]Code Snippet:[/]\n")
                    syntax = Syntax(code_snippet, "solidity", line_numbers=True, theme="dracula")
                    console.print(syntax)
                
                console.print(Rule(style="dim"))

    else:
        console.print(Panel("[bold green]✅ No issues found by custom detectors!", title="All Clear"))
Code Highlight

Conclusions

In this chapter, we explored several ways to use Slither’s API, from basic recon to building custom detectors.
We also learned how to enhance Slither's output using Python’s Rich library.

There's so much potential with Slither’s API. I encourage you to experiment and see what cool tools you can build!
See you in the next chapter.

References

Trail of Bits. "Slither API Documentation: Python Interface for Static Analysis." Available at: https://crytic.github.io/slither/

Chapters

Botón Anterior
Slither: Your First Line of Defense in Smart Contract Security

Previous chapter

Enjoyed the article?

Subscribe to the newsletter and get technical insights, cybersecurity tips, and development content straight to your inbox. Or support my work with a coffee ☕ if you found it useful!

📫 Subscribe now ☕ Buy me a coffee