Evoting is an… e-voting service :) that appeared in FAUST CTF 2025. We got the first blood on this service.
The service allows users to create polls and vote to them. The system does not have a proper user registration and login mechanism, the management of the polls only relies of the knowledge of some secrets. Polls have a name and a public description, an expiration date after which no more votes can be submitted to it and are protected with a PIN code choosen at poll creation that must be used. Polls can optionally be anonymized, i.e. the names of the voters get hidden.
After creation, a poll gets assigned an id and gets listed publicly in the homepage. Knowing the id or clicking on the poll name you can access it and insert votes.
As you can see from the screenshot, you can see the number of Yes and No votes, as well as the names of the people who voted if the poll is not anonymized, but you can’t see the details of each vote. As the screenshot suggests, during the CTF the flag was encoded in binary and all its bits were inserted as votes, with the name of the user who voted being something like FLAG[i]. As an additional information, the flag_ids contained the id of the poll with the flag.
Our objective now is clear: we want to reveal votes. To do so, the intended way is knowing the PIN. In fact, inserting the correct pin on the “Reveal” button in the frontend unlocks the details of each vote.
Now that we have an overview, let’s move to the source. As we can see from the docker-compose, the service exposes 3 ports: frontend on port 1144, collector on port 1122 and aggregator on port 1133. Most of the relevant code lies in vote-crypt/src/lib.rs. Other relevant source is in frontend/static/vote.js (but we decided to completely ignore this one, it is mostly a 1-1 implementation of the crypto already present in the Rust lib). There is also a database where all the data of the service are stored, but also that one is not relevant for the exploitation.
Of course I can’t read fluently neither Rust nor JS, so my experience with the service was really close to:
building the service locally to play with the frontend
hoping that my teammates get a working client to communicate with it in a small amount of time
relying on ChatGPT to understand the flow of the service
Solution
By opening the source (or by reading the ChatGPT comment) you can see that Elliptic Curve Cryptography is involved here. Let’s go back to the frontend and let’s do the same process as before but with the developer tools open.
When creating a poll we can see a request to the curve endpoint
The return value of this endpoint, with the service untouched, looks like this
That order is at least… sus? Let’s try to put this into Sagemath:
1 2 3 4 5 6
sage: E Elliptic Curve defined by y^2 = x^3 + 2*x + 3 over Finite Field of size 340282366920938463463374607431768211507 sage: E(g) (284981617970323841078688057335394279152 : 884541836722392049381507808995822293 : 1) sage: E(g).order() 284150833938
Ok, the order is very small, so we can do dlogs on this curve
1 2 3 4 5 6 7 8 9
sage: x = randint(1,p) sage: x 316198710582612282878457406407046441978 sage: g = E(g) sage: gg = x*g sage: gg (258416197371529663793486150964669523585 : 200397001932509475292852852166320628024 : 1) sage: gg.log(g) 116038119280
Perfect! Now we know that we can do dlogs… But we still don’t know how the service works and how to use this. Prompting time!
Hey ChatGPT, suppose that I can do dlogs, how can I break the service?
… long text of GPT yapping about something …
TLDR: votes are encoded as jsons, like the following one
The content of the vote is a pair of points. The service uses a secret (chosen at poll creation from the client), samples a point (let’s call it , the first point in enc) and then, if the vote is “Yes”, computes , while if it’s “No”, computes . Knowing how to make dlogs we can recover potential values for (which is the same for all the votes in a poll) and decrypt all the votes together. Full exploit below.
Patch
We patched by simply taking a random point on the Elliptic Curve with a decently-high order (for the scope of the CTF this was more than enough, as we lost 0 flags overall). More precisely, we patched lines 119 and 120 of lib.rs with
1 2
let x = BigNum::from_dec_str("148159849182608731887086692632522981862").unwrap(); let y = BigNum::from_dec_str("194873534234446969905887067451950113087").unwrap();
Comments
We were very surprised to get the first blood on this service, as we had some issues and started working on it very late. It is nice sometimes to have some crypto in A/D, it’s a shame that there were no (intended) harder vulnerabilities in the service.
Full exploit
Note: this exploit is extracted from our A/D template, it may miss some imports or functions, but the core is here :)
votes_points = [parse_encrypted_vote(v["enc"]) for v in raw_votes] rets = [] for s in recover_secret_from_votes(votes_points): classifier = VoteClassifier(s)
classified = [] yes = no = 0 for v, pts in zip(raw_votes, votes_points): res = classifier.classify(*pts) if res == "Yes": yes += 1 elif res == "No": no += 1 classified.append( {"id": v["id"], "name": v["name"], "vote": res, "timestamp": v["timestamp"]} )
rets.append( { "secret": hex(s), "yes": yes, "no": no, "unknown": len(classified) - yes - no, "votes": classified, }) return rets
for id in attack_data: s = utils.get_requests_session() api = Api(s, target) try: results = classify_votes(api, id) for result in results: try: s = [-1] * len(result["votes"]) for v in result["votes"]: s[int(v["name"].split("[")[1].split("]")[0])] = ( 1if v["vote"] == "Yes"else0 )
while len(s) % 8 != 0: s = [0] + s s = "".join(map(str, s)) chars = [s[i : i + 8] for i in range(0, len(s), 8)] flag = "".join([chr(int(x, 2)) for x in chars])[::-1] if"FAUST"in flag and flag notin flags: flags += flag + "&" except Exception as e: print(str(e)) pass except Exception as e: print(str(e)) pass