Go to course navigation

8 - Security I.

Tutorial objectives

  • Introduction into security auditing
  • Increase skill in recognizing known security flaws
  • Ability to write unit tests in Brownie

Tutorial pre-requisites

    $ brownie bake token
    $ cd token
    $ brownie compile
    $ brownie test
Caution:

Computers in the laboratory don’t allow to install programs and save their state between login sessions. Please bring your own hardware for these tutorials.

Hacking approach

There are several different techniques to find vulnerabilities in smart contracts. To achieve the best possible output, it is recommended to combine them together.

  • The first essential part is understanding the target contracts' main purpose. Defi applications can be very complex, with different approaches to the same problems as reward logic, token distribution, etc. Read the documentation if it exists and try to understand the idea behind the project.
  • Use the tools with automated vulnerability detectors (e.g. MythX, Woke, Slither, etc.). This way, finding the most common vulnerabilities is possible, but it is necessary to be aware of many false positives.
  • The code review is the most important and challenging part of the process. An auditor can check +- 100 lines of code daily. While browsing the code, use the tooling to extract helpful information (call graph, inheritance graph, state variables, etc.). The first goal is to understand the purpose of every single line. Sometimes the line does not make much sense - bug or feature? With a good understanding of the code, we can focus on the crucial parts:
    • Access control - Who can call the function? Why is there no modifier?
    • Requirements and conditions - Is the operator < = strict enough? Any way to bypass it?
    • State variables - Can a regular user make a state changing transaction? Does the change affect other users? Does the change happen in the correct order (reeantrancy)?
    • Inputs - Think about every input as a payload. Is there any payload that can allow me to access a critical line of code? Look for edge cases.
    • Solidity version - Does the contract use the old version? Is there any known vulnerability in this version?
  • Interact with the contract when suspicious behavior is found. Deploy the project, simulate the real-world behavior, and write tests. If a project does not provide tests with good coverage, write them yourself. Unit tests, property-based tests, fuzzing, etc. Well-written tests can be used to verify the contract behavior and also break the code logic with randomized outputs.

Common vulnerabilities

Just a few common vulnerabilities are described. Most of the issues cannot be easily categorized. Some of the known vulnerabilities can be found in SWC Registry of known issues.

Integer under/overflow

When an unsigned integer reaches its maximal/minimal value, and then, it is incremented/decremented. E.g. uint8 has range 2^8 (0-255).

    uint8 balance = 255;
    balance++; // balance = 0
    balance--; // balance = 255

The issue no more exists for solidity version >= 0.8.0.

Reentrancy

A dangerous situation when calling an external contract address. If the called contract is malicious, it can take control of the control flow. This type of bug can have many forms, but the basic idea stays the same. If there is a call to the external address before the state change, there is a possibility of making a reentrancy attack. Reentrancy can be in the same function but also cross-function, cross-contract, and even cross-chain thanks to the cross-chain bridges.

mapping (address => uint) private userBalances;

function withdrawBalance(uint amount) public {
    require(userBalances[msg.sender] >= amount, "insufficient funds")
    uint amountToWithdraw = userBalances[amount];
    msg.sender.call.value(amountToWithdraw)(""); // Caller's code can be executed, and can call withdrawBalance again
    userBalances[msg.sender] -= amount;  // State variable is updated after the call, attacker can drain the contract
}

Denial of Service

Make the contract unusable for future use.

Block Gas Limit: Each block has an upper bound on the amount of gas that can be spent and thus the amount of computation that can be done. This is the Block Gas Limit. If the gas spent exceeds this limit, the transaction will fail. This leads to a couple of possible Denial of Service vectors. A simple example is when users can store an array with unbounded length. Whenever the contract loop over the array and do some computation, it is possible to reach the block gas limit and make the transaction fail.

Front Running

Since all transactions are visible in the mempool for a short while before being executed, network observers can see and react to an action before it is included in a block. An example of how this can be exploited is with a decentralized exchange where a buy order transaction can be seen, and second order can be broadcasted and executed before the first transaction is included.

Flash Loan attack

Some protocols offer the possibility to borrow a large amount of tokens for a short time. The time is limited to one transaction. It means a borrower must create a smart contract with the logic of borrowing money, doing some activities with it, and then returning the loan. This approach can be used in algorithmic arbitrage trading, but it can also be weaponized to attack the protocol.

E.g. DAO (Decentralized Autonomous Organization) is a smart contract that can be used as a governance contract. Users stake their tokens, and based on the staked amount, they receive the corresponding voting power. DAO can be used to govern the staking pools and control a fee amount or an address of a fee receiver.

Attack scenario:

  • Attacker uses a flash loan,
  • stakes tokens to the DAO contract,
  • because of the flash loan he has a lot of voting power (more than 50%) to change the fee receiver to his own address,
  • unstake
  • return the loan.

It can all be done in one transaction using the customized smart contract.

Brownie

Python-based development and testing framework for EVM smart contracts.

Documentation: https://eth-brownie.readthedocs.io/en/stable/index.html

Useful commands

  • init - initialize an empty project
  • compile - compile all of the contract sources
  • pm - package manager
  • test - will run your tests
  • console - starts local blockchain and python console
  • gui - will open GUI

Console

Brownie console is a great way to interact with smart contracts. It can be used to deploy contracts, call functions, read state variables, etc. Because of the python language, it is possible to use all the python features. The same syntax is also used in brownie test files.

Unit test

Useful commands

  • test --coverage - show coverage
  • test --gas - show gas usage
  • test --interactive - open interactive console if test fails
  • test - v - verbose mode

Test filenames must match test_*.py or *_test.py, be placed in tests/ folder, and test functions must start with test.

Examples:

import pytest
import brownie

def test_add_10(SomeContract,accounts):
    contract = SomeContract.deploy({'from': accounts[0]})
    contract.add(10)
    assert contract.actualBalance() == 10

def test_add_20(SomeContract,accounts):
    contract = SomeContract.deploy({'from': accounts[0]})
    contract.add(20)
    assert contract.actualBalance() == 20

Use @pytest.fixture on the function that initializes the contract to avoid code duplicity.

import pytest
import brownie

@pytest.fixture
def some_contract(SomeContract,accounts):
    contract = SomeContract.deploy({'from': accounts[0]})
    return contract

def test_add_10(some_contract,accounts):
    some_contract.add(10)
    assert some_contract.actualBalance() == 10

def test_add_20(some_contract,accounts):
    some_contract.add(20)
    assert some_contract.actualBalance() == 20

Fixtures can be used for any repetitive task. E.g. deploy a contract, mint some tokens, distribute tokens etc. With Python syntax and Brownie functionalities, it is possible to efficiently simulate real-world project behavior.

import pytest

from brownie import Token, accounts

@pytest.fixture
def token():
    return accounts[0].deploy(Token, "Test Token", "TST", 18, 1000)

@pytest.fixture
def distribute_tokens(token):
    for i in range(1, 10):
        token.transfer(accounts[i], 100, {'from': accounts[0]})

For handling reverted transactions use with brownie.reverts(): block.

import pytest
import brownie

@pytest.fixture
def some_contract(SomeContract,accounts):
    contract = SomeContract.deploy({'from': accounts[0]})
    return contract

def test_add_10(some_contract,accounts):
    some_contract.add(10)
    assert some_contract.actualBalance() == 10

def test_add_260(some_contract,accounts):
    with brownie.reverts(): # should revert because of uint8
        some_contract.add(260)

Useful sources