Foundry for studying hacks

Author: Daniil Ogurtsov
Security researcher at MixBytes
Intro
This article aims to explain how the Foundry smart contract development framework can be used in studying hacks. It can be a useful workflow for beginners who prefer studying hacks as a step in mastering Solidity skills.
Setting up
Foundry is a smart contract development framework, like Hardhat or Brownie. Like others, it allows the compilation and deploying smart contracts and projects, writing all the variety of necessary tests.
The key advantages are:
  • it is fast
  • you write in Solidity, both smart contracts, tests, and script (no need to switch between Python/JS and Solidity)

Follow this guide to install Foundry:
https://book.getfoundry.sh/getting-started/installation
The simplest way to build a project is running:
forge init
As a result, you will have this project structure.
├── lib
│   └── forge-std
        ...
├── script
│   └── Counter.s.sol
├── src
│   └── Counter.sol
└── test
│   └── Counter.t.sol
├── foundry.toml
├── README.md
This project has a basic smart contract and a simple unit test. We can run the test:
forge test
Forking
By default, Foundry launches a network on a local machine. But you can easily customize to use any real network - Foundry allows forking networks, including specifying the exact block.

In practice it is widely used for real-world tests - some projects prefer testing their project in the environment of real tokens and other projects.
Here we should introduce the concept of Cheatcodes. Foundry has built-in precompiles - it treats some smart contract calls as reserved commands, allowing some magic not allowed in real networks.

There is a huge list of available Cheatcode commands:
https://book.getfoundry.sh/forge/cheatcodes?highlight=cheatc#cheatcodes
Manipulating cheatcodes is the key to mastering Foundry.
Now let's write a test:
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";

contract ForkTest is Test {

    function setUp() public {
        vm.createFork(MAINNET_RPC_URL);
    }
    
    function test_PrintBalanceCETH {
        address cETH = 0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5;
        uint256 balanceOnCETH = cETH.balance;
        console.log("Balance on cETH: ", balanceOnCETH);
    }
We created ForkTest file, which inherits from Test (imported from forge-std/Test.sol). This is how we connect our Cheatcodes.

It has function setUp() - this is a reserve function name. It will be run before every other test function. So here we can indicate operations that are the same for every test function in the file. Forking perfectly fits here:
vm.createFork("MAINNET_RPC_URL")
This cheatcode tries to find an RPC named "MAINNET_RPC_URL" in your .env file in the project directory.

So we should configure an env file - you should add ".env" file to the project directory.
Fill it with this line:
MAINNET_RPC_URL=https://eth.llamarpc.com
You can register on an RPC provider website to take the key (like Alchemy).
Or you can google any publicly available RPCs (but remember that not all of them will allow forking).

If your tests require multiple RPCs, you can indicate any necessary amount of them in your .env file.
Technically it is possible to run multiple forks and switch between them in one test file, everything using just Cheatcode commands. So, having multiple RPCs is the case.

When function setUp() sets a fork, it is time for tests. They must be named as test_YourTestName.
function test_PrintBalanceCETH {
        address cETH = 0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5;
        uint256 balanceOnCETH = cETH.balance;
        console.log("Balance on cETH: ", balanceOnCETH);
    }
This function introduces a new Cheacode - console.log().
It is very handy and allows printing on your console.

So, this test takes the mainnet cETH address and prints its ETH balance.
To execute tests, run the following forge terminal command:
forge test
You will notice that you see nothing printed. That's because Foundry has multiple levels of script detailization.

As described in Foundry docs:
Level 1 (forge test): Only displays a summary of passing and failing tests.
Level 2 (forge test -vv): Logs emitted during tests are also displayed. That includes assertion errors from tests, showing information such as expected vs actual.
Level 3 (forge test -vvv): Stack traces for failing tests are also displayed.
Level 4 (forge test -vvvv): Stack traces for all tests are displayed, and setup traces for failing tests are displayed.
Level 5 (forge test -vvvvv): Stack traces and setup traces are always displayed

So, you will see your console.log() if you run tests as:
forge test -vv
Forking to study hacks
We will use this repository by SunWeb3Sec to study the library of hacks.
https://github.com/SunWeb3Sec/DeFiHackLabs

It is a large Forge repository with a long list of hack demonstrations.
You can find all the tests in src/test. To the day of writing this article, the repository consists of more than 300 hacks.
First of all, follow the setup instruction in the README of the repo, as cloning a repo has a different script.
When you have it locally, take a look at the repo structure - this repo has many tests, but don't run everything with forge test - it will take a lot of time to run them all.

Instead, use this:
forge test --contracts src/test/NAME_OF_THE_FILE.t.sol -vv
Or this one:
forge test --match-path src/test/NAME_OF_THE_FILE.t.sol -vv
Simple hack
In this section we will go through one of the hacks - CowSwap exploit.
https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/CowSwap_exp.sol

This is the transaction of the hack.
https://etherscan.io/tx/0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b
As you can see an attacker managed to build a malicious calldata. So this hack will be very simple to demonstrate additional Foundry possibilities.

First of all, look at the setUp() function:
    function setUp() public {
        cheats.createSelectFork("mainnet", 16_574_048);
        vm.label(address(DAI), "DAI");
        vm.label(address(swapGuard), "SwapGuard");
        vm.label(address(GPv2Settlement), "GPv2Settlement");
    }
This type of forking is almost the same. There is a small difference between:
vm.createSelectFork()
and
vm.createFork()
You can learn in deep here:
https://book.getfoundry.sh/cheatcodes/forking

cheats and vm have no difference for our purposes.
But both createSelectFork() and createFork() can accept the second argument - the block number. As you can see for this attack it is 16_574_048.
Then you have this cheatcode:
vm.label(address(DAI), "DAI");
It helps to read traces better. If traces in your console print address 0x6B175474E89094C44Da98b954EedeAC495271d0F it will be displayed with the "DAI" label. It is handy in analyzing complicated traces.

Then, you have the attack flow in the function testExploit(). Here is the question - who is the attacker in this test? For all tests, Foundry has a default msg.sender and tx.origin. For this simple attack, they are not changed, because everyone can make this attack, even some default address.
More complex hack
Here we will study this Hundred Finance hack.
https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/HundredFinance_2_exp.sol

The official post-mortem:
https://blog.hundred.finance/15-04-23-hundred-finance-hack-post-mortem-d895b618cf33
It utilizes the inflation attack, a well-known attack vector. If you are not familiar, we recommend our article explaining the topic in deep.
https://mixbytes.io/blog/overview-of-the-inflation-attack

The exploit test is messy. First of all, it has three smart contracts in it.
  • contractTest - test script itself
  • ETHDrain - code of one of the attacker contracts
  • tokenDrain - same thing, but for tokens

This is done to have all smart contract codes in one file. As the repo reserves one file for an attack.
If the call is made from a deployed smart-contract it will be treated as usual - that this deployed smart-contract makes a call (msg.sender is changed according to EVM rules).

In the attack script, we have these lines:
        ...
        cheats.startPrank(HundredFinanceExploiter);
        hWBTC.transfer(address(this), 1_503_167_295);
        cheats.stopPrank();
        ...
startPrank() is the extremely important cheatcode. It allows changing a msg.sender for the next calls. It ends with the cheats.stopPrank() which changes msg.sender back to the default address.

The widely used alternative is prank(). It changes a msg.sender only for the next call.
The script ends with taking a flashloan. But it is not the end, because it continues in the flashloan callback - at the function executeOperation().
This function runs a few attacks - one ETHDrains() and multiple tokenDrains(). You can find these functions below.
Each of them follows the same pattern where some of the attack steps are placed either in tokenDrains() function or in tokenDrain smart contract constructor.

Each step is followed by console.log() - as a result, the attack is very well documented.
Further steps
Now you are familiar with Foundry. You can use it in development or keep studying hacks in the same way as we did here.
Other posts