Skip to content

Commit

Permalink
Add slither integration (#530)
Browse files Browse the repository at this point in the history
* Initial Slither integration

* add slither config and basic test

* add caching

* complete unit test and improve logging

* add newline

* update CI and allow for caching to disk

* fix linter

* fix linting again

* add --use-slither flag to run slither on the fly

* prevent other fuzz tests from using slither to speed up CI

* update documentation for slither integration

* throw error before logging

* fix comment formatting.

* linting is very annoying

* improve error handling

* improve documentation

* remove -1 from value set when adding bools

* fix test

---------

Co-authored-by: Simone <[email protected]>
  • Loading branch information
anishnaik and smonicas authored Jan 8, 2025
1 parent 350ee4b commit 0112ddc
Show file tree
Hide file tree
Showing 11 changed files with 422 additions and 4 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ jobs:

- name: Install Python dependencies
run: |
pip3 install --no-cache-dir solc-select crytic-compile
pip3 install --no-cache-dir solc-select slither-analyzer
- name: Install solc
run: |
Expand Down
14 changes: 14 additions & 0 deletions cmd/fuzz_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ func addFuzzFlags() error {
// Exploration mode
fuzzCmd.Flags().Bool("explore", false, "enables exploration mode")

// Run slither on-the-fly
fuzzCmd.Flags().Bool("use-slither", false, "runs slither")
return nil
}

Expand Down Expand Up @@ -193,5 +195,17 @@ func updateProjectConfigWithFuzzFlags(cmd *cobra.Command, projectConfig *config.
projectConfig.Fuzzing.Testing.OptimizationTesting.Enabled = false
}
}

// Update configuration to run slither
if cmd.Flags().Changed("use-slither") {
useSlither, err := cmd.Flags().GetBool("use-slither")
if err != nil {
return err
}
if useSlither {
projectConfig.Slither.UseSlither = true
}
}

return nil
}
203 changes: 203 additions & 0 deletions compilation/types/slither.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package types

import (
"encoding/json"
"errors"
"github.com/crytic/medusa/logging"
"os"
"os/exec"
"time"
)

// SlitherConfig determines whether to run slither and whether and where to cache the results from slither
type SlitherConfig struct {
// UseSlither determines whether to use slither. If CachePath is non-empty, then the cached results will be
// attempted to be used. Otherwise, slither will be run.
UseSlither bool `json:"useSlither"`
// CachePath determines the path where the slither cache file will be located
CachePath string `json:"cachePath"`
}

// NewDefaultSlitherConfig provides a default configuration to run slither. The default configuration enables the
// running of slither with the use of a cache.
func NewDefaultSlitherConfig() (*SlitherConfig, error) {
return &SlitherConfig{
UseSlither: true,
CachePath: "slither_results.json",
}, nil
}

// SlitherResults describes a data structures that holds the interesting constants returned from slither
type SlitherResults struct {
// Constants holds the constants extracted by slither
Constants []Constant `json:"constantsUsed"`
}

// Constant defines a constant that was extracted by slither while parsing the compilation target
type Constant struct {
// Type represents the ABI type of the constant
Type string `json:"type"`
// Value represents the value of the constant
Value string `json:"value"`
}

// RunSlither on the provided compilation target. RunSlither will use cached results if they exist and write to the
// cache if we have not written to the cache already. A SlitherResults data structure is returned.
func (s *SlitherConfig) RunSlither(target string) (*SlitherResults, error) {
// Return early if we do not want to run slither
if !s.UseSlither {
return nil, nil
}

// Use the cached slither output if it exists
var haveCachedResults bool
var out []byte
var err error
if s.CachePath != "" {
// Check to see if the file exists in the first place.
// If not, we will re-run slither
if _, err = os.Stat(s.CachePath); os.IsNotExist(err) {
logging.GlobalLogger.Info("No Slither cached results found at ", s.CachePath)
haveCachedResults = false
} else {
// We found the cached file
if out, err = os.ReadFile(s.CachePath); err != nil {
return nil, err
}
haveCachedResults = true
logging.GlobalLogger.Info("Using cached Slither results found at ", s.CachePath)
}
}

// Run slither if we do not have cached results, or we cannot find the cached results
if !haveCachedResults {
// Log the command
cmd := exec.Command("slither", target, "--ignore-compile", "--print", "echidna", "--json", "-")
logging.GlobalLogger.Info("Running Slither:\n", cmd.String())

// Run slither
start := time.Now()
out, err = cmd.CombinedOutput()
if err != nil {
return nil, err
}
logging.GlobalLogger.Info("Finished running Slither in ", time.Since(start).Round(time.Second))
}

// Capture the slither results
var slitherResults SlitherResults
err = json.Unmarshal(out, &slitherResults)
if err != nil {
return nil, err
}

// Cache the results if we have not cached before. We have also already checked that the output is well-formed
// (through unmarshal) so we should be safe.
if !haveCachedResults && s.CachePath != "" {
// Cache the data
err = os.WriteFile(s.CachePath, out, 0644)
if err != nil {
// If we are unable to write to the cache, we should log the error but continue
logging.GlobalLogger.Warn("Failed to cache Slither results at ", s.CachePath, " due to an error:", err)
// It is possible for os.WriteFile to create a partially written file so it is best to try to delete it
if _, err = os.Stat(s.CachePath); err == nil {
// We will not handle the error of os.Remove since we have already checked for the file's existence
// and we have the right permissions.
os.Remove(s.CachePath)
}
}
}

return &slitherResults, nil
}

