Ethernaut(Lvl 13):Gatekeeper 1 . Make it past and gain entry

Ethernaut(Lvl 13):Gatekeeper 1 . Make it past and gain entry

ByteMasking and calclulating smart contract gas consumption.

In Ethereum, performing computations incurs costs. These costs are determined by multiplying the amount of gas required by the current gas price. Gas represents a unit of computation, and the gas price adjusts according to the Ethereum network's activity levels. For each transaction initiated, the sender is responsible for paying the resulting ethers.

More intricate transactions, such as creating contracts, require greater resources than simpler ones, like transferring ethers to another account. Additionally, storing data on the blockchain is more expensive than retrieving data, and accessing constant variables is cheaper than accessing values stored in the blockchain's storage.

Byte masking in Solidity involves manipulating individual bits within a byte to achieve specific outcomes or optimizations in smart contract development. It's a technique used for various purposes, including efficient storage, flag management, and bit-level operations, accomplished through bitwise operators like & (AND), | (OR), ^ (XOR), and ~ (NOT), along with bit-shifting operators like << (left shift) and >> (right shift). For instance, to store multiple boolean flags within a single byte variable, developers define flag positions using bit-shifting constants and then use byte masking to set, clear, or check flags. While byte masking can offer efficiency gains, it requires careful management to ensure clarity and correctness in contract logic, as well as thorough testing and auditing to address potential security risks and gas cost considerations associated with bitwise operations.

The Challenge

The challenge requires you to make it past the gatekeeper and register as an entrant in order to pass this level.

// 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;
  }
}

These three function modifiers resembles solving unique puzzles within the contract; otherwise, the contract faces reversion.

Let's embark on the journey through each segment:

Gate 1: Deciphering msg.sender and tx.origin

To unlock this gate, an insight into the essence of msg.sender and tx.origin becomes imperative, unraveling the subtle distinctions between them.

Referencing the Solidity Docs provides clarity:

  • msg.sender (address): The sender of the current call message.

  • tx.origin (address): The originator of the transaction, encapsulating the entire call chain.

When an Externally Owned Account (EOA) initiates a transaction, directly engaging with a smart contract, these variables align. However, if the transaction involves a middle-man contract A, subsequently interacting with another contract B through a direct call (not a delegatecall), these values diverge.

In this scenario:

  • msg.sender captures the EOA address.

  • tx.origin encapsulates the A contract's address.

For Gate 1 to remain un-reverted, the condition necessitates msg.sender to be distinct from tx.origin. This implies invoking enter from a smart contract, steering clear of direct calls from the player's EOA.

Gate 2: Unlocking second gate by understanding gasleft()

According to the Solidity Docs on Global Variables, gasleft() is a function that retrieves the remaining gas available for the transaction, returned as a uint256 value.

It's crucial to understand that each Solidity instruction is essentially a high-level representation of a sequence of low-level EVM Opcodes. Upon executing the GAS opcode (refer to the EVM codes documentation), the resulting value signifies the gas remaining after the GAS opcode execution, which currently consumes 2 gas.

Navigating through Gate 2's complexities, meeting the gateTwo criteria mandates calling challenge.enter{gas: GasAmount}(gateKey) with the right gas amount to ensure gasleft()%8191 returns 0 (where the gas leftover is a multiple of 8191).

Attempting to deduce this number proves formidable as it requires translating all Solidity code into EVM opcodes, calculating gas consumption for each, and consuming copious amounts of time. Furthermore, it's essential to recognize that gas costs may fluctuate based on the Solidity compiler version used to compile the code into bytecode and the compilation flags employed—a convoluted process indeed.

So, what's the workaround? Opting for the path of least resistance, we can embrace a brute force approach. Leveraging our local test environment (or a forked one), we can approximate that the gas consumed by the enter transaction must exceed 8191 plus the gas expended to execute these opcodes. By iteratively testing within a range, we can brute force until achieving success. Below is a code example:

for (uint256 i = 0; i <= 8191; i++) {
    try victim.enter{gas: 800000 + i}(gateKey) {
        console.log("passed with gas ->", 800000 + i);
        break;
    } catch {}
}

Code sample gotten from here

As seen from here , we are starting from a base gas and trying to figure out the right amount of gas needed to execute the operation.

Gate 3 : ByteCasting

When casting from a smaller type to a larger one, no issues arise. All high-order bits are zero-filled, preserving the value. However, complications arise when downcasting from a larger type to a smaller one. This process can result in data loss as high-order bits are truncated. For instance, uint16(0x0101) equates to 257 in decimal. However, downcasting it to uint8 yields 1 in decimal, showcasing potential data loss.

At this juncture, the task entails identifying a _gateKey value that fulfills all these criteria simultaneously:

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");

Overcoming this challenge we have to do "masking" using the AND operator. This operator acts like a filter, allowing specific bits to pass through while blocking others based on the mask's pattern.

Let's delve into the first requirement:

uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)). Here, we aim to ensure that the last 2 bytes precisely match the last 4 bytes of the input. To achieve this, we create a mask that zeroes out the higher-order 2 bytes while preserving the lower-order byte's value. Picture it like peeling off layers to reveal the essential core, where 0x11111111 transforms into 0x00001111 under this mask's influence.

Now, onto the second requirement, which demands a distinction between the last 8 bytes and last 4 bytes of the input. But here's the twist: we must uphold the first requirement simultaneously. To navigate this complexity, our mask undergoes a transformation, allowing all the first 4 bytes to pass unchanged while maintaining our earlier constraint. Thus, our revamped mask becomes 0xFFFFFFFF0000FFFF.

With these masks in hand, we're equipped to tackle the third gate. By applying this specialized mask to our tx.origin, casted to a bytes8 format (as an address spans 20 bytes), we unveil the key to unlocking this gate: bytes8(uint64(uint160(address(player)))) & 0xFFFFFFFF0000FFFF.

The Solution

  • We first of all create an attack contract.

  • We use our script to calculate the gas required.

  • We then pass in the masked passKey.

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
      interface GatekeeperOneInterface {
          function enter(bytes8 _gateKey) external returns (bool);
      }
    
      contract Attacker {
          GatekeeperOneInterface public gatekeeperOne;
    
          constructor(address _gatekeeperOneAddress) {
              gatekeeperOne = GatekeeperOneInterface(_gatekeeperOneAddress);
          }
    
          function attack() public {
              bytes8 gateKey = bytes8(uint64(uint16(uint160(address(this)))));
              gatekeeperOne.enter{gas: `calculatedGas`}(gateKey);
          }
      }
    

    Developer TidBits

  • Avoid specifying gas consumption in your smart contracts, as it can vary depending on compiler settings, leading to inconsistent outcomes.

  • Exercise caution when converting data types to different sizes to prevent potential data corruption.

  • Optimize gas usage by minimizing unnecessary value storage. Utilizing memory operations like MSTORE and MLOAD is generally more gas-efficient than storing values directly to the blockchain with SSTORE and SLOAD.

  • Utilize appropriate modifiers to reduce gas costs associated with function calls. For instance, external pure or external view function calls are gas-free, helping to conserve gas.

  • Optimize gas consumption by employing value masking techniques, which involve fewer operations compared to typecasting, thus minimizing gas expenditure.