Inkscope - Ink! Fuzzer

Inkscope - Ink! Fuzzer

0xLucca's photo
·

7 min read

An Ink! Fuzzer that passes the Ityfuzz challenge in less than 15 seconds.

Exploring ink! - Polkadot's Smart Contract Language

Polkadot is a cutting-edge blockchain protocol that supports a diverse ecosystem of parallel chains called parachains. While parachains function as layer-1 blockchains with their own runtimes and governance models, Polkadot also enables the development and deployment of smart contracts through its specialized language, ink!.

ink! leverages the power of Rust, a systems programming language renowned for its safety, performance, and efficiency. By adapting a subset of Rust, ink! inherits its type safety, memory safety, and absence of undefined behaviors, ensuring robust and secure smart contract development. Minimal ink! example here.

Securing Smart Contracts with Fuzz Testing

Ensuring the security and reliability of smart contracts is paramount in the blockchain ecosystem. Smart contracts, once deployed, become immutable and potentially manage significant amounts of value. Consequently, any vulnerabilities or bugs can have severe consequences, leading to financial losses, data breaches, or system compromises.

Fuzz testing, or fuzzing, is a highly effective technique for identifying vulnerabilities and edge cases in software systems, including smart contracts. By generating vast numbers of semi-valid and invalid inputs, fuzzers stress-test the target system, exposing potential vulnerabilities that might have gone unnoticed through traditional testing methods.

While the EVM ecosystem has benefited from several mature fuzzers like Echidna and Foundry for testing smart contracts, the ink! ecosystem has been lacking a dedicated fuzzer.

Inkscope Fuzzer

Inkscope fuzzer is a property-based fuzzing tool designed to find bugs and vulnerabilities in Ink! smart contracts during the development phase. It utilizes the ink-sandbox runtime emulation engine to execute and test Polkadot smart contracts against user-defined properties.

These properties are written in ink! and the fuzzer starts from a standard .contract file generated by compiling the ink! contract. The fuzzer generates random inputs and send messages to the emulated contract to reach interesting states. Then it checks if the provided properties hold true for the smart contract under test.

If the fuzzer discovers a property violation, it prints the complete execution trace, including the contract deployment process, all the messages called, and the violated properties. This detailed output assists developers in identifying and fixing issues within their contracts. Moreover the resultant failing trace is optimized and reduced to a minimal form for better readability.

By incorporating property-based testing through inkscope fuzzer, developers can enhance the reliability and security of their smart contracts before deployment on a live network.

How to use it?

You start from an existing ink! project. To be able to fuzz this smart contract, follow these steps:

  1. Enable the fuzzing feature in the Cargo.toml
[features]
...
fuzz-testing = []
  1. Write the invariants of your contract. Inkscope fuzzer supports 3 approaches for writing properties to detect bugs in ink! smart contracts:

    1. Dedicated Property Messages (Recommended):
      The recommended approach is to write a new message that performs a specific action and if correct it always returns a boolean value: true. If the function returns false, it indicates that the property has been violated, signaling the presence of a bug. These properties can also execute other functions that modify the contract state, but the state changes done during the property function execution are not saved as they are performed in a dry-run during the fuzzing process.
      For convention, these messages name should start with inkscope_ to differentiate them from regular contract messages.

      Example:

       #![cfg_attr(not(feature = "std"), no_std, no_main)]
      
       #[ink::contract]
       mod example_contract {
      
           #[ink(storage)]
           pub struct ExampleContract {
              // State variables
           }
      
           impl ExampleContract {
               // Regular contract constructors and messages
               ...
           }
      
           // Dedicated property messages for fuzz testing
           #[cfg(feature = "fuzz-testing")]
           #[ink(impl)]
           impl ExampleContract {
               #[cfg(feature = "fuzz-testing")]
               #[ink(message)]
               pub fn inkscope_bug(&self) -> bool {
                   // Property logic that returns true or false
               }
           }
      
       }
      
    2. Assertions in Existing Calls:
      Incorporate assert!(..) statements within the existing contract calls. If any of these assertions fail, it means a bug has been discovered in the contract.

      Example:

       #![cfg_attr(not(feature = "std"), no_std, no_main)]
      
       #[ink::contract]
       mod example_contract {
      
           #[ink(storage)]
           pub struct ExampleContract {
              value: u128,
           }
      
           impl ExampleContract {
               // Regular contract constructors and messages
               #[ink(constructor)]
               pub fn new(init_value: u128) -> Self {
                   Self { value: init_value }
               }
      
               #[ink(message)]
               pub fn incr_value(&self, value: u128) {
                   self.value += value;
      
                   // Write assertions to check property
                   assert!(self.value <= 10, "Value must be less than or equal to 10");
               }
      
               ...
      
           }
      
       }
      
    3. Panic-based Properties:
      Very similar to the assertion properties, the third approach involves making the contract panic when a specific fail condition is met. This signals to the fuzzer that a property has been violated and a bug has been found.

      Example:

       #![cfg_attr(not(feature = "std"), no_std, no_main)]
      
       #[ink::contract]
       mod example_contract {
      
           #[ink(storage)]
           pub struct ExampleContract {
              value: u128,
           }
      
           impl ExampleContract {
               // Regular contract constructors and messages
               #[ink(constructor)]
               pub fn new(init_value: u128) -> Self {
                   Self { value: init_value }
               }
      
               #[ink(message)]
               pub fn incr_value(&self, value: u128) {
                   self.value += value;
      
                   // Check property and panic if violated
                   if self.value > 10 {
                       panic!("Value must be less than or equal to 10");
                   }
               }
      
               ...
      
           }
      
       }
      