// UnmarshalJSON unmarshals the slither output into a Slither type
func (s *SlitherResults) UnmarshalJSON(d []byte) error {
// Extract the top-level JSON object
var obj map[string]json.RawMessage
if err := json.Unmarshal(d, &obj); err != nil {
return err
}

// Decode success and error. They are always present in the slither output
var success bool
var slitherError string
if err := json.Unmarshal(obj["success"], &success); err != nil {
return err
}

if err := json.Unmarshal(obj["error"], &slitherError); err != nil {
return err
}

// If success is not true or there is a non-empty error string, return early
if !success || slitherError != "" {
if slitherError != "" {
return errors.New(slitherError)
}
return errors.New("slither returned a failure during parsing")
}

// Now we will extract the constants
s.Constants = make([]Constant, 0)

// Iterate through the JSON object until we get to the constants_used key
// First, retrieve the results
var results map[string]json.RawMessage
if err := json.Unmarshal(obj["results"], &results); err != nil {
return err
}

// Retrieve the printers data
var printers []json.RawMessage
if err := json.Unmarshal(results["printers"], &printers); err != nil {
return err
}

// Since we are running the echidna printer, we know that the first element is the one we care about
var echidnaPrinter map[string]json.RawMessage
if err := json.Unmarshal(printers[0], &echidnaPrinter); err != nil {
return err
}

// We need to de-serialize the description in two separate steps because go is dumb sometimes
var descriptionString string
if err := json.Unmarshal(echidnaPrinter["description"], &descriptionString); err != nil {
return err
}
var description map[string]json.RawMessage
if err := json.Unmarshal([]byte(descriptionString), &description); err != nil {
return err
}

// Capture all the constants extracted across all the contracts in scope
var constantsInContracts map[string]json.RawMessage
if err := json.Unmarshal(description["constants_used"], &constantsInContracts); err != nil {
return err
}

// Iterate across the constants in each contract
for _, constantsInContract := range constantsInContracts {
// Capture all the constants in a given function
var constantsInFunctions map[string]json.RawMessage
if err := json.Unmarshal(constantsInContract, &constantsInFunctions); err != nil {
return err
}

// Iterate across each function
for _, constantsInFunction := range constantsInFunctions {
// Each constant is provided as its own list, so we need to create a matrix
var constants [][]Constant
if err := json.Unmarshal(constantsInFunction, &constants); err != nil {
return err
}
for _, constant := range constants {
// Slither outputs the value of a constant as a list
// However we know there can be only 1 so we take index 0
s.Constants = append(s.Constants, constant[0])
}
}
}

return nil
}
2 changes: 2 additions & 0 deletions docs/src/project_configuration/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ configuration is a `.json` file that is broken down into five core components.
- [Testing Configuration](./testing_config.md): The testing configuration dictates how and what `medusa` should fuzz test.
- [Chain Configuration](./chain_config.md): The chain configuration dictates how `medusa`'s underlying blockchain should be configured.
- [Compilation Configuration](./compilation_config.md): The compilation configuration dictates how to compile the fuzzing target.
- [Slither Configuration](./slither_config.md): The Slither configuration dictates whether Slither should be used in
`medusa` and whether the results from Slither should be cached.
- [Logging Configuration](./logging_config.md): The logging configuration dictates when and where to log events.

To generate a project configuration file, run [`medusa init`](../cli/init.md).
Expand Down
27 changes: 27 additions & 0 deletions docs/src/project_configuration/slither_config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Slither Configuration

