Beyond the CLI: Hacking Smart Contracts with the Slither API
11 min read
May 4, 2025

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 aContract
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)

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
, accesscontract.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!")
```

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)

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"))

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

Previous chapter