Ethernaut 13: GatekeeperOne
Objective
Get past the three gatekeepers and register as the entrant.
Code
1contract GatekeeperOne {
2 address public entrant;
3
4 modifier gateOne() {
5 require(msg.sender != tx.origin);
6 _;
7 }
8
9 modifier gateTwo() {
10 require(gasleft() % 8191 == 0);
11 _;
12 }
13
14 modifier gateThree(bytes8 _gateKey) {
15 require(
16 uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)),
17 "GatekeeperOne: invalid gateThree part one"
18 );
19 require(
20 uint32(uint64(_gateKey)) != uint64(_gateKey),
21 "GatekeeperOne: invalid gateThree part two"
22 );
23 require(
24 uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)),
25 "GatekeeperOne: invalid gateThree part three"
26 );
27 _;
28 }
29
30 function enter(bytes8 _gateKey)
31 public
32 gateOne
33 gateTwo
34 gateThree(_gateKey)
35 returns (bool) {
36 entrant = tx.origin;
37 return true;
38 }
39}
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
require(gasleft() % 8191 == 0);
the amount of gas remaining when reaching that point should be a multiple of 8191. Sending 8191 gas would fail for two reasons:
- some gas is spent before reaching the point where the gasleft opcode is executed, so I have to account for that.
- After all the gates are passed, the entrant variable is written to storage, and that's surely more expensive than 8191 gas, so I'd have to send a multiple to avoid an out of gas error.
The latter is a write to a cold, uninitialized storage slot with a non-zero value. I should use a multiple:
22100/8191
2.69808326211695763643
so, at least...
8191*3
24573
Regarding the former: the gasleft() call is not the first action in the call. And even if it was in source code, the internal transaction would still have consumed some gas decoding enough of the calldata to know which function implementation to jump to.
So I have to figure out how much gas is spent up to that point. Thankfully foundry can help with that:
[N]> forge test --mc GatekeeperOne --debug testSolution
and jumped to the point in the code where the GAS opcode is called. sourcemaps are broken somehow, so I had to log the address of the target contract and scroll until the gas opcode. The gas used until that point (and including the GAS opcode itself) is 416. So the gas to send is:
24573 + 416
24989
Note
using gas like this is very fragile because the gas costs of opcodes change between EVM hardforks. When updating the solutions repo to the last hard fork (shanghai), the gas costs of described in the last paragraph changed, so I had to update the solution to send 148 more gas.
gate three part one
From the contract:
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
A reminder that the EVM is big-endian. And this means (from wikipedia):
A big-endian system stores the most significant byte of a word at the smallest memory address and the least significant byte at the largest
Also, from explicit type conversions in the solidity docs :
If an integer is explicitly converted to a smaller type, higher-order bits are cut off
Interactively, in chisel:
[I] capu ~/s/ethernaut-solutions (master)> chisel
➜ bytes8 key = 0x0011223344556677;
➜ uint64(key)
├ Hex: 0x11223344556677
➜ uint16(uint64(key))
├ Hex: 0x6677
➜ uint32(uint64(key))
├ Hex: 0x44556677
so for the first check, I want the memory contents of the last two bytes and the last four bytes to evaluate to the same number. So bytes 6,7 can be whatever, but 4,5 must be zero. A zero calldata will do:
target.enter{gas: 24989}(0x0000000000000000);
gate three part two
uint32(uint64(_gateKey)) != uint64(_gateKey)
in chisel:
➜ bytes8 key = 0x0011223344556677;
➜ uint64(key)
├ Hex: 0x11223344556677
➜ uint32(uint64(key))
├ Hex: 0x44556677
all of the bytes in the key, interpreted as an uint, should have a value different than bytes 4,5,6,7. So any bit of the remaining bytes should be non-zero.
So far, the interesection of all conditions is:
- bytes 4,5 must be zero
- bytes 0,1,2,3 must have at least one bit be 1
let's try:
target.enter{gas: 24989}(0x0100000000000000);
and run it:
[N] capu ~/s/ethernaut-solutions (master) [1]> forge test --mc GatekeeperOne -vv
...
[FAIL. Reason: GatekeeperOne: invalid gateThree part three] testSolution() (gas: 1067270)
...
Yey! Progress! Let's get onto part three
gate three part three
require(
uint32(uint64(_gateKey)) == uint160(tx.origin),
"GatekeeperOne: invalid gateThree part three"
);
... this means bytes 6,7 of the key should be the same as bytes 30,31 of tx.origin
Wrapping up, conditions on _gateKey:
- bytes 4,5 must be zero
- bytes 0,1,2,3 must be non-zero
- bytes 6,7 should be the same as tx.origin's bytes 30,31
If there also were a condition on bytes 6,7 to be zero (or something specific), then I'd have to compute a vanity address. But in this case, it's enough to set the key to the same value whatever address we're using has:
// I know the address being pranked is // 0x0000000000000000000000000000000000000539 (new Caller()).enter(target, 0x0100000000000539);
[N] capu ~/s/ethernaut-solutions (master) [1]> forge test --mc GatekeeperOne
Running 1 test for test/13-GatekeeperOne.t.sol:GatekeeperOneSolution
[PASS] testSolution() (gas: 2064881)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 837.73µs
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
😎