Topics Covered:
- Setting up our project
In this primer we cover the basics of how to setup foundry and pull the contracts we need for each challenge, and go through an example setup.
Setting Up the Dev Environment
Foundry can be installed by following the direction on their site. Once installed, you can use the foundryup
command to update all the tools we need for this course.
Assuming you have foundry and git installed: (Windows users may want to use WSL)
# update forge, chisel, cast and anvil
foundryup
My repo below has the full code and solutions. You can use it as a reference if you wish, or just copy the contracts from OpenZeppelin as we go.
Building a new repo
# create a new directory and initialize forge
mkdir {PROJECT NAME}
forge init
Copying From Me
# clone the repo and open the new directory
git clone https://github.com/jordaniza/assemblynaut.git
cd assemblynaut
# build the repo
forge build
You should be good to go!
Example Structure
Before we start, let’s lay a foundation for how we are going to structure these tests.
For this example, let’s use the Fallback
contract in Ethernaut.
Our basic project structure should look like this:
lib/ # dependencies
src/
|-- Fallback.sol # target contract
test/
|-- Fallback.t.sol # attack contract
foundry.toml # foundry config
Our source code (the contract we are attacking) will live in the src
folder, and our hacks will live in the test
folder.
Fallback.sol:
The first contract in Ethernaut is a relatively simple starting point, copy this code into the Fallback.sol
file in the src
folder:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
Fallback.t.sol
Our Fallback.t.sol
will have the following structure:
pragma solidity ^0.8.0;
// import the forge test utilities
import "forge-std/Test.sol";
// import our target contract
import "../src/Fallback.sol";
// ensure our test contract has the forge test utils enabled
contract ASMFallback is Test {
// here we will declare any state variables
function setUp() public {
// this function runs before each of our test
// functions, and is where we will set up
// our contract and state
}
function testAttackFallback() public {
// this is where our attack will go
// forge tests must always start with the word
// 'test' and be marked as `public` or `external`
}
}
ASMFallback is Test
says that our contract is inheriting the foundry test suite, giving us access the forge test utilities in our test contract.
Writing the Test Case
Let’s fill this in: first let’s define 2 state variables:
- Our
target
contract, which is of typeFallout
- Our
attacker
which is a nominal address
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Fallback.sol";
contract ASMFallback is Test {
Fallback public target;
address payable public attacker = payable(address(420));
// ...
}
Now, let’s create our setUp
hook, recall the aim of this challenge:
You will beat this level if:
You claim ownership of the contract
You reduce its balance to 0
So we need to make sure the target is deployed and has some ether. For good measure, let’s also give the attacker some ether.
We can use foundry’s vm.deal(address who, uint256 wei)
to mint arbitrary amounts of wei to any address:
function setUp() public {
target = new Fallback();
vm.deal(attacker, 1 ether);
vm.deal(address(target), 1000 ether);
}
Lastly, we want to execute our test as the attacker. We can do this using foundry’s vm.startPrank(address who)
which will execute all subsequent functions as the address who
.
function testAttackFallback() public attack {
vm.startPrank(attacker);
// our exploit goes here
vm.stopPrank();
}
Personal preference here, but this pattern is so common I like to mark the attack function as such with a modifier:
modifier attack() {
vm.startPrank(attacker);
_;
vm.stopPrank();
}
function testAttackFallback() public attack {
// our exploit goes here
}
Finally, set’s complete the setup by defining our assertions: what must hold true for this challenge to be considered “Complete”?
function testAttackFallback() public attack {
// ... exploit
// you claim ownership of the contract
assertEq(target.owner(), attacker);
// you reduce its balance to 0
assertEq(address(target).balance, 0);
}
With that, we are good to go, putting it all together:
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Fallback.sol";
contract ASMFallback is Test {
Fallback public target;
address payable public attacker = payable(address(420));
function setUp() public {
target = new Fallback();
vm.deal(attacker, 1 ether);
vm.deal(address(target), 1000 ether);
}
modifier attack() {
vm.startPrank(attacker);
_;
vm.stopPrank();
}
function testAttackFallback() public attack {
// assertions
assertEq(target.owner(), attacker);
assertEq(address(target).balance, 0);
}
}
Checking it all works
Let’s test everything is functioning as intended. We can run our tests using foundry’s forge test
command:
Failing tests:
Encountered 1 failing test in test/Fallback.t.sol:ASMFallback
[FAIL. Reason: Assertion failed.] testAttackFallback() (gas: 40534)
Test failing is fine in this case - we haven’t written any logic.
If you’re not seeing the above for any reason then you should feel bad you should have more specific errors that tell you what’s wrong.
Assuming you ARE seeing the above, then all is good and you’re ready to go.