Slither is a Solidity and Vyper static analysis framework written in Python 3. It runs a suite of vulnerability detectors, prints visual information about contract details, and provides an API to easily write custom analyses. Most engineers interact with it through a single command — slither . — collect the colored output, triage a handful of reentrancy and access-control warnings, and move on. That is a perfectly valid first step. It is also about 20% of what the tool can do.
The remaining 80% lives in the detector API, the printer framework, and the Python scripting surface. These three surfaces let you ask questions that no generic detector was ever designed to answer: Does every function that writes to totalSupply also emit the canonical event? Does any code path reach an oracle read without a prior staleness check? Can an unprivileged caller reach a delegatecall through a chain of internal calls? Default detectors will never fire on those questions, because they require knowledge of your protocol’s invariants — knowledge that only you have.
This article covers the internal machinery that makes custom analysis possible, shows you how to build a production-quality custom detector, explains how to use the printer framework as a pre-audit reconnaissance tool, and closes with a realistic integration strategy that combines Slither with the manual review work it cannot replace.
How Slither Processes Solidity: AST to SlithIR
Before writing a single line of detector code, you need a mental model of what Slither’s analysis pipeline produces and why it looks the way it does.
Slither takes as initial input the Solidity Abstract Syntax Tree (AST) generated by the Solidity compiler. It then generates important information such as the contract’s inheritance graph, the control flow graph (CFG), and the list of all expressions in the contract. Slither then translates the code of the contract into SlithIR, an internal representation language that makes precise and accurate analyses easier to write.
The AST is the compiler’s parse tree: every variable declaration, function definition, expression, and statement is a node. Slither reads this tree and builds its own object model — contracts, functions, state variables, modifiers — before the IR transformation even starts. This object model is what you navigate when you write Python analysis scripts.
SlithIR: SSA with Solidity Semantics
Slither works by converting Solidity smart contracts into an intermediate representation called SlithIR. SlithIR uses Static Single Assignment (SSA) form and a reduced instruction set to ease implementation of analyses while preserving semantic information that would be lost in transforming Solidity to bytecode. Slither allows for the application of commonly used program analysis techniques like dataflow and taint tracking.
SSA form means every variable is assigned exactly once. When a variable can have multiple values depending on control flow, a φ-function merges the possible definitions. A φ-function on a node indicates that a variable has multiple potential definitions and is a key element of SSA form. One particularity of smart contracts is to heavily rely on state variables, which act as global variables. At the beginning of a function, the value of a state variable can be its initial value, or the value after the execution of any function. Additionally, an external call may re-enter, which can allow a state variable to change.
SlithIR uses fewer than 40 instructions. It has no internal control flow representation and relies on Slither’s control-flow graph structure — SlithIR code is associated with each node in the graph.
Why does this matter for detector authors? Because operating on SlithIR is far more reliable than operating on raw Solidity AST nodes. The IR has already resolved inheritance, flattened expressions, and made data dependencies explicit. A detector checking for reentrancy does not need to understand Solidity’s expression syntax — it interrogates SolidityCall, HighLevelCall, and LowLevelCall IR nodes along with the variable write set of each node.
Understanding SlithIR is not necessary if you only want to write basic checks. However, it will come in handy if you plan to write advanced semantic analyses.
To inspect the IR of a real contract, run:
slither MyContract.sol --print slithir
slither MyContract.sol --print slithir-ssa
The output maps each CFG node to its SlithIR instructions, making it straightforward to identify what the analysis engine actually sees.
What Slither Computes for Free
Slither identifies the reads and writes of variables. For each contract, function, or node of the control flow graph, it is possible to retrieve the variables read or written, and filter by the type of variables (local or state). For example, it is possible to know the state variables written from a specific function, or find which functions write to a given variable. Several detectors are based on this information, such as those for uninitialized variables and reentrancy.
Beyond variable flow, the framework pre-computes protected function detection. Modeling the protection of functions lowers the number of false positives. This means when you write a detector, you can call function.is_protected() to check whether a function is guarded by an onlyOwner-style modifier rather than manually parsing modifier lists.
Writing a Custom Detector
Every custom detector imports AbstractDetector and DetectorClassification. The ARGUMENT field lets you run the detector from the command line. The IMPACT and CONFIDENCE fields classify severity and certainty respectively.
The full classification options for both IMPACT and CONFIDENCE are:
DetectorClassification.HIGHDetectorClassification.MEDIUMDetectorClassification.LOWDetectorClassification.INFORMATIONALDetectorClassification.OPTIMIZATION
Slither’s architecture comprises a core analyzer, printer for reports, and detectors as composable modules. Each detector inherits from a base class, implementing visit functions for AST nodes, akin to visitor patterns in design theory.
A Production-Ready Custom Detector: Unguarded Oracle Reads
The following detector checks for a common DeFi vulnerability pattern: functions that read from a Chainlink-style oracle (identified by calling latestRoundData) without checking the returned updatedAt timestamp for staleness. Default Slither detectors have no concept of “your oracle integration contract.” This is exactly the kind of protocol-specific check a custom detector handles well.
# detectors/unguarded_oracle_read.py
from typing import List
from slither.detectors.abstract_detector import (
AbstractDetector,
DetectorClassification,
)
from slither.core.declarations import Function
from slither.core.cfg.node import NodeType
from slither.slithir.operations import (
HighLevelCall,
LowLevelCall,
InternalCall,
)
from slither.utils.output import Output
class UnguardedOracleRead(AbstractDetector):
"""
Detects calls to Chainlink latestRoundData() where the returned
`updatedAt` timestamp is never compared against block.timestamp,
indicating the caller does not check for stale prices.
"""
ARGUMENT = "unguarded-oracle-read"
HELP = "Chainlink latestRoundData() result not checked for staleness"
IMPACT = DetectorClassification.HIGH
CONFIDENCE = DetectorClassification.MEDIUM
WIKI = "https://your-org.github.io/slither-rules/unguarded-oracle-read"
WIKI_TITLE = "Unguarded Oracle Read"
WIKI_DESCRIPTION = (
"Calling latestRoundData() without verifying that "
"`updatedAt + MAX_DELAY >= block.timestamp` allows the protocol "
"to consume stale price data silently."
)
WIKI_EXPLOIT_SCENARIO = """
```solidity
// Vulnerable
(, int256 price,,,) = priceFeed.latestRoundData();
uint256 amount = uint256(price) * tokens; // stale price, no staleness check
"""
WIKI_RECOMMENDATION = (
"After calling latestRoundData(), verify that "
"`block.timestamp - updatedAt <= STALENESS_THRESHOLD`. "
"Revert if the check fails."
)
# Canonical Chainlink AggregatorV3Interface function name
TARGET_FUNCTION = "latestRoundData"
# The variable name Chainlink docs recommend for the timestamp return value
TIMESTAMP_RETURN_NAMES = {"updatedAt", "updated_at", "updatedat"}
def _is_oracle_call(self, ir) -> bool:
"""Return True if this IR operation is a latestRoundData() call."""
if isinstance(ir, HighLevelCall):
if ir.function and ir.function.name == self.TARGET_FUNCTION:
return True
# Also match by function_name for interface calls not resolved
if (
hasattr(ir, "function_name")
and ir.function_name == self.TARGET_FUNCTION
):
return True
return False
def _timestamp_is_used_in_comparison(
self, function: Function, timestamp_var
) -> bool:
"""
Walk every node in the function's CFG. Return True if `timestamp_var`
appears in at least one binary comparison operation (typically a
require or if-condition staleness check).
"""
if timestamp_var is None:
return False
for node in function.nodes:
# Check SlithIR operations for comparisons involving timestamp_var
for ir in node.irs:
# Look for operations whose read-set contains the variable
reads = getattr(ir, "read", []) or []
if timestamp_var in reads:
# Check if this node has a conditional (if/require)
# that consumes the variable
if node.type in (
NodeType.IF,
NodeType.IFLOOP,
):
return True
# Check for require/assert internal calls with this var
if isinstance(ir, InternalCall):
if ir.function and ir.function.name in (
"require",
"assert",
):
return True
return False
def _analyze_function(self, function: Function) -> List[Output]:
results = []
for node in function.nodes:
for ir in node.irs:
if not self._is_oracle_call(ir):
continue
# latestRoundData returns (roundId, answer, startedAt,
# updatedAt, answeredInRound). The updatedAt is index 3.
# In SlithIR, tuple unpacking assigns to lvalue elements.
timestamp_var = None
# Attempt to resolve the `updatedAt` return variable.
# Slither represents tuple returns as a TupleVariable or
# individual assignments depending on how the code unpacks.
if hasattr(ir, "lvalue") and ir.lvalue is not None:
lv = ir.lvalue
# If lvalue is a tuple, index 3 is updatedAt
if hasattr(lv, "elements"):
elements = list(lv.elements)
if len(elements) > 3:
timestamp_var = elements[3]
# Named variable — check if its name looks like updatedAt
elif hasattr(lv, "name"):
if lv.name.lower() in self.TIMESTAMP_RETURN_NAMES:
timestamp_var = lv
if not self._timestamp_is_used_in_comparison(
function, timestamp_var
):
info = [
"Function ",
function,
" calls latestRoundData() at ",
node,
" but does not verify the `updatedAt` staleness "
"timestamp.\n",
]
res = self.generate_result(info)
results.append(res)
return results
def _detect(self) -> List[Output]:
results = []
for contract in self.compilation_unit.contracts_derived:
for function in contract.functions_and_modifiers:
results.extend(self._analyze_function(function))
return results
#### Registering the Detector
The cleanest registration path is through Slither's plugin system. Create a `setup.py` entry point:
```python
# setup.py
from setuptools import setup, find_packages
setup(
name="slither-oracle-detectors",
version="0.1.0",
packages=find_packages(),
install_requires=["slither-analyzer>=0.11.0"],
entry_points={
"slither_analyzer.plugin": [
"slither-oracle-detectors=slither_oracle_detectors:make_plugin",
]
},
)
# slither_oracle_detectors/__init__.py
from .detectors.unguarded_oracle_read import UnguardedOracleRead
def make_plugin():
plugin_detectors = [UnguardedOracleRead]
plugin_printers = []
return plugin_detectors, plugin_printers
After pip install -e ., run your detector with:
slither . --detect unguarded-oracle-read
The Printer Framework
Slither includes printers, which allow users to quickly understand what a contract does and how it is structured. Printers are not detectors — they do not classify severity or generate findings. They are reconnaissance tools, and using them before writing detectors or beginning a manual audit dramatically compresses the time needed to build a mental model of the codebase.
The printer documentation describes the information Slither is capable of visualizing for each contract. There are two categories worth distinguishing:
Quick Review Printers are designed for first-pass orientation:
human-summary— contract-level overview of functions, variable counts, and inheritancecontract-summary— tabular view of all function visibilitiesinheritance-graph— exports a.dotfile of the full inheritance hierarchy
In-Depth Review Printers produce structured data for deeper analysis:
call-graph— exports the complete inter-function call graph to.dotcfg— exports the control flow graph for each functionfunction-summary— for each function: visibility, modifiers, state vars read/written, internal and external callsvars-and-auth— maps each state variable write to the modifier guarding the function that performs it
Call Graph Analysis
Running slither SecureContract.sol --print contract-summary,function-summary,modifiers gives information about each function of the contract, its visibility, modifiers, internal and external calls. There is also a printer to build graphs to visualize the function interactions inside the contract. Running slither SecureContract.sol --print call-graph generates a dot file that can be opened with any online dot viewer.
The call graph is most useful for tracing privilege escalation paths. Convert the output:
slither . --print call-graph
dot -Tpng all_contracts.call-graph.dot -o call-graph.png
Look for unexpected paths from public or external functions down to sensitive operations like selfdestruct, delegatecall, transfer, or any function that writes critical state variables. The graph makes it instantly clear when a public function can reach a privileged internal function without passing through a modifier check.
Inheritance Graph Analysis
The inheritance-graph printer outputs a graph showing the inheritance interaction between the contracts. If a contract has multiple inheritance, the connecting edges are labeled in order of declaration. Functions highlighted orange override a parent’s function. Functions which do not override each other directly but collide due to multiple inheritance are emphasized at the bottom of the affected contract node in grey font. Variables highlighted red overshadow a parent’s variable declaration.
Variable shadowing highlighted in red is one of the most immediately actionable pieces of information the inheritance graph provides. A shadowed variable in a derived contract is a recurring source of bugs in proxy patterns and upgradeable contracts, and the visual makes it trivial to identify.
vars-and-auth: The Privilege Audit Map
The vars-and-auth printer specifies what state variables are written by each function, which in big projects can be a screenful, but is useful information indeed.
The output is a table: function name, state variables written, and which access control modifier (if any) protects the function. In a single pass, you can identify:
- State-changing functions that have no modifier at all
- Functions that share modifiers when they should have distinct access levels
- Functions that write critical variables through indirect calls, bypassing the modifier-detection heuristic
Using the Python API for Custom Analysis Scripts
Slither provides an API to inspect Solidity code via custom scripts. Trail of Bits uses this API to rapidly answer unique questions about code under review, including: identifying code that can modify a variable’s value; isolating the conditional logic statements influenced by a particular variable’s value; and finding other functions that are transitively reachable as a result of a call to a particular function.
The API is most useful when the question does not fit the detector framework — for example, when you need to cross-reference findings from multiple contracts, produce a structured report for a particular client, or run an exploratory analysis that you have not fully formalized into a detector yet.
Basic API Usage
from slither import Slither
# Initialize on a Hardhat/Foundry project
sl = Slither(".")
for contract in sl.contracts:
print(f"\n=== {contract.name} ===")
for fn in contract.functions:
# State variables this function writes
written = fn.state_variables_written
# State variables this function reads
read = fn.state_variables_read
# External calls made
ext_calls = fn.external_calls_as_expressions
if written:
print(f" {fn.name} writes: {[v.name for v in written]}")
if ext_calls:
print(f" {fn.name} makes {len(ext_calls)} external call(s)")
Tracing Taint Through the Call Graph
A more powerful usage pattern is taint-tracing: given a source (e.g., msg.sender or an oracle return value), find every state variable that is reachable from it without sanitization. The API exposes DataDependency analysis results:
from slither import Slither
from slither.analyses.data_dependency.data_dependency import (
is_dependent,
)
sl = Slither(".")
# Find a specific contract and function
target_contract = sl.get_contract_from_name("LendingPool")[0]
deposit_fn = target_contract.get_function_from_signature("deposit(uint256)")
# Walk all state variable writes and check if they are data-dependent
# on user-controlled input (the function's parameters)
user_inputs = deposit_fn.parameters
for node in deposit_fn.nodes:
for ir in node.irs:
written_vars = getattr(ir, "lvalue", None)
if written_vars is None:
continue
for param in user_inputs:
if is_dependent(written_vars, param, target_contract):
print(
f"[TAINT] {written_vars} in node '{node}' is "
f"tainted by parameter '{param.name}'"
)
Querying Cross-Contract Relationships
The API’s contracts_derived property returns only concrete (non-abstract) contracts, which is the right set to analyze for deployed surface area. Iterating over sl.contracts includes abstract contracts and interfaces, which can pollute results.
# Only analyze deployable contracts
for contract in sl.compilation_unit.contracts_derived:
# Check every public/external function
for fn in contract.functions:
if fn.visibility not in ("public", "external"):
continue
if fn.is_constructor:
continue
# Check if this function can reach a selfdestruct
# by inspecting all reachable nodes in the call graph
if any(
node.contains_require_or_assert() is False
and any(
"selfdestruct" in str(ir)
for ir in node.irs
)
for node in fn.all_reachable_from_nodes()
):
print(
f"[WARNING] {contract.name}.{fn.name} can reach selfdestruct"
)
Common False Positive Patterns and How to Filter Them
Slither analyzes contracts within seconds. However, static analysis often leads to false positives and is not suitable for complex checks against high-level proxies and business logic.
Understanding which patterns generate false positives lets you write tighter detectors and spend less time triaging noise.
Pattern 1: Protected Internal Functions
Slither might return some results that in your case may not be very useful because you know that your smart contracts are actually protected against those vulnerabilities, for instance reentrancy detections for functions that only a single account can invoke.
The fix is to check function.is_protected() in your detector logic before emitting a finding. Functions guarded by onlyOwner, onlyRole, or equivalent modifiers have a much narrower attack surface, and many generic detectors treat them identically to unprotected functions.
Pattern 2: Proxy and Delegatecall Patterns
A common failure is false positives on safe patterns, for example flagging legitimate delegatecalls in proxy patterns as high-risk. The root cause is overly broad heuristics without context awareness.
When you know a contract is an EIP-1967 proxy, filter it by checking whether the contract inherits from known proxy base contracts:
KNOWN_PROXY_BASES = {"ERC1967Upgrade", "Proxy", "TransparentUpgradeableProxy"}
def is_proxy_contract(contract) -> bool:
return any(
base.name in KNOWN_PROXY_BASES
for base in contract.inheritance
)
Pattern 3: The Triage Mode
Slither’s triage mode turns the console into interactive mode; result messages are displayed one after the other and you are asked if you wish to remove the result from future executions. Once you are done, a slither.db.json file is generated with the list of excluded results. To remove all exclusions, simply remove the slither.db.json file.
Run triage once at the start of a project to suppress findings you have already reviewed:
slither . --triage-mode
The persisted slither.db.json is worth committing to your repository so that CI runs do not re-flag reviewed findings.
Pattern 4: Path Filtering
Slither will analyze everything it compiles by default, including node_modules, OpenZeppelin base contracts, and test files. Filter paths aggressively using a config file:
{
"filter_paths": "node_modules,lib,test,mock",
"exclude_dependencies": true
}
Placing a .slither.config.json file in your project root allows you to configure detectors to run, printers to run, path filtering, and Solidity remappings in one place.
What Slither Reliably Catches vs. What It Misses
Knowing the boundaries of a tool is as important as knowing its capabilities.
Slither Reliably Catches
Static analysis is valuable because it scales. It can flag issues involving visibility, inheritance, dangerous external calls, storage concerns, reentrancy patterns, and other structural warnings across many contracts in seconds. That makes it ideal for the early stages of review and for repeated checks during remediation.
Concretely, Slither’s default detectors have high confidence on:
- Reentrancy via state-mutation-after-call: The classic CEI violation.
- Variable shadowing in inheritance hierarchies: Identified immediately by the IR.
- Uninitialized storage pointers: Caught at the variable read/write analysis layer.
- Incorrect visibility: Public functions that should be internal.
- Suicidal and arbitrary-send patterns: Well-covered by built-in detectors.
- Divide-before-multiply precision loss: The
divide-before-multiplydetector catches imprecise arithmetic operations where Solidity’s integer division truncates, leading to precision loss when division is performed before multiplication. - ERC standard conformance violations:
slither-ercchecks implementations against ERC20, ERC721, and ERC1155 ABIs.
What Slither Misses
Slither isn’t infallible — its static nature misses runtime behaviors like oracle manipulations in dynamic markets.
More precisely, Slither has structural blind spots:
- Business logic errors: Slither has no model of your protocol’s economic invariants. It cannot know that a liquidation threshold must always exceed a borrow ratio. Expert auditors read and reason about code the way an attacker would. They catch issues that no automated tool has ever seen, particularly novel business logic exploits.
- Price manipulation via flash loans: The sequence of state changes across a flash loan callback requires reasoning about multi-transaction execution, which is outside the scope of single-function CFG analysis.
- Incorrect assumptions about external contract behavior: Slither models external calls as opaque unless it has the source of the callee in scope.
- Complex multi-contract interaction bugs: Issues that only manifest when Contract A calls Contract B, which triggers a callback into Contract C, require cross-contract semantic reasoning that Slither does not perform.
- Semantic correctness of access control role assignments: Slither can tell you that
ROLE_Xis required for a function, but it cannot tell you whetherROLE_Xhas been granted to the right accounts. - Numeric precision under specific input ranges: Analysis of types, results of operations, and counting calls are not tasks for static analyzers.
Combining Slither with Manual Review
The most effective audit workflow treats Slither output as a structured input to human review, not as a substitute for it.
The top firms always combine both. Automated tools handle the first pass and give auditors a prioritized list of areas to investigate. Manual experts then go deep on those areas and explore the codebase in ways no tool can replicate. This hybrid approach is the industry gold standard for any serious Web3 security audit.
A practical division of labor:
| Layer | Slither Role | Manual Review Role |
|---|---|---|
| Structural patterns | Automated detection | Confirm or dismiss findings in context |
| Privilege boundaries | vars-and-auth printer maps all writes | Verify business logic requires those privileges |
| Call graph | call-graph printer identifies reachable paths | Trace suspicious paths for exploitability |
| Oracle integrations | Custom detector flags unguarded reads | Verify staleness thresholds are appropriate |
| Economic invariants | None | Full manual analysis required |
| Multi-tx attack paths | None | Manual + fuzzing (Echidna/Foundry) |
Integrating Slither into an Audit Workflow
Phase 1: Project Orientation (Day 1)
Before reading a single line of source code, run the printer battery:
# Get the high-level picture
slither . --print human-summary,contract-summary
# Export call and inheritance graphs
slither . --print call-graph,inheritance-graph
# Map all state variable writes to their authorization guards
slither . --print vars-and-auth,function-summary
Convert .dot output to images and review them before opening any source files. This builds a spatial model of the protocol — what calls what, who inherits from what, what state each function touches — that makes the source code review dramatically more efficient.
Phase 2: Default Detector Triage (Day 1–2)
# Run all detectors and export to JSON for structured triage
slither . --json slither-findings.json
# Run triage mode to mark known false positives
slither . --triage-mode
Process findings by impact level: high first, then medium, then low. For each finding, answer three questions: (1) Is this a true positive? (2) Is it exploitable in this protocol’s context? (3) Does it interact with any findings from other detectors?
Phase 3: Protocol-Specific Custom Detectors (Day 2–3)
After manual review reveals the protocol’s specific invariants, write custom detectors for those invariants. Common candidates:
- DeFi: oracle staleness, slippage parameter enforcement, fee calculation consistency
- Token contracts: inflation cap checks, minting permission consistency
- Governance: timelock bypass paths, quorum manipulation
- Upgradeable contracts: storage slot collision checks, initializer guard verification
Phase 4: CI Integration
For GitHub Action integration, use slither-action.
# .github/workflows/slither.yml
name: Slither Analysis
on: [push, pull_request]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Slither
uses: crytic/slither-action@v0.4.0
with:
target: "."
slither-config: "slither.config.json"
fail-on: high
sarif: results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
To generate a Markdown report, use slither [target] --checklist. To generate a Markdown report with GitHub source code highlighting, use slither [target] --checklist --markdown-root https://github.com/ORG/REPO/blob/COMMIT/.
Phase 5: Remediation Verification
After developers push fixes, re-run only the detectors that fired in Phase 2 and the custom detectors from Phase 3:
# Run only specific detectors to verify fixes
slither . --detect reentrancy-eth,unguarded-oracle-read,arbitrary-send
This is faster than a full re-run and produces a clean diff-able record of which findings have been resolved.
A Note on Tool Composition
Slither’s core provides advanced static-analysis features, including an intermediate representation (SlithIR) with taint tracking capabilities on top of which complex analyses can be built. That is an accurate description of what the open-source core delivers. It also implicitly defines what it does not deliver: runtime analysis, symbolic execution over complex state spaces, and invariant verification under arbitrary inputs.
Fuzzing approaches like Echidna excel at finding unexpected state transitions and complex multi-transaction bugs that static analysis might miss. The right interpretation of a