TL;DR
A party user could race a cantonal supervisor’s approval request and silently restore an already locked candidate list to its previous unlocked state. The bug was a classic stale-write race, but the object being overwritten was headed for an official ballot.
Lets race...
The lock was supposed to mean the candidate list was finished. Approved. Immutable. Good for printing.
Instead, a party user could send a perfectly ordinary PUT request at the right moment and silently undo the cantonal supervisor’s lock.
No authentication bypass. No forged role. No weird payload. Just two legitimate requests, a stale database snapshot, and missing concurrency control.
The workflow
The issue was in Abraxas VOTING Wahlvorschlag, the software used for managing election proposals and candidate lists in the canton of St. Gallen.
There are two roles that matter here.
A party user, called
A cantonal supervisor, called
That lock matters because the approved data is later exported for signing and printing. These are not draft records sitting in some forgotten admin panel. They are the names and details intended to appear on official ballots.
The backend exposes the following update route:
Both roles can reach it, although their permissions and allowed state transitions differ.
The problem was not the route itself. The problem was how the handler processed an update.
Read, check, write
The list update followed a familiar pattern:
At first glance, this looks reasonable.
The service loads the existing row, checks whether the caller may modify it, preserves fields the caller is not allowed to control, increments the version, and writes the result.
The interesting part is where those operations happen. The initial read runs in autocommit mode. The authorization check uses the object returned by that read. Only the final write runs inside the transaction scope.
There is no row lock covering the gap. The entity has no EF Core concurrency token. The update does not include a condition such as
The race
The attack requires a legitimate party user who already has access to the target list. The cantonal supervisor is not involved in the attack. They are simply performing the normal lock operation.
The sequence is straightforward.
The party user fetches the candidate list and keeps the returned JSON unchanged. At roughly the same time, the supervisor prepares the normal update that sets:
The party user then resends the old list body while the supervisor’s lock request is being processed.
The important ordering looks like this:
The party user’s authorization check already passed against the old in-memory object where
When the write happens, the handler explicitly copies the stale value back:
So even if the supervisor’s lock commits first, the party user can overwrite it moments later.
The lock is not rejected. It is not preserved. It is simply replaced with the value from the stale snapshot.
Both requests returned 200
I confirmed the race against the live test environment using a small Python driver and two concurrent requests.
The driver fetched the list as the party user, created a second body with
The core of the test was basically this:
I stopped after the first confirmed win. It happened on attempt four.
Both PUT requests returned HTTP 200.^Both responses reported
The verification GET, performed using the supervisor account, showed:
The supervisor’s
The damage was not limited to the lock flag. Because the service wrote the entire stale entity back, other lifecycle fields were also restored to their earlier values. That included fields such as validation state, submission date, indentation data, and the responsible tenant.
I immediately sent the supervisor lock again and confirmed that the list returned to
I did not modify any candidates after winning the race.
Once the list was unlocked, though, the next step would not have required another exploit. The party user could use the normal candidate-management endpoints to add, remove, or edit candidates. The broken lock was the only barrier.
The version field did nothing
The application already had a field called
The controller increments it:
But it was only being used as a counter. EF Core was not told that the field represented a concurrency boundary. There was no
Conceptually, the service was doing this:
What it needed was something closer to this:
With the version condition present, the stale update affects zero rows because the supervisor has already changed the record to version 11.
EF Core then raises
The funny part is that the project already had middleware for exactly that exception. It mapped the exception to HTTP 409. The error handling was ready. The entity configuration required to trigger it was missing. Dead code waiting for a one-line fix.
It was not limited to one PUT handler
The same read-check-write pattern appeared in several related operations.
The full list PUT handler had it.
The PATCH path had it.
The DELETE path had it.
The candidate batch endpoint used the same general structure, with an autocommit read followed by a later transactional write. These were not separate vulnerabilities. They were different entry points into the same persistence flaw. The controller treated an in-memory entity as authoritative even after another request could have changed the database row underneath it. Once the entity is protected with optimistic concurrency, every update path benefits from the same check.
The fix
The clean fix is to make the existing version field an EF Core concurrency token:
After that, EF Core includes the old version in the update condition. When the supervisor’s lock wins first, the party user’s stale update fails with a concurrency conflict instead of restoring the unlocked state. The application already maps that conflict to HTTP 409, so most of the required behavior was already present. I would also add a second layer of protection around the lock itself.
The controller currently preserves the lock using the old object:
A safer version would read the current lock state at write time, or perform a conditional update that refuses to modify a locked list.
Optimistic concurrency is the main fix. Rechecking the lifecycle state is useful defense in depth, especially because future refactoring has a habit of quietly removing protections people assumed would always stay there. A regression test should run the party update and supervisor lock concurrently, then assert that the final state remains locked.
Not “one of the requests succeeded."
Not “the version increased.”
The property that matters is simple:
Low CVSS, awkward consequence
The report was accepted with a Low severity rating, CVSS 3.1, and a reward of CHF 500.
I understand how the number happens. The attacker needs an authenticated party account. They need access to the correct list. They need to race a legitimate supervisor action. The timing adds complexity, and there is no confidentiality impact. On paper, that produces a small score.
The actual workflow consequence is less small.
A cantonal supervisor can approve a candidate list for printing, receive HTTP 200, and still have that approval silently overwritten by a party user. Once the stale write restores the unlocked state, the existing candidate endpoints become available again. The test race succeeded once in four attempts before I stopped. That is not a theoretical nanosecond window that only works under a debugger. It is repeatable application behavior with ordinary HTTP requests.
The bug itself was not exotic. It was a standard lost-update problem.
What made it interesting was the security boundary built on top of the affected field. The application treated
A party user could race a cantonal supervisor’s approval request and silently restore an already locked candidate list to its previous unlocked state. The bug was a classic stale-write race, but the object being overwritten was headed for an official ballot.
Lets race...
The lock was supposed to mean the candidate list was finished. Approved. Immutable. Good for printing.
Instead, a party user could send a perfectly ordinary PUT request at the right moment and silently undo the cantonal supervisor’s lock.
No authentication bypass. No forged role. No weird payload. Just two legitimate requests, a stale database snapshot, and missing concurrency control.
The workflow
The issue was in Abraxas VOTING Wahlvorschlag, the software used for managing election proposals and candidate lists in the canton of St. Gallen.
There are two roles that matter here.
A party user, called
Benutzer, manages the candidate list belonging to their party.A cantonal supervisor, called
Wahlverwalter, reviews that list and eventually marks it as locked=true. This is the “Gut zum Druck” step. Once that approval lands, the candidate list is supposed to stop changing.That lock matters because the approved data is later exported for signing and printing. These are not draft records sitting in some forgotten admin panel. They are the names and details intended to appear on official ballots.
The backend exposes the following update route:
Code:
PUT /elections/{electionId}/lists/{listId}
Both roles can reach it, although their permissions and allowed state transitions differ.
The problem was not the route itself. The problem was how the handler processed an update.
Read, check, write
The list update followed a familiar pattern:
Code:
existing = repository.Get(listId)
authService.AssertListWriteAccess(existing)
list.Locked = existing.Locked
list.Version = existing.Version + 1
repository.Update(list)
At first glance, this looks reasonable.
The service loads the existing row, checks whether the caller may modify it, preserves fields the caller is not allowed to control, increments the version, and writes the result.
The interesting part is where those operations happen. The initial read runs in autocommit mode. The authorization check uses the object returned by that read. Only the final write runs inside the transaction scope.
There is no row lock covering the gap. The entity has no EF Core concurrency token. The update does not include a condition such as
WHERE Locked = false. That means the code makes its security decision using a snapshot that may already be obsolete by the time the update reaches the database. For a normal profile-editing feature, this would be an annoying lost update. For a candidate list approval workflow, it is a way to undo the approval.The race
The attack requires a legitimate party user who already has access to the target list. The cantonal supervisor is not involved in the attack. They are simply performing the normal lock operation.
The sequence is straightforward.
The party user fetches the candidate list and keeps the returned JSON unchanged. At roughly the same time, the supervisor prepares the normal update that sets:
Code:
"locked": true
The party user then resends the old list body while the supervisor’s lock request is being processed.
The important ordering looks like this:
Code:
Party user reads list:
locked = false
version = 10
Supervisor writes:
locked = true
version = 11
Party user writes stale snapshot:
locked = false
version = 11
The party user’s authorization check already passed against the old in-memory object where
locked was still false.When the write happens, the handler explicitly copies the stale value back:
Code:
list.Locked = existing.Locked;
So even if the supervisor’s lock commits first, the party user can overwrite it moments later.
The lock is not rejected. It is not preserved. It is simply replaced with the value from the stale snapshot.
Both requests returned 200
I confirmed the race against the live test environment using a small Python driver and two concurrent requests.
The driver fetched the list as the party user, created a second body with
locked=true for the supervisor, then submitted both updates using a two-worker thread pool.The core of the test was basically this:
Python:
def party_write():
return put_list(PARTY_TOKEN, original_body)
def supervisor_lock():
locked_body = dict(original_body)
locked_body["locked"] = True
return put_list(SUPERVISOR_TOKEN, locked_body)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
party_result = pool.submit(party_write)
lock_result = pool.submit(supervisor_lock)
party_result.result()
lock_result.result()
I stopped after the first confirmed win. It happened on attempt four.
Code:
WIN on attempt 4: locked=false, version=11
Both PUT requests returned HTTP 200.^Both responses reported
version=11.The verification GET, performed using the supervisor account, showed:
JSON:
{
"version": 11,
"locked": false,
"validated": false
}
The supervisor’s
locked=true update had committed, but the stale party update replaced it.The damage was not limited to the lock flag. Because the service wrote the entire stale entity back, other lifecycle fields were also restored to their earlier values. That included fields such as validation state, submission date, indentation data, and the responsible tenant.
I immediately sent the supervisor lock again and confirmed that the list returned to
locked=true with version 12.I did not modify any candidates after winning the race.
Once the list was unlocked, though, the next step would not have required another exploit. The party user could use the normal candidate-management endpoints to add, remove, or edit candidates. The broken lock was the only barrier.
The version field did nothing
The application already had a field called
Version, which made this bug slightly more frustrating.The controller increments it:
C#:
list.Version = existing.Version + 1;
But it was only being used as a counter. EF Core was not told that the field represented a concurrency boundary. There was no
IsConcurrencyToken(), ConcurrencyCheck, or row-version configuration on the entity. So the generated update did not verify that the database still contained the version originally read by the controller.Conceptually, the service was doing this:
SQL:
UPDATE Lists
SET Locked = false,
Version = 11
WHERE Id = @listId;
What it needed was something closer to this:
SQL:
UPDATE Lists
SET Locked = false,
Version = 11
WHERE Id = @listId
AND Version = 10;
With the version condition present, the stale update affects zero rows because the supervisor has already changed the record to version 11.
EF Core then raises
DbUpdateConcurrencyException.The funny part is that the project already had middleware for exactly that exception. It mapped the exception to HTTP 409. The error handling was ready. The entity configuration required to trigger it was missing. Dead code waiting for a one-line fix.
It was not limited to one PUT handler
The same read-check-write pattern appeared in several related operations.
The full list PUT handler had it.
The PATCH path had it.
The DELETE path had it.
The candidate batch endpoint used the same general structure, with an autocommit read followed by a later transactional write. These were not separate vulnerabilities. They were different entry points into the same persistence flaw. The controller treated an in-memory entity as authoritative even after another request could have changed the database row underneath it. Once the entity is protected with optimistic concurrency, every update path benefits from the same check.
The fix
The clean fix is to make the existing version field an EF Core concurrency token:
C#:
builder.Property(l => l.Version).IsConcurrencyToken();
After that, EF Core includes the old version in the update condition. When the supervisor’s lock wins first, the party user’s stale update fails with a concurrency conflict instead of restoring the unlocked state. The application already maps that conflict to HTTP 409, so most of the required behavior was already present. I would also add a second layer of protection around the lock itself.
The controller currently preserves the lock using the old object:
C#:
list.Locked = existing.Locked;
A safer version would read the current lock state at write time, or perform a conditional update that refuses to modify a locked list.
Optimistic concurrency is the main fix. Rechecking the lifecycle state is useful defense in depth, especially because future refactoring has a habit of quietly removing protections people assumed would always stay there. A regression test should run the party update and supervisor lock concurrently, then assert that the final state remains locked.
Not “one of the requests succeeded."
Not “the version increased.”
The property that matters is simple:
C#:
Assert.True(final.Locked);
Low CVSS, awkward consequence
The report was accepted with a Low severity rating, CVSS 3.1, and a reward of CHF 500.
I understand how the number happens. The attacker needs an authenticated party account. They need access to the correct list. They need to race a legitimate supervisor action. The timing adds complexity, and there is no confidentiality impact. On paper, that produces a small score.
The actual workflow consequence is less small.
A cantonal supervisor can approve a candidate list for printing, receive HTTP 200, and still have that approval silently overwritten by a party user. Once the stale write restores the unlocked state, the existing candidate endpoints become available again. The test race succeeded once in four attempts before I stopped. That is not a theoretical nanosecond window that only works under a debugger. It is repeatable application behavior with ordinary HTTP requests.
The bug itself was not exotic. It was a standard lost-update problem.
What made it interesting was the security boundary built on top of the affected field. The application treated
Locked as an access-control decision, but the persistence layer treated it like any other value in a stale object. That is the whole issue.