CVEReports
CVEReports

Automated vulnerability intelligence platform. Comprehensive reports for high-severity CVEs generated by AI.

Product

  • Home
  • Sitemap
  • RSS Feed

Company

  • About
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Made with love by Amit Schendel & Alon Barad



CVE-2026-22819
5.90.04%

The Race for Infinity: Smashing Outray's Plan Limits (CVE-2026-22819)

Amit Schendel
Amit Schendel
Senior Security Researcher

Feb 20, 2026·6 min read·6 visits

PoC Available

Executive Summary (TL;DR)

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.

The Hook: When "Limit 1" Means "Limit N"

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 Flaw: A Classic TOCTOU Tragedy

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:

  1. Lock the user's record so nobody else can touch it.
  2. Count how many subdomains the user already has.
  3. Compare that count to the plan limit.
  4. Insert the new subdomain if they are under the limit.
  5. Unlock the record.

Here is how the vulnerable code actually worked:

  1. Count the subdomains (SELECT count(*) FROM subdomains...).
  2. Compare the count.
  3. Insert the new subdomain (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.

The Code: The Smoking Gun

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."

The Exploit: Running the Race

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:

  1. Create a free account on a vulnerable instance of Outray.
  2. Note that your limit is 1 subdomain.
  3. Capture the 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.

The Impact: Why Should We Care?

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: Implementing Traffic Lights

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.

Official Patches

OutrayPatch for Subdomain Registration

Fix Analysis (2)

Technical Appendix

CVSS Score
5.9/ 10
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:H
EPSS Probability
0.04%
Top 89% most exploited

Affected Systems

Outray (Self-Hosted)Outray Tunnel Server

Affected Versions Detail

Product
Affected Versions
Fixed Version
outray
outray-tunnel
< 0.1.50.1.5
AttributeDetail
Vulnerability TypeRace Condition (TOCTOU)
CWE IDCWE-366
CVSS v3.15.9 (Medium)
Attack VectorNetwork
PrivilegesLow (Authenticated User)
Exploit StatusPoC Available / High Reliability

MITRE ATT&CK Mapping

T1059Command and Scripting Interpreter
Execution
T1499Endpoint Denial of Service
Impact
CWE-366
Race Condition within a Thread

Race Condition within a Thread

Known Exploits & Detection

GitHubAdvisory containing description of the race condition logic.

Vulnerability Timeline

Patch committed to GitHub
2026-01-13
Vulnerability Published (GHSA & CVE)
2026-01-14
NVD Analysis Complete
2026-01-20

References & Sources

  • [1]GHSA-45hj-9x76-wp9g
  • [2]NVD Entry

Attack Flow Diagram

Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.