2025-10-13·15 min read

Medusa fuzzer tutorial: from install to first bug

Medusa fuzzer tutorial: from install to first bug

So you've heard about fuzzing and you want to try it on your Solidity contracts. Good call. Medusa is one of the best tools for the job — it's fast and configurable, built in Go so you get a single binary with zero runtime dependencies. In this tutorial, we'll go from a fresh install to catching a real bug in a vault contract.

No fluff. Just the steps.

What is Medusa?

Medusa is a coverage-guided fuzzer for Solidity smart contracts. It generates semi-random inputs, calls your contract functions, and checks whether your properties hold. When a property breaks, Medusa gives you the exact call sequence that triggered the failure.

If you're wondering how it stacks up against Echidna, check out our Echidna vs Medusa comparison. Short version: Medusa tends to be faster for large codebases and has better parallel execution. But both are solid.

Installation

You've got two options here.

Option A: Go install (recommended)

If you've got Go 1.21+ installed:

go install github.com/crytic/medusa@latest

Make sure $GOPATH/bin is in your PATH:

export PATH=$PATH:$(go env GOPATH)/bin
medusa --version

Option B: pre-built binary

Grab the latest release from the Medusa GitHub releases page. Download the binary for your OS, make it executable, and drop it somewhere in your PATH.

chmod +x medusa
sudo mv medusa /usr/local/bin/
medusa --version

Either way, you should see a version string. If you do, you're good.

Project setup

We'll use Foundry for compilation since Medusa plays nicely with it. If you don't have Foundry yet, install it:

curl -L https://foundry.sh | bash
foundryup

Now let's create a project:

mkdir medusa-tutorial && cd medusa-tutorial
forge init --no-commit

The target: a buggy vault contract

Create src/SimpleVault.sol. This is a basic deposit/withdraw vault with a subtle bug — see if you spot it before Medusa does.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleVault {
    mapping(address => uint256) public balances;
    uint256 public totalDeposits;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        totalDeposits -= amount;

        // Bug: state update after external call (reentrancy)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= amount;
    }

    function getBalance(address user) external view returns (uint256) {
        return balances[user];
    }
}

The bug is a classic reentrancy. The balance gets updated after the external call. But let's not just eyeball it, let's write a property that catches it automatically.

Writing your first property test

Create test/SimpleVaultTest.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../src/SimpleVault.sol";

contract SimpleVaultTest {
    SimpleVault vault;

    constructor() payable {
        vault = new SimpleVault();
    }

    // Property: total deposits should always equal the sum of all balances
    // If reentrancy happens, totalDeposits goes out of sync
    function fuzz_totalDeposits_solvency() public view returns (bool) {
        return address(vault).balance >= vault.totalDeposits();
    }

    // Property: no user can withdraw more than they deposited
    function fuzz_no_free_money() public view returns (bool) {
        return address(vault).balance >= vault.totalDeposits();
    }

    function deposit() external payable {
        vault.deposit{value: msg.value}();
    }

    function withdraw(uint256 amount) external {
        vault.withdraw(amount);
    }

    receive() external payable {
        // Attacker reentrancy callback
        if (address(vault).balance > 0) {
            try vault.withdraw(msg.value) {} catch {}
        }
    }
}

Medusa looks for functions prefixed with fuzz_ that return bool. If any of them return false, the property is broken and Medusa reports it.

Configuring medusa.json

Initialize the Medusa config:

medusa init

This creates a medusa.json file. Let's tweak the key settings:

{
  "fuzzing": {
    "workers": 4,
    "workerResetLimit": 50,
    "timeout": 300,
    "testLimit": 100000,
    "callSequenceLength": 20,
    "deploymentOrder": ["SimpleVaultTest"],
    "corpusDirectory": "corpus",
    "coverageEnabled": true,
    "targetContracts": ["SimpleVaultTest"],
    "testing": {
      "propertyTesting": {
        "enabled": true,
        "testPrefixes": ["fuzz_"]
      },
      "assertionTesting": {
        "enabled": false
      },
      "optimizationTesting": {
        "enabled": false
      }
    }
  },
  "compilation": {
    "platform": "crytic-compile",
    "platformConfig": {
      "target": ".",
      "solcVersion": "",
      "exportDirectory": "",
      "args": ["--foundry-compile-all"]
    }
  }
}

Here's what matters:

  • workers: Number of parallel fuzzing goroutines. More = faster, but don't exceed your CPU cores.
  • testLimit: Total number of transactions before stopping. 100K is a decent starting point.
  • callSequenceLength: Max number of calls in a single test sequence. Longer sequences can find deeper stateful bugs.
  • corpusDirectory: Where Medusa saves interesting inputs for replay.
  • coverageEnabled: Track which code paths get hit. You want this on.

Running your first campaign

Build first, then fuzz:

forge build
medusa fuzz

You should see output like:

[INFO] Starting fuzzer...
[INFO] Workers: 4
[INFO] Target: SimpleVaultTest
[PROPERTY BROKEN] fuzz_totalDeposits_solvency()
  Call sequence:
    1. deposit() [value: 1000000000]
    2. withdraw(1000000000)
    3. withdraw(1000000000) (via reentrant callback)

  Result: false

Medusa found the reentrancy. The call sequence shows exactly how: deposit, withdraw, and then the receive callback triggers a second withdraw before the first one updates the balance. The totalDeposits accounting breaks because the same amount gets subtracted twice.

Reading the results

When Medusa finds a property violation, it gives you:

  1. The broken property name (which fuzz_ function returned false)
  2. The call sequence (the exact series of transactions that triggered it)
  3. The shrunk sequence. Medusa tries to minimize the sequence to the smallest reproduction

Check the corpus/ directory after a run. You'll find JSON files with saved sequences:

ls corpus/

These are replayable. If you want to reproduce a failure:

medusa fuzz --replay corpus/<sequence-file>

Coverage reports

With coverageEnabled: true, Medusa tracks which lines of your contract got executed during fuzzing. After the campaign:

ls corpus/coverage/

The coverage report shows you which branches Medusa explored. If you see low coverage on critical paths, you might need to:

  • Increase callSequenceLength to reach deeper states
  • Add more helper functions in your test contract to guide the fuzzer
  • Increase testLimit to give Medusa more time

Aim for 90%+ line coverage on your target contract. Anything less means the fuzzer is probably missing interesting behavior.

Optimization mode

Medusa has an optimization mode that tries to maximize a numeric value. This is useful for finding worst-case scenarios.

function optimize_max_drain() external view returns (int256) {
    // Medusa will try to maximize this value
    // Higher = more ETH drained beyond deposits
    int256 drained = int256(vault.totalDeposits()) - int256(address(vault).balance);
    return drained;
}

Enable it in medusa.json:

"optimizationTesting": {
    "enabled": true,
    "testPrefixes": ["optimize_"]
}

Medusa will report the maximum value it achieved and the sequence that got there. Great for quantifying economic impact.

Tips from the trenches

Start simple. Write one or two properties, run a short campaign, check coverage. Then iterate. Don't try to write 30 properties on day one.

Use the Chimera framework for larger projects. It lets you write properties once and run them on both Medusa and Echidna without changing anything. Portability matters when you're doing serious invariant testing.

Watch your memory. Medusa with 8+ workers on a large contract can eat RAM. Monitor it during long campaigns.

Seed your corpus. If you know certain states are important (e.g., a pool with specific token ratios), create setup functions that get the contract into those states before fuzzing.

Don't ignore "partially broken" properties. If a property passes 99.99% of the time but fails once in 100K runs, that's still a bug. Rare doesn't mean safe.

Fixing the bug

The fix for our vault is straightforward. Apply checks-effects-interactions:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    // Update state BEFORE the external call
    balances[msg.sender] -= amount;
    totalDeposits -= amount;

    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Run Medusa again after the fix. The property should hold across all sequences now.

What's next?

You've got the basics down. From here:

Fuzzing isn't a silver bullet, but it catches real bugs that manual review misses. The earlier you integrate it into your workflow, the fewer surprises you'll get in production.

Get a professional fuzzing audit

Try Recon Pro

Related Posts

Related Glossary Terms

Want expert fuzzing for your protocol?