The [Slither](https://github.com/crytic/slither) configuration defines the parameters for using Slither in `medusa`.
Currently, we use Slither to extract interesting constants from the target system. These constants are then used in the
fuzzing process to try to increase coverage. Note that if Slither fails to run for some reason, we will still try our
best to mine constants from each contract's AST so don't worry!

- > 🚩 We _highly_ recommend using Slither and caching the results. Basically, don't change this configuration unless
> absolutely necessary. The constants identified by Slither are shown to greatly improve system coverage and caching
> the results will improve the speed of medusa.
### `useSlither`

- **Type**: Boolean
- **Description**: If `true`, Slither will be run on the target system and useful constants will be extracted for fuzzing.
If `cachePath` is a non-empty string (which it is by default), then `medusa` will first check the cache before running
Slither.
- **Default**: `true`

### `cachePath`

- **Type**: String
- **Description**: If `cachePath` is non-empty, Slither's results will be cached on disk. When `medusa` is re-run, these
cached results will be used. We do this for performance reasons since re-running Slither each time `medusa` is restarted
is computationally intensive for complex projects. We recommend disabling caching (by making `cachePath` an empty string)
if the target codebase changes. If the code remains constant during the fuzzing campaign, we recommend to use the cache.
- **Default**: `slither_results.json`
4 changes: 4 additions & 0 deletions fuzzing/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/crytic/medusa/compilation/types"
"math/big"
"os"

Expand All @@ -27,6 +28,9 @@ type ProjectConfig struct {
// Compilation describes the configuration used to compile the underlying project.
Compilation *compilation.CompilationConfig `json:"compilation"`

// Slither describes the configuration for running slither
Slither *types.SlitherConfig `json:"slither"`

// Logging describes the configuration used for logging to file and console
Logging LoggingConfig `json:"logging"`
}
Expand Down
8 changes: 8 additions & 0 deletions fuzzing/config/config_defaults.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"github.com/crytic/medusa/compilation/types"
"math/big"

testChainConfig "github.com/crytic/medusa/chain/config"
Expand Down Expand Up @@ -31,6 +32,12 @@ func GetDefaultProjectConfig(platform string) (*ProjectConfig, error) {
return nil, err
}

// Obtain a default slither configuration
slitherConfig, err := types.NewDefaultSlitherConfig()
if err != nil {
return nil, err
}

// Create a project configuration
projectConfig := &ProjectConfig{
Fuzzing: FuzzingConfig{
Expand Down Expand Up @@ -88,6 +95,7 @@ func GetDefaultProjectConfig(platform string) (*ProjectConfig, error) {
TestChainConfig: *chainConfig,
},
Compilation: compilationConfig,
Slither: slitherConfig,
Logging: LoggingConfig{
Level: zerolog.InfoLevel,
LogDirectory: "",
Expand Down
38 changes: 35 additions & 3 deletions fuzzing/fuzzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ type Fuzzer struct {
// contractDefinitions defines targets to be fuzzed once their deployment is detected. They are derived from
// compilations.
contractDefinitions fuzzerTypes.Contracts
// slitherResults holds the results obtained from slither. At the moment we do not have use for storing this in the
// Fuzzer but down the line we can use slither for other capabilities that may require storage of the results.
slitherResults *compilationTypes.SlitherResults

// baseValueSet represents a valuegeneration.ValueSet containing input values for our fuzz tests.
baseValueSet *valuegeneration.ValueSet

Expand Down Expand Up @@ -284,16 +288,44 @@ func (f *Fuzzer) ReportTestCaseFinished(testCase TestCase) {
// AddCompilationTargets takes a compilation and updates the Fuzzer state with additional Fuzzer.ContractDefinitions
// definitions and Fuzzer.BaseValueSet values.
func (f *Fuzzer) AddCompilationTargets(compilations []compilationTypes.Compilation) {
// Loop for each contract in each compilation and deploy it to the test chain
var seedFromAST bool

// No need to handle the error here since having compilation artifacts implies that we used a supported
// platform configuration
platformConfig, _ := f.config.Compilation.GetPlatformConfig()

// Retrieve the compilation target for slither
target := platformConfig.GetTarget()

// Run slither and handle errors
slitherResults, err := f.config.Slither.RunSlither(target)
if err != nil || slitherResults == nil {
if err != nil {
f.logger.Warn("Failed to run slither", err)
}
seedFromAST = true
}

// If we have results and there were no errors, we will seed the value set using the slither results
if !seedFromAST {
f.slitherResults = slitherResults
// Seed our base value set with the constants extracted by Slither
f.baseValueSet.SeedFromSlither(slitherResults)
}

// Capture all the contract definitions, functions, and cache the source code
for i := 0; i < len(compilations); i++ {
// Add our compilation to the list and get a reference to it.
f.compilations = append(f.compilations, compilations[i])
compilation := &f.compilations[len(f.compilations)-1]

// Loop for each source
for sourcePath, source := range compilation.SourcePathToArtifact {
// Seed our base value set from every source's AST
f.baseValueSet.SeedFromAst(source.Ast)
// Seed from the contract's AST if we did not use slither or failed to do so
if seedFromAST {
// Seed our base value set from every source's AST
f.baseValueSet.SeedFromAst(source.Ast)
}

// Loop for every contract and register it in our contract definitions
for contractName := range source.Contracts {
Expand Down
Loading

0 comments on commit 0112ddc

Please sign in to comment.