The choice of property-writing approach depends on the specific requirements and complexity of the smart contract being tested. The dedicated property messages method is generally recommended as it provides a clear separation of concerns and makes the testing process more organized and maintainable.

  1. Execute the fuzzer

     inkscope-fuzzer /path/to/.contract
    

Example

Let's perform all the necessary steps to execute the fuzzer. Below is a simple example to understand how it works, similar to the ItyFuzz contract. (see ItyFuzz: Snapshot-Based Fuzzer for Smart Contract, section 3 Figure 2 for the original pseudo-code of the contract).

Otherwise here, we reproduce a similar contract in ink!:

#![cfg_attr(not(feature = "std"), no_std, no_main)]

#[ink::contract]
mod ityfuzz {

    const BUG_VALUE: u128 = 15;

    #[ink(storage)]
    pub struct Ityfuzz {
        counter: u128,
        bug_flag: bool,
    }

    impl Ityfuzz {
        #[ink(constructor)]
        pub fn default() -> Self {
            Self {
                counter: 0,
                bug_flag: true,
            }
        }

        #[ink(message)]
        pub fn incr(&mut self, value: u128) -> Result<(), ()> {
            if value > self.counter {
                return Err(());
            }
            self.counter = self.counter.checked_add(1).ok_or(())?;
            Ok(())
        }

        #[ink(message)]
        pub fn decr(&mut self, value: u128) -> Result<(), ()> {
            if value < self.counter {
                return Err(());
            }
            self.counter = self.counter.checked_sub(1).ok_or(())?;
            Ok(())
        }

        #[ink(message)]
        pub fn buggy(&mut self) {
            if self.counter == BUG_VALUE {
                self.bug_flag = false;
            }
        }

        #[ink(message)]
        pub fn get_counter(&self) -> u128 {
            self.counter
        }
    }
}

In this contract, the incr and decr functions increment and decrement the counter variable based on the condition compared to the provided value, respectively. The buggy function sets the bug_flag variable to false if the counter variable is equal to BUG_VALUE.

Note: The sequence of incr and decr messages required to break the property by making the counter equal to BUG_VALUE is not a trivial one. Each incr and decr call need to pass a specific condition as explained in the ityfuzz paper.

Fuzzing the contract

To test the contract, write a property as an ink! message that checks the value of the bug_flag variable. If the message returns false, it means that property was violated, and the fuzzer will print the execution trace and the violated property.

Note that this message is wrapped in a #[cfg(feature = "fuzz-testing")] attribute to avoid compiling it in the final contract. In order for this to work, the fuzz-testing feature must be enabled in the Cargo.toml file.

[features]
...
fuzz-testing = []

This is the property that checks the bug_flag must always be true:

#![cfg_attr(not(feature = "std"), no_std, no_main)]

#[ink::contract]
mod ityfuzz {
    ...

    #[cfg(feature = "fuzz-testing")]
    #[ink(impl)]
    impl Ityfuzz {
        #[cfg(feature = "fuzz-testing")]
        #[ink(message)]
        pub fn inkscope_bug(&self) -> bool {
            self.bug_flag
        }
    }
}

Once the property is written, we can compile the contract:

cd test-contracts/ityfuzz
cargo contract build --features fuzz-testing

And then, execute the fuzzer against it and check the output

 inkscope-fuzzer ./test-contracts/ityfuzz/target/ink/ityfuzz.contract

If the fuzzer finds a property violation, it will print the execution trace and the violated property:

Property inkscope_bug failed ❌
  Message0: default()
  Message1: incr(UInt(0))
  Message2: incr(UInt(0))
  Message3: incr(UInt(2))
  Message4: incr(UInt(2))
  Message5: incr(UInt(2))
  Message6: incr(UInt(0))
  Message7: incr(UInt(0))
  Message8: incr(UInt(2))
  Message9: incr(UInt(0))
  Message10: incr(UInt(0))
  Message11: incr(UInt(1))
  Message12: incr(UInt(2))
  Message13: incr(UInt(2))
  Message14: incr(UInt(0))
  Message15: incr(UInt(2))
  Message16: buggy()
  Property: inkscope_bug()

Demo

Documentation

The documentation and repository provides comprehensive guides and examples to get you started.

Acknowledgements

The development of this fuzzer was funded by the Web3 Grants Program