Feb 20, 2026·6 min read·6 visits
Race condition in Outray's API allows bypassing plan limits. Fixed in v0.1.5 via database locking.
A classic Time-of-Check Time-of-Use (TOCTOU) race condition in Outray, an open-source ngrok alternative, allows users to bypass subscription limits. By spamming concurrent requests, attackers can register more subdomains and tunnels than their plan allows, effectively turning a free tier account into an enterprise-grade resource hog.
Outray positions itself as the open-source hero to save us from ngrok's pricing tiers. It's a noble goal: exposing your localhost to the internet without selling a kidney. To make this sustainable, the developers introduced subscription plans. You know the drill: Free users get 1 subdomain, Pro users get 10, and Enterprise users get to talk to sales.
But here's the thing about enforcing limits in code: it requires a fundamental understanding of time. In the physical world, if there is one cookie in the jar and I grab it, you can't grab it too. In the digital world of asynchronous web servers, if we both grab for the cookie at the exact same microsecond, the server might just panic and clone the cookie. That is exactly what happened here.
CVE-2026-22819 isn't a complex buffer overflow or a wizard-level crypto break. It's a failure to understand that HTTP requests don't stand in a polite single-file line. They arrive in swarms. And when Outray checked if you were allowed to create a new tunnel, it forgot to lock the door behind it while it was checking.
The vulnerability is a textbook Time-of-Check to Time-of-Use (TOCTOU) race condition. It lives in the logic gap between checking a user's quota and actually consuming it.
Here is how the server logic should work:
Here is how the vulnerable code actually worked:
SELECT count(*) FROM subdomains...).INSERT INTO subdomains...).Do you see the gap? Between step 1 and step 3, time passes. It might only be 5 milliseconds, but for a computer, that's an eternity. If an attacker sends 50 concurrent requests, all 50 of them might hit Step 1 simultaneously. They all query the database, and the database honestly replies: "This user has 0 subdomains." The code says, "Great! 0 is less than 1," and proceeds to Step 3. By the time the dust settles, the user has 50 subdomains, and the database is wondering what just happened.
Let's look at the crime scene in apps/web/src/routes/api/$orgSlug/subdomains/index.ts. The original code was naive. It trusted the state of the database to remain static during execution.
The Vulnerable Code (Conceptually):
// 1. The Check
const existingSubdomains = await db
.select()
.from(subdomains)
.where(eq(subdomains.organizationId, orgId));
// 2. The Logic
if (existingSubdomains.length >= limit) {
return error("Limit reached");
}
// 3. The Use
await db.insert(subdomains).values(newSubdomain);This is a "check-then-act" pattern without atomicity. The fix, introduced in commit 73e8a09575754fb4c395438680454b2ec064d1d6, introduces the concept of a transaction with a lock. They used Drizzle ORM's transaction API to wrap the entire operation.
The Patched Code:
await db.transaction(async (tx) => {
// 1. The LOCK
const existingSubdomains = await tx
.select({ id: subdomains.id })
.from(subdomains)
.where(eq(subdomains.organizationId, organization.id))
.for("update"); // <--- THE FIX
// Now, any other request trying to read these rows
// MUST WAIT until this transaction finishes.
if (existingSubdomains.length >= limit) {
return error("Limit reached");
}
await tx.insert(subdomains).values({...});
});The magic words here are .for("update"). This translates to a SQL SELECT ... FOR UPDATE query. It tells the database: "I am reading these rows because I intend to change something related to them. Don't let anyone else read or write to them until I'm done."
Exploiting this requires speed. You can't just click the "Create" button really fast. You need automation. The goal is to get multiple HTTP requests into the application's processing pipeline inside the "Race Window."
The Setup:
POST /api/org/subdomains request in Burp Suite.The Attack:
We can use a tool like Turbo Intruder (a Burp extension) or a simple Python script using aiohttp or concurrent.futures. We want to open the connections but hold back the final byte of the request, then release them all at once (a technique often called "gate crashing").
import requests
from concurrent.futures import ThreadPoolExecutor
def register_subdomain(i):
payload = {"subdomain": f"hacker-{i}"}
headers = {"Authorization": "Bearer <JWT>"}
r = requests.post("https://outray.local/api/...", json=payload, headers=headers)
print(f"Request {i}: {r.status_code}")
# Launch 20 threads simultaneously
with ThreadPoolExecutor(max_workers=20) as executor:
executor.map(register_subdomain, range(20))The Result:
Instead of getting one 200 OK and nineteen 400 Bad Request responses, you will likely see ten or twelve 200 OK responses. You have now successfully provisioned resources you aren't paying for. Rinse and repeat to potentially exhaust the server's storage or tunnel capacity.
On the surface, getting a few extra subdomains sounds harmless. "Oh no, the hacker got 5 free URLs instead of 1!" But this bug scales linearly with the attacker's aggression.
Resource Exhaustion (DoS): If an attacker scripts this to register 10,000 subdomains, they bloat the database and potentially the routing table of the tunnel server. This degrades performance for legitimate users.
Financial Impact: For a SaaS business, limits are the product. If users can bypass the limits, the business model collapses. Why pay for the Enterprise plan (50 tunnels) if I can just race-condition the Free plan into giving me 100 tunnels?
Integrity:
The application state is now invalid. The database contains records that violate the application's own invariants. This can lead to unpredictable behavior in other parts of the system that assume a user can never have more than N items.
The fix provided by the Outray team in version 0.1.5 is solid, though it relies heavily on the database's ability to handle locking efficiently. By wrapping the check and the insertion in a transaction with explicit locking, they serialized the operations.
> [!NOTE] > Transaction Locking is Expensive: While correct, row-level locking reduces concurrency. If a user tries to create subdomains from 10 different tabs, they will now happen one by one. This is a necessary trade-off for correctness.
Residual Risk:
There is a subtle edge case mentioned in the patch analysis. If SELECT ... FOR UPDATE returns zero rows (because the user has 0 subdomains), there is technically nothing to lock in that specific table query in some SQL dialects, depending on the isolation level. However, usually, developers lock the parent row (the User or Subscription row) to act as a mutex. In the provided patch, they lock the subdomains rows. If the table is empty for that user, the lock might be effectively a no-op unless the isolation level is Serializable. A more robust fix often involves locking the Organization row first.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
outray outray-tunnel | < 0.1.5 | 0.1.5 |
| Attribute | Detail |
|---|---|
| Vulnerability Type | Race Condition (TOCTOU) |
| CWE ID | CWE-366 |
| CVSS v3.1 | 5.9 (Medium) |
| Attack Vector | Network |
| Privileges | Low (Authenticated User) |
| Exploit Status | PoC Available / High Reliability |
Race Condition within a Thread