Defending a civil-society voting application against nation-state-grade attacks
Designing, operating and hardening a one-off civic voting system under live adversarial conditions — with a five-figure budget and a team of one.
Operating a civic voting application against sustained cyberattacks — in a jurisdiction under intense political pressure.
In autumn 2019 I built and ran the software for a small civil-society poll. No ID-data collection, no chain-of-custody drama — just “let registered people vote from their phones”. Behind the unassuming UI, the application had to withstand the full attention of actors who did not want the poll to happen.
What one engineer, a five-figure budget, and a clear threat model can do.
The brief
Earlier civic votes run by the same group collected national identification data for tamper-resistance. The 2019 vote was to be less intrusive — phone numbers only, no ID cards — while producing a result any outside party could verify independently.
The core business logic, dropped to pseudocode, is three lines:
if phone_already_voted(phone):
reject
else:
record(phone, ballot)
Anyone can write that in an afternoon. The interesting question is what you build around it once you assume every endpoint will be attacked.
Design choices under assumed-hostile conditions
Rate-limiting on phone numbers. Every verification attempt was throttled and logged. Table stakes for OTP, yet the most common class of vulnerability in civic code.
Layered encryption. Three layers, three separately held keys. Losing any one key loses nothing useful; losing all three loses everything permanently — deliberate, over a single-key design that is easier to seize under duress.
Verification-message routing. OTPs went out over SMS, with sender identifiers rotated across foreign telecom operators to avoid trivial interception. When delivery failed for ~17% of local numbers (the local market was too small for foreign telecoms to prioritise), a locally operated 4G modem took over as fallback.
Transparent, cryptographically chained tallying. Every voter received a query code to check, after the fact, whether their ballot appeared in the final dataset. Each vote row was cryptographically linked to the previous, forming a chain. Two-hourly turnouts and cumulative digests were published live. After the close, any third party could recompute the chain and verify the published digests — independent verification without disclosing any individual vote.
The attacks, and the defence
Phase 1 — Reconnaissance. Vulnerability scanners ran first. Because the application was not built on top of an off-the-shelf CMS, nothing canned fired. The Chinese-language payloads in the request bodies were the first fingerprint of origin.
Phase 2 — An exploitable SMS endpoint. A late design change accidentally deactivated a per-number send cap. An API endpoint could be coaxed into firing verification messages at the same number without limit. I found and patched it in 1.5 hours. In those 1.5 hours, malicious requests burned through roughly USD 170 in SMS fees — the entire material loss from the whole cyberattack campaign.
Phase 3 — Laissez-faire deception. After patching, I kept the endpoint returning HTTP 200 to malicious requests. The attackers now had a broken pattern they believed was working. They ramped from ~1 to ~260 requests per second. Every hour they spent on a dead pattern was an hour they weren’t developing the next one.
Phase 4 — Rate-limit saturation. Cloudflare’s rate-limiting takes seconds to engage for unseen IPs. Under high concurrency the app occasionally admitted small bursts past the cap before the DB write. No successful votes went through — verification came first — but the designed rate limit was technically overshot. Fixing the concurrency issue in-flight would have meant redeploying the vote.
Phase 5 — Non-local JavaScript challenge. Rather than chase IP blocks (losing game against a botnet), I required all non-local-IP requests to pass a JavaScript-execution challenge. Genuine browsers cleared it. Automated scripts did not. The attacks stopped.
Two observations from the logs
The attackers were on a 996 shift. Sustained IP rotation occurred only between 10:00 and 22:00 mainland-China time — the exploitative “996” schedule (9-to-9, six days a week) notorious in parts of the Chinese tech industry. I was in British Summer Time, reviewing logs and planning responses after my adversaries had clocked off. Time-zone separation is a real defensive asset, rarely discussed as one.
Usability and security pull in opposite directions. Image-picking CAPTCHA was avoided because the poll needed to be usable by older voters — a frontline volunteer’s direct feedback. Repeated CAPTCHA failures caused more abandonments than attacks did. Proving humanity at low friction remains unsolved; picking the right failure mode is what matters.
Observations
The suppression was discreet rather than loud. No mass DDoS — one would have drawn international press. The chosen campaign was time-consuming for the adversary, lower-yield, and invisible.
What I would change next time:
- Evidence the laissez-faire strategy. I believe the 200-OK deception bought real time, but the logs alone cannot prove it. A parallel honeypot or an A/B on response codes would have settled it.
- Time-box volunteering more honestly. Planned two weeks alongside a law dissertation; ran four.
Civic technology runs on extreme asymmetry: small teams, small budgets, adversaries with institutional support to lose. The defensive moves above are cheap and well known to professional security teams — and almost never reach the volunteer-engineer world.