Ethernaut challenges, comparable to a Web3-themed hacking Capture The Flag (CTF) competition, offer a dynamic environment for diving into Ethereum and Solidity programming. Each level, including Ethernaut Level 14, introduces a distinct smart contract puzzle designed to test your abilities in pinpointing and exploiting flaws. This article provides a comprehensive walkthrough for Ethernaut Level 14, guiding you through the steps needed to solve the challenge and enhance your understanding of Ethereum security.
The GatekeeperTwo
Contract
The GatekeeperTwo
contract has a function called enter
that sets an address as the “entrant” if three conditions (or “gates”) are met. Here is the code:
// SPDX-License-Identifier: MIT
pragma 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;
}
}
Gate 1: Understanding msg.sender
and tx.origin
msg.sender
: This is the address that directly called the current function. It could be an externally owned account (EOA) or another contract.tx.origin
: This is the address of the original external account that started the entire transaction.
To pass this gate, msg.sender
must not be the same as tx.origin
. This means the call to the enter
function must come from another contract, not directly from an EOA. Here’s why:
- If you, as an EOA, call the
enter
function directly,msg.sender
(your address) will be the same astx.origin
(your address). - If another contract calls the
enter
function,msg.sender
will be the address of that contract, whiletx.origin
will still be your address (the original initiator).
Gate 2: The Mystery of Code Size
extcodesize(address)
: This function returns the size of the code at a given address.caller()
: Returns the address of the caller.
For this gate, the code size of the caller must be zero. This is typically true for EOAs since they don’t have associated code, but since gate one requires a contract to call this function, how can a contract have a code size of zero?
How a Contract Can Have Code Size Zero
When a contract is being deployed, it has two stages:
- Creation Bytecode: This is the bytecode needed to create the contract and execute its constructor.
- Runtime Bytecode: This is the actual code that runs the contract’s functions once deployed.
During the contract’s construction (before its constructor finishes executing), the contract’s runtime code isn’t yet stored on the blockchain. Therefore, its code size is zero during this period.
Gate 3: Bitwise XOR and Hashing
keccak256(abi.encodePacked(msg.sender))
: Computes a Keccak-256 hash of the encodedmsg.sender
.bytes8(...)
: Converts the hash tobytes8
, taking the least significant 8 bytes.uint64(...)
: Castsbytes8
to auint64
integer.^
(Bitwise XOR): A binary operation where each bit of the output is the same as the corresponding bit in one operand if the bit in the other operand is 0, and it is different if the corresponding bit in the other operand is 1.
To pass this gate, the XOR of the hash of msg.sender
(converted to uint64
) and _gateKey
must equal the maximum value for a uint64
(which is uint64(0) - 1
or 0xFFFFFFFFFFFFFFFF
).
Bitwise XOR Explained
- XOR Operation: If the bits are the same, the result is
0
. If the bits are different, the result is1
.
For example:
1010
XOR0101
=1111
1111
XOR1111
=0000
Given the XOR condition, _gateKey
must be the bitwise complement of the hash of msg.sender
.
Solution: Exploiting the Contract
To solve the challenge, we need:
- A contract to call the
enter
function (to pass gate one). - The call to be made during the contract’s construction (to pass gate two).
- The correct
_gateKey
(to pass gate three).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Gate2Attack {
constructor(address _address) {
bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));
(bool success,) = _address.call(abi.encodeWithSignature('enter(bytes8)', key));
require(success, "Call failed");
}
}
Why This Contract Can Hack GatekeeperTwo
Now, let’s explain how this contract manages to pass each gate of the GatekeeperTwo
contract.
Gate 1: msg.sender
vs tx.origin
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
- Requirement:
msg.sender
must not be equal totx.origin
. - How It Passes: When the
Gate2Attack
contract calls theenter
function,msg.sender
is the address of theGate2Attack
contract, andtx.origin
is the address of the user who deployedGate2Attack
. These two addresses are different, satisfying the requirement.
Gate 2: Code Size of the Caller
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
- Requirement: The code size of the caller must be zero.
- How It Passes: The call to
enter
is made from the constructor of theGate2Attack
contract. During the execution of a contract’s constructor, its runtime code is not yet stored on the blockchain. Hence, theextcodesize
of the contract is zero at this point, satisfying the requirement.
Gate 3: Bitwise XOR and Hashing
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}
- Requirement: The XOR of
uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
anduint64(_gateKey)
must equaluint64(0) - 1
. - How It Passes:
- The
key
is calculated as follows: keccak256(abi.encodePacked(address(this)))
: Hash the address of theGate2Attack
contract.bytes8(...)
: Take the least significant 8 bytes of the hash.uint64(...)
: Cast these bytes to auint64
integer.^ (uint64(0) - 1)
: XOR withuint64(0) - 1
to get the complement.- This calculation ensures that the XOR condition in the gate is satisfied, allowing the key to pass the check.
Conclusion and Security Takeaways: Ethernaut Level 14
Avoid tx.origin
for Authorization:
- Issue:
tx.origin
can be easily manipulated by intermediate contracts. - Solution: Use
msg.sender
for reliable access control
Be Wary of Code Size Checks:
- Issue: Contracts have zero code size during construction, bypassing
extcodesize
checks. - Solution: Combine code size checks with other robust validation methods.
Understand Bitwise Operations:
- Issue: Bitwise operations like XOR can be reversed to deduce required inputs.
- Solution: Use secure, tested cryptographic methods and avoid easily reversible logic.
Lifecycle Awareness:
- Issue: Contracts behave differently during deployment, posing security risks.
- Solution: Design security mechanisms considering all stages of a contract’s lifecycle.
These insights underscore the importance of using well-established security practices and understanding the intricacies of smart contract behavior in Ethereum.
FAQs
What is the Ethernaut Challenge?
Ethernaut challenges are Web3-themed CTF competitions testing Ethereum and Solidity skills. Learn more.
What is the goal of the GatekeeperTwo contract?
Set an address as the “entrant” if three conditions (gates) are met. Contract code.
What are the three gate conditions?
- Gate One:
msg.sender
≠tx.origin
- Gate Two: Caller’s code size = 0
- Gate Three: XOR of hash of
msg.sender
and gate key = max uint64
Why is msg.sender
different from tx.origin
in Gate One?
The call must come from a contract, not directly from an EOA.
How does a contract have a code size of zero in Gate Two?
During deployment, the contract’s runtime code isn’t yet stored on the blockchain.
What is the role of the XOR operation in Gate Three?
XOR of the hash of msg.sender
and the gate key must equal the maximum uint64 value.
How does the solution exploit GatekeeperTwo?
- The
Gate2Attack
contract callsenter
during its constructor:
- Gate One:
msg.sender
≠tx.origin
- Gate Two: Code size is zero during construction
- Gate Three: Key satisfies XOR condition
Why avoid tx.origin
for authorization?
- It can be manipulated by intermediate contracts. Use
msg.sender
. More info.
What are the security takeaways?
- Avoid
tx.origin
- Combine code size checks with other validations
- Use secure cryptographic methods
- Design for all contract stages
How can I learn more about securing smart contracts?
- Study best practices and participate in Ethernaut challenges.