EF/CF is a new approach to smart contract fuzzing: instead of using a new custom built fuzzer, it repurposes existing fuzzing infrastructure of C/C++ code to smart contracts. Currently, AFL++ is the primarily supported fuzzer, although there is some very rudimentary support for libfuzzer and honggfuzz as well.
Why use existing fuzzing infrastructure?
What are some problems that we encounter along the way?
./src/ethmutator/
./src/evm2cpp/
This repository the primary entry point for the EF/CF project. It contains all
the relevant code as sub-projects in ./src/
and several convenience scripts for
installation, scripts for launching a fuzzing campaigns and various datasets to
test the fuzzer (and compare against other tools).
./src/
- contains all source necessary to build and run EF/CF; for
reproducibility all direct dependencies are added as git submodules../data/
- contains the datasets used during evaluation./scripts
- contains scripts to run experiments, installation, etc../docker
- Dockerfile for container-based workflow
./docker/tools/
contains dockerfiles for tools that we evaluated EF/CF
against. We tried our best to use fix the versions we evaluated in our
paper in the dockerfiles../EXPERIMENTS.md
- contains a guide to
reproduce the experiments from our paper../examples
- contains example outputs produced by EF/CFWe describe EF/CF's architecture, implementation, and summarize our evaluation results in our paper: arxiv.org preprint
When referring to EF/CF in academic work please use the following bibtex entry for citation:
@InProceedings{efcf2023,
author = "Michael Rodler and David Paaßen and Wenting Li and Lukas Bernhard and Thorsten Holz and Ghassan Karame and Lucas Davi",
title = "EF/CF: High Performance Smart Contract Fuzzing for Exploit Generation",
booktitle = "{IEEE} European Symposium on Security and Privacy ({EuroS\&P})",
publisher = "{IEEE}",
year = "2023",
}
The recommended way is to run EF/CF as an interactive docker container.
docker run --rm -it ghcr.io/uni-due-syssec/efcf-framework
or build the container from the cloned repository
make gitmodules # to fetch the git submodules
make container-enter
efcfuzz --until-crash --out ./baby_bank_results/ --source ./data/examples/baby_bank.sol
cd /tmp/baby_bank_results/
./r.sh crashes_min/default_id:000000*
No git? if you use a tarball/docker release, ignore this.
Run git submodule update --init
to fetch the latest submodule commits on already cloned repositories.
Make sure to run this also in ./src/eEVM
.
git submodule update --init; cd src/eEVM/; git submodule update --init; cd ../../
Warning: Running git clone --recursive $repo
or passing the --recursive
argument to git sumbodule (update|init)
will make git recursive into submodules of the AFL++ repository, which are not needed for this project. So to save some space it is better to avoid the recusive submodule checkouts.
We provide the following convenience make targets for container-based workflows:
make container-build # build default efcf container
make container-enter # enter default efcf container in current working dir
If you want to ensure a clean build, you can use the following command
make container-build CLEAN_CHECKOUT=1
Alternatively the container can be built with the following docker command:
docker build \
-f docker/ubuntu.Dockerfile \
-t efcf:latest \
.
Note that there is also an Archlinux and Fedora based Dockerfile. They should work as well, but are not as well tested.
For manually distributing a docker image (e.g., if including some local changes), use:
make container-release
docker load -i ./efcf*.tar
We recommend the following docker options for launching:
--security-opt seccomp=unconfined
- better fuzzing perf--net=host
- for easy access to a local ethereum node--tmpfs "/tmp/efcf/":exec,size=6g
- put EF/CF's temporary files onto a ramdisk if possible (less disk wear)--privileged
- to run afl-system-config
or efcfuzz --configure-system
-v
- to persist the output data of EF/CFFor VM or bare-metal-based workflows:
make system-install # install efcf to current system (requires root or sudo rights)
Note that a lot of the scripts work on the relative directory layout anyway, so
this mostly installs dependencies and some tools that are handy to have in your
PATH
. We have tested running EF/CF on the following Linux distributions:
(Distro does not matter that much, we tested LLVM 13 and 14 with 14 being the preferred choice. LLVM 11 or 12 might also still work, ~~but as always - the newer the better~~. The important part is that there is a LLVM that is compatible with our fork of AFL++.)
We have not tested EF/CF on Mac OS natively. Likely things won't work (e.g., afl-clang-lto
on Mac OS seems to not work). The best option is to utilize docker.
# make sure that the submodules are initialized
make gitmodules
# pull the linux/amd64 base image
docker pull ubuntu:jammy --platform linux/amd64
# build the ef/cf image
docker build -t efcf:latest -f docker/ubuntu.Dockerfile --platform linux/amd64 .
# launch the EF/CF container
docker run --tmpfs "/tmp/efcf/":exec,size=8g --platform linux/amd64 --rm -it -v $(pwd):$(pwd) -w $(pwd) efcf:latest
We tested using docker desktop v4.21.1 and basic EF/CF usage works. However, consider the following:
The tools generally do not need to be installed. Install the required
dependencies as in the system-install.sh
script or as in the Dockerfiles.
For convenience we have some scripts to update your PATH
:
# POSIX-like shells (i.e., bash, ...)
source ./scripts/env.sh
# for the fish shell
source ./scripts/env.fish
Some of the scripts require a API key for fetching metadata (e.g., ABI) from
the Etherscan service. If you have an API key you have to set the
ETHERSCAN_API_KEY
environment variable to pass this to scripts. For
a docker-based workflow you can either launch the docker container with the
--env
flag or put your API key into the .etherscan_api_key
file, which will
bake the API key into the docker container.
For convenience, we utilize a wrapper script that takes cares of all the details
for you, when launching the EF/CF fuzzer: efcfuzz
You can set many command line options to configure the fuzzer's behavior with
respect to the build and fuzzing process. Take a look at efcfuzz --help
for a
list of options.
Examples
Compile solidity source code to EF/CF native code and start fuzzing for 5 minutes (aka 300 seconds).
efcfuzz --timeout 300 --source ./data/examples/baby_bank.sol
Alternatively, launch with reduced fuzzing output (--quiet
suppresses the
base fuzzer's output, while --print-progress
will print a short summary of
the fuzzing progress), and launching the fuzzer on 4 cores.
efcfuzz --quiet --print-progress --cores 4 --timeout 300 --source ./data/examples/baby_bank.sol
Use already compiled bytecode and compile the bytecode to EF/CF native code and start fuzzing.
# efcfuzz can handle the combined.json output of the solidity compiler
pushd ./data/examples/; make baby_bank.combined.json; popd
efcfuzz --timeout 300 --bin-runtime ./data/examples/baby_bank.combined.json
# but you can also explicitely pass the runtime and deploy bytecode and the ABI
# definition. This is useful if you want to fuzz contracts using other
# compilers (e.g., vyper).
pushd ./data/examples/; make baby_bank; popd
efcfuzz --timeout 300 \
--bin-runtime ./data/examples/baby_bank.bin-runtime \
--bin-deploy ./data/examples/baby_bank.bin \
--abi ./data/examples/baby_bank.abi
The wrapper can export a contract's state from a go-ethereum/erigon node and start fuzzing from there.
$ efcfuzz --timeout 300 --live-state 0xfffF8D17CB019E0825c478c666B251A7099df3FD
Additionally, you can pass --include-address-deps=y
to recursively search for
addresses of other accounts in the exported contract's storage and also include
those into the state export. However, this does not include other contracts
stored in Solidity mapping
types. To really export the whole state
recursively pass also the --include-mapping-deps=y
flag.
But beware, this recursive lookup can lead to long compilation times and poor
fuzzing performance. Especially, frequently used contracts can have a lot of
internal state and using their exported state can make the fuzzing slow. Check
whether the fuzzer can achieve more than 1k execs/sec. If not you should rather
try to craft an artificial and smaller state. Try running a local go-ethereum
node in the --dev
mode and deploy your contracts there. Then export the live
state from there.
The wrapper caches builds, so a second fuzzing run should launch much faster,
because the initial compilation time is not needed anymore. If you want to only
build and place it into the cache then you can pass the --build-only
argument.
Example: Fuzzing with properties
EF/CF also supports property-based fuzzing using the same property definition as the echidna fuzzer. Properties (or invariants) are expressed as solidity functions that act as a bug oracle to the fuzzer. For example, you can add a solidity function:
function test_property_balance() public view returns (bool) {
return total_balance < 1000;
}
Which represents the property that the total_balance should always be below
false
.To tell EF/CF that this is a property you need to specify a list of function signatures in a file, which will be picked up by EF/CF as a list of properties to check during fuzzing.
The easiest way is to obtain the relevant signatures using the --hashes
flag
to the solidity compiler, e.g.,
solc --hashes ./path/to/your.sol | grep test_property > property_list
Now you can launch the fuzzer with:
efcfuzz --source ./path/to/your.sol --properties ./property_list -C
You can also add --disable-detectors
to disable the built-in ether-based bug oracles.
You can try the following example for property based fuzzing:
efcfuzz \
--properties ./data/examples/harvey_baz_properties.signatures
--disable-detectors \
--until-crash --timeout 120 \
--source ./data/examples/harvey_baz.sol \
Example: Fuzzing for Events
EF/CF supports fuzzing for assertion violations that are expressed through
events. Actually, we also support using arbitrary custom events as a bug
oracle. By default, EF/CF will identify a bug if the target contract logged one
of the following events: AssertionFailed()
, AssertionFailed(uint256)
,
AssertionFailed(string)
, and Panic(uint256)
.
efcfuzz --event-assertions \
--timeout 120 --until-crash \
--source ./data/properties-assertions-tests/verifyfunwithnumbers.sol
You can also specify additional custom event topics/hashes to look out for in a
file with --event-assertions-list ./path/to/eventslist.txt
. As with the
property list before, you can obtain the format by using solc --hashes
and
copying the event hashes and names to the event list file.
By default, EF/CF will ignore events that were not issued by the target
contract. If you want to change that use --event-assertions-target-only=n
.
(Note: you can use --assertions
to enable both event and solidity assertion
checking)
Example: Fuzzing for Solidity ^0.8 Assertions
Currently, we do not support fuzzing for arbitrary assertions in solidity code
for solidity version below 0.8. Previously solidity assertions would simply
trigger a invalid
opcode, resulting in a quite forceful revert. Solidity
version 0.8 changed the behavior to instead of using the invalid
opcode to
revert transactions, they now utilize the revert
mechanism and signal errors
back to the caller. We can utilize this type of error propagation as a bug
oracle in EF/CF. Currently, EF/CF supports checking for the Solidity error type
Panic(uint256)
. More information on Solidity
errors.
efcfuzz --sol-assertions \
--timeout 120 --until-crash \
--source ./data/assertions-tests/overflow.sol
(Note: you can use --assertions
to enable both event and solidity assertion
checking)
System Specs and Configuration
We recommend allocating 4 to 16 cores and roughly 1 GB of memory per core. You
can utilize the --configure-system
flag to configure your system for high
speed fuzzing, or configure it yourself. In docker containers you also need to
configure the host for best performance. If it is an uncritical host you can
launch the container as --privileged
and use
/usr/local/bin/afl-system-config
to configure the system for high speed
fuzzing (note that this essentially runs the container as root).
# configure system
docker run --rm -it --privileged efcf afl-system-config
# run fuzzer (somewhat sandboxed and using a tmpfs for less SSD wear)
docker run --rm -it \
--security-opt seccomp=unconfined \
--tmpfs "/tmp/efcf/":exec,size=6g \
efcf
To run the experiment on the data/tests/
dataset you can use the following
command to build the contracts and their fuzzing harness and then run the
fuzzer with different settings, and multiple repetitions, etc. Because this
would take quite a while we can run those experiments in parallel. We split the
fuzzing experiments into a build step and a fuzz step. The build steps will
build all smart contracts sequentially (the building itself uses multiple cores
though). Then we launch 8 fuzzer instances in the background, which will take
the build artifacts from the build step and start fuzzing runs. The Makefile
will automatically attempt to launch everything in the proper container if
either docker
or podman
is available.
make build-tests
make fuzz-tests CONTAINER_BACKGROUND=1 FUZZER_INSTANCES=8
We disable seccomp and network sandboxing when launching the containers in background. Disabling seccomp sandboxing, improves fuzzing performance. Using the host network allows EF/CF to access Ethereum nodes in the local network without further configuration.
We used the script ./scripts/run-tools-on-dataset.py
to run the other tools
inside docker containers on these datasets, e.g. with these commands for the
multi dataset:
python3 ./scripts/run-tools-on-dataset.py ./data/multi/
cd ./results/tools-multi/
python3 ../../scripts/get-tools-on-dataset-stats.py
head stats.csv
You need to adapt the script to configure the tools and number of runs.
Here, we use the tests
experiment as an example. Simply replace the string
tests
with the name of the experiment in the following steps:
./data/
, e.g., the ./data/tests
dataset with test
contracts. For solidity contracts we have a generic Makefile
to build the
contracts: sol.Makefile
. You can reuse this if you wish, see
./data/tests/Makefile
for an example. tests
dataset we
have the ./scripts/build-tests.sh
script. The build artifacts should be
stored in ./builds/tests/${contract}.build.tar.xz
.tests
dataset
create a script called ./scripts/fuzz-tests.sh
. Typically you can use the
common fuzzing campaign function from ./scripts/common.sh
. Take a look at the
fuzz-tests.sh
for a template.fuzz-tests.sh
will be stored in ./results/run-fuzz-tests/
../scripts/summarize.py
for the bash
based launcher scripts and ./scripts/summarize_l.py
for python launcher
scripts (the efcfuzz
tool)../data/multi
contains the scalability benchmark
we used to assess how well an analysis tool scales to longer transaction
sequences. It consists of three types of contracts:
multi_gen_*.sol
- automatically synthesized contracts, which do a bunch
of require(input <= MAGIC)
and then set an internal state variable. If
all state variables are set, then the selfdestruct
(or echidna oracle)
can be triggered.multi_man_complex_*.sol
- manually created variants that work similarly
to the multi_gen
type of contracts, but features a bit more tricky
constraints (e.g., other things than equality and inequality with a magic
value)justlen_*.sol
- these are taken from the echidna-parade examplemulti_simple_*.sol
- sanity checks that verify that a fuzzer/tool can
could in theory find bugs that require 9 or 10 transactions. Here the
analyzer just needs to call 10 functions in the right order without any
arguments. This is pretty easy for most analysis tools. ./data/throughput
contains the contracts we
used for assessing the throughput. This is a selection of contracts with
varying size. Note that we patched all vulnerabilities in these contracts,
such that found vulnerabilities do not impact throughput measurements../data/cov-max-testset
contains the
contracts we used for comparison of fuzzers based on code coverage../data/ethbmc-vuln
list of contracts that
EthBMC detected as vulnerable../data/ethbmc-timeouts
list of
contracts, where EthBMC stopped analysis due to a timeout../data/reentrancy
a set of contracts
vulnerable to reentrancy attacks../data/sailfish-dao-tp
a set of contracts
that have been verified to contain a reentrancy bug as part of the
sailfish study../data/sailfish-dao
list of all contracts,
where
sailfish
found a reentrancy bug../data/sereum
a list of contracts
vulnerable to reentrancy attacks according to Sereum../data/smartbugs-curated-accesscontrol
contracts from the curated smartbugs, classified as "access control" bugs
(smartbugs github)./data/smartbugs-curated-reentrancy
contracts from the curated smartbugs, classified as "reentrancy" bugs
(smartbugs github)The following datasets contain basic synthetic test contracts to test the fuzzer's capabilities:
./data/tests
basic tests gathered from multiple
sources that check basic capabilities of a fuzzer. All use a selfdestruct
oracle../data/tests-not-vuln
same as tests, but should
not be detected as vulnerable../data/properties-tests
tests for
property-based fuzzing ./data/assertions-tests
tests for
fuzzing for assertions.We utilize wrapper scripts to launch the actual fuzzer (AFL++ in our case).
This is done automatically when using the efcfuzz
launcher.
$ cd data/tests
$ make SimpleDAO.evm2cpp
$ cd ../../src/eEVM/
$ env AFL_BENCH_UNTIL_CRASH=1 ./fuzz/launch-aflfuzz.sh SimpleDAO
If you have tmux and tmuxp installed then for development and inspection the interactive version of the script might be useful:
$ ./fuzz/interactive-aflfuzz.sh -b SimpleDAO
This will then compile and fuzz for quite a while. You can then
cd ./fuzz/out/SimpleDAO*
to view the fuzzing results. Our wrapper scripts do
some extra work on top of launching the afl-fuzz
program, i.e., mostly
post-processing the results. Additionally it will generated several convenience
scripts to analyze the generated test cases.
./a.sh
- Print human-readable form of a test case, wrapper around
efuzzcaseanalyzer
../r.sh
- run a test case with the same settings as when the fuzzer was run../m.sh
- minimize a testcase with the same settings as when the fuzzer was
run../c.sh
- analyze the "chain" of test cases that lead to the given test
case. Useful for analyzing/optimizng the fuzzer. You can quickly see, which
test case was produced by which chain of mutations on which queue entries.
Requires fzf
.There are also some other convenience reports, such as
./bugs
and ./bugtypes
that summarize any bugs that were identified../crashes_min
, which contains minimized crashes of all the afl-fuzz
instances.View EVM Basic Block Code Coverage
$ cat coverage-percent-all.evmcov
70.73170731707317
The script fuzz/evm-bb-coverage.sh
will compute the basic block coverage
given an AFL output directory. The harness can optionally dump a trace of basic
blocks, which are then compared to a list of basic blocks that is output by
evm2cpp
(i.e., the .bb_list
files in eEVM/contracts/
).
By default we also compute the coverage that our default generic seeds (see
eEVM/fuzz/generic_seeds
produce:
$ cat coverage-percent-seeds.evmcov
10.5890
The list of covered basic blocks is stored in the file all.evmcov
.
View Summary of Generated Testcases
efuzzcaseanalyzer
can be used to view/summarize the generated testcases,
e.g.,
$ efuzzcaseanalyzer -a ./contract.abi -s ./crashes_min/
Transactions Sequences:
--------------------------------------------------------------
TX [🪙]
deposit()[🪙];
withdraw(uint256)[↕️ ↩️ ];
withdraw(uint256)[];
--------------------------------------------------------------
Number of fuzzcases: 1
Average number of TXs: 3
Number of unique TX sequences: 1
Number of unique TX sequences (consecutive deduplicated): 1
The summaries are usually stored in the files crashes_tx_summary
and
queue_tx_summary
, but this last one can be a bit verbose.
Analyze a single crashing testcase
$ ./a.sh default/crashes/id:000000,...
# roughly equivalent to running
$ efuzzcaseanalyzer -a ./contract.abi default/crashes/id:000000,...
Block header:
number: 0
difficulty: 0
gas_limit: 0
timestamp: 0
initial_ether: 0
TX with tx_sender: 54 (selector); call_value: 0x0; length: 36; block+=1; #returns=0
func: withdraw(uint256)
input: { Uint(80), }
TX with tx_sender: 238 (selector); call_value: 0x246ddf979; length: 4; block+=1; #returns=0
func: deposit()
input: { }
TX with tx_sender: 153 (selector); call_value: 0x3860e6373; length: 4; block+=1; #returns=0
func: deposit()
input: { }
TX with tx_sender: 166 (selector); call_value: 0x0; length: 36; block+=1; #returns=1
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
returns:
return val: 1; allows reenter: 2; data: 0x0000000000000000000000000000000000000000000000000000000000000001
TX with tx_sender: 166 (selector); call_value: 0x0; length: 36; block+=1; #returns=0
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
And to get the actual result of the fuzz target, you can execute:
$ ./r.sh default/crashes/id:000000,sig:06,src:000000+000010,time:1584,EM-________SAO_______AD
# roughly equivalent to running
$ env EVM_DEBUG_PRINT=1 ./build/fuzz_multitx default/crashes/id:000000,sig:06,src:000000+000010,time:1584,EM-________SAO_______AD
[...]
account 0xc4b803ea8bc30894cc4672a9159ca000d377d9a3 has balance 0x100000000000000000000000000000001bc16d67562e80000( > 0x1000000000000000000000000000000000000000000000000)
Aborted (core dumped)
This gives you a lot of verbose output, including some parts of the execution traces of the contracts and the balance check result that the harness does.
Minimizing Crashing Inputs
Crashing inputs often do contain unrelated transactions due to the randomized
testing approach. This can be mitigated by performing a minimization on the
crashing input (i.e., reduce the input as long as it still causes a crash).
If you want to minimize non-crashing inputs, then you can use the -M
flag to
enable minimization according to coverage as minimization criterion.
The following command will reduce the testcase and overwrite the file:
$ efuzzcaseminimizer -oa ./contract.abi ./build/fuzz_multitx ./default/crashes/id:000000,sig:06,src:000000+000010,time:1584,EM-________SAO_______AD
[..]
=== Before minimizing: ===
Block header:
number: 0
difficulty: 0
gas_limit: 0
timestamp: 0
initial_ether: 0
TX with tx_sender: 54 (selector); call_value: 0x0; length: 36; block+=1; #returns=0
func: withdraw(uint256)
input: { Uint(80), }
TX with tx_sender: 238 (selector); call_value: 0x246ddf979; length: 4; block+=1; #returns=0
func: deposit()
input: { }
TX with tx_sender: 153 (selector); call_value: 0x3860e6373; length: 4; block+=1; #returns=0
func: deposit()
input: { }
TX with tx_sender: 166 (selector); call_value: 0x0; length: 36; block+=1; #returns=1
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
returns:
return val: 1; allows reenter: 2; data: 0x0000000000000000000000000000000000000000000000000000000000000001
TX with tx_sender: 166 (selector); call_value: 0x0; length: 36; block+=1; #returns=0
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
=== After minimizing: ===
Block header:
number: 0
difficulty: 0
gas_limit: 0
timestamp: 0
initial_ether: 15133991795
TX with tx_sender: 4 (selector); call_value: 0x246ddf979; length: 4; block+=0; #returns=0
func: deposit()
input: { }
TX with tx_sender: 4 (selector); call_value: 0x0; length: 36; block+=1; #returns=1
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
returns:
return val: 1; allows reenter: 2; data: 0x
TX with tx_sender: 0 (selector); call_value: 0x0; length: 36; block+=1; #returns=0
func: withdraw(uint256)
input: { Uint(37000000000000000000), }
The test case format is geared towards fuzzing and is not as straight forward to read. There are several subtleties you have to be aware of.
returns
or spurious reenter
flags. Use
the test case minimizer to get rid of those.reenter
flag is set to something, this does not necessarily
mean that the contract is reentered, just that the harness code will try to
do it if possible. For example, if the contract does not perform a call the
reenter flag is ignored as there is no possibility to reenter. Usually the
minimizer will remove any spurious reenter flags.In general many of those issues go away when you use the test case minimizer, so it is always a good idea to use this one before analyzing generated test cases.
We observed several types of false alarms that seem to be recurring when fuzzing contracts with EF/CF.
N
blocks. EF/CF's simulated attacker is capable of waiting for N
blocks
and then receives the interest payout, which is again picked up by the
Ether-gains bug oracle.DELEGATECALL
: currently we report a
controllable delegatecall as soon as it is invoked. However, there are
multiple contracts, which feature functions that intentionally allow the
caller to perform a delegatecall to an arbitrary address. However, these
functions will unconditionally revert the transaction immediately after the
delegatecall. This prevents any state updates or ether transfers from
persisting. Typically such functions have words like "simulate" in their
function names and then they are easy to spot.
initializer
, init
, or similar.We did our best to make this somewhat usable, but it is still a research prototype. Do expect things breaking. Here are some common issues we observed:
TOKENPASTE
macro.efcfuzz
guesses the wrong contract name (i.e.,
it guesses an abstract contract), try passing --name YourContract
to
specify the target contract.