Introduction
Ethernaut is a Web3/Solidity based CTF developed by OpenZeppelin. The CTF is played online using Ethereum test networks, but for faster development, I setup a local dev environment using hardhat forking Sepolia testnet. Comparing with Capture-the-Ether, Ethernaut is more up-to-date in aspects like Solidity versions (CTE uses ^0.4 versions) and DeFi-related content. I found Ethernaut to be highly educational and comprehensive, offering a thorough overview of smart contract security vulnerabilities.
This is the Part 1 of Ethernaut Solution series:
You can find all my solutions and local setup in this GitHub repository.
This post will cover solutions for challenges 0-9. These ten challenges are relatively straightforward, requiring only an understanding of Solidity language and some basic security patterns.
Local Setup
In summary, there’s a central Ethernaut factory contract that creates instances for each level. To start a level, you input the level’s address, and the factory contract then deploys that specific level and emits a LevelInstanceCreatedLog
event with the level instance address. After that, you must link the contract ABI of each level to its address to interact with it.
The factory contract is also responsible for checking whether the level is completed. By submitting the address of the level instance, it will emit a LevelCompletedLog
event to indicate successful completion.
Solutions
0. Hello
Simply call password()
and pass it in authenticate()
. (Password is ethernaut0
.)
1. Fallback
First call contribute()
to send some eth, then do an ether transfer (e.g call()
in Solidity) to send some eth and claim ownership. Last, call withdraw()
.
2. Fallout
The Fal1out()
is mis-spelled, pretending to be a constructor function. Simply call it to claim ownership.
3. Coin Flip
The contract incorrectly uses blockhash(block.number - 1)
as a pseudo-random number generator. This is a classic error because other contracts calling this function will share the same global variables. The solution is to create a contract that uses the same formula to calculate the flip guess and then send it to the challenge contract.
4. Telephone
tx.origin
and msg.sender
are not the same:
tx.origin
refers to the address that originally initiated the transaction.msg.sender
indicates the address that called the function.
For instance, in a call chain of A -> B -> C
, within contract C
, tx.origin
would be A
, but msg.sender
would be B
.
So we can create a new contract that calls the challenge contract’s changeOwner()
function, and then we call this new contract.
5. Token
This is a classic integer underflow issue. The contract incorrectly uses require(balances[msg.sender] - _value >= 0);
to check if there is enough balance. However, this check actually does nothing due to integer underflow.
6. Delegation
This challenge requires us to know how delegatecall()
works. When A
uses delegatecall()
on B
, B
operates with A
’s msg.sender and state storage, essentially running B
’s code in A
’s context. So the solution is simple, we can craft a pwn()
function call with raw data and call Delegation
contract.
This is how to do it using ethers.
const interface = ethers.Interface.from(["function pwn()"]);
[eoa] = await ethers.getSigners();
tx = await eoa.sendTransaction({
to: await challenge.getAddress(),
data: interface.encodeFunctionData("pwn", [])
});
await tx.wait();
7. Force
Use selfdestruct()
to force-send a contract some ether.
8. Vault
Even though the password
variable is marked as private
, it’s only hidden from other contracts, not offchain access. We can easily read its value by accessing the first slot (recall that slot numbers starting from 0) of the contract.
// All data is public on-chain.
const password = await challenge.runner.provider.getStorage(await challenge.getAddress(), 1);
tx = await challenge.unlock(password);
await tx.wait();
9. King
This challenge demonstrates a classic DoS (Denial of Service) attack. By deploying a contract that does not implement the receive()
function to claim the king’s position, the challenge contract becomes unable to repay the ether, effectively making us the permanent king.
The key lesson here is to be cautious with external calls from unknown sources, even for basic ether transfer calls. It’s advised to establish a pull payment system rather than relying on push methods.
Read this consensys article for more details: https://consensys.github.io/smart-contract-best-practices/attacks/denial-of-service/