DEV Community

Erhan Tezcan
Erhan Tezcan

Posted on

QuillCTF: 7. True XOR

Objective of CTF:

  • Make a successfull call to the ctf function.
  • The given target parameter should belong to a contract deployed by you, and should use IBoolGiver interface.

Target contract:

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

interface IBoolGiver {
  function giveBool() external view returns (bool);
}

contract TrueXOR {
  function ctf(address target) external view returns (bool) {
    bool p = IBoolGiver(target).giveBool();
    bool q = IBoolGiver(target).giveBool();
    require((p && q) != (p || q), "bad bools");
    require(msg.sender == tx.origin, "bad sender");
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Solution

The logical operation that is happening in ctf basically does an XOR. If the XOR of p and q is 0, the transaction will revert. Furthermore, we require the sender to be an EOA.

The question boils down to this: how can we return different values from a view function? We need to somehow change the state without using view, but how can we do that? Well, we don't have to be the ones to change the state; EVM does it literally every instruction by changing the gasleft result! So, if we can find the remaining gas in between the two calls to giveBool, we can use that to return a different result.

Proof of Concept

Here is the attacker contract:

contract TrueXORAttacker is IBoolGiver {
  uint t = 28543000;

  function giveBool() external view override returns (bool) {
    uint g = gasleft();
    return g < t;
  }

  function changeThreshold(uint _t) external {
    t = _t;
  }
}
Enter fullscreen mode Exit fullscreen mode

We added an extra changeThreshold function to avoid deploying a new contract in case we miss the sweet spot for the gasleft. In my case, 28543000 was the correct amount, such that within the first call there is more gas, and within the second call there is less gas.

The Hardhat test code to demonstrate this attack is given below. Contract types are generated via TypeChain.

describe('Custom: True XOR', () => {
  let contract: TrueXOR;
  let attackerContract: TrueXORAttacker;
  let owner: SignerWithAddress;
  let attacker: SignerWithAddress;

  before(async () => {
    [owner, attacker] = await ethers.getSigners();
    contract = await ethers.getContractFactory('TrueXOR', owner).then(f => f.deploy());
    await contract.deployed();
  });

  it('should call `ctf` successfully', async () => {
    // deploy the attacker contract
    attackerContract = await ethers.getContractFactory('TrueXORAttacker', attacker).then(f => f.deploy());
    await attackerContract.deployed();

    expect(await contract.connect(attacker).ctf(attackerContract.address)).to.be.true;
  });
});
Enter fullscreen mode Exit fullscreen mode

Alternative Solution

If you look into detials of loading a storage variable, you will see that the first time a storage variable is loaded, it will cost minimum 2100 gas. Later loads however will cost a lot less, around a minimum 100. So, the gas usage of a storage variable load can tell us whether a function has been called before or not.

Here is an example contract for this scenario:

contract TrueXORAttacker2 is IBoolGiver {
  uint256 slot0 = 12345;

  function giveBool() external view override returns (bool) {
    uint gas = gasleft();
    uint tmp = slot0;
    tmp; // silence warning
    return (gas - gasleft()) >= 2000;
  }
}
Enter fullscreen mode Exit fullscreen mode

This works fine too!

Oldest comments (0)