Outray Race Condition: Bypassing Subscription Limits for Fun and Profit
Jan 14, 2026·5 min read
Executive Summary (TL;DR)
Outray failed to use atomic transactions when checking user limits. Attackers could send parallel requests to register subdomains. The server would check the limit for all requests simultaneously (seeing '0 used'), pass them all, and then insert them all. Fixed by implementing pessimistic row-level locking.
A critical race condition in the Outray project allowed users to bypass subscription plan limits. By flooding the API with concurrent requests, attackers could register unlimited subdomains and tunnels, effectively rendering the monetization model useless.
The Hook: Infinite Resources on a Budget
Outray is a nifty tool designed to expose your local localhost server to the world—a classic tunneling solution similar to ngrok. Like any SaaS trying to keep the lights on, it implements a tier-based subscription model. Free users get a taste (maybe one subdomain, one tunnel), while paying customers get the buffet.
The logic seems simple enough: when a user asks for a new subdomain, count how many they have. If they have less than their limit, give them another one. If they hit the cap, show them the paywall. It’s the cornerstone of the business model.
But here’s the thing about simple logic: computers are literal, and databases are concurrent. If you don't tell the database to hold its breath while you count, it won't. And that creates a tiny, beautiful window of opportunity where the laws of mathematics—specifically simple addition—temporarily stop applying. This vulnerability isn't about complex memory corruption; it's about being faster than the referee.
The Flaw: The Gap Between Check and Act
This vulnerability is a textbook Time-of-Check to Time-of-Use (TOCTOU) race condition. In the security world, we often call this the "Check-Then-Act" anti-pattern. The application performs a three-step dance:
- Check Plan: Look up the user's subscription to find their limit (e.g.,
max: 1). - Check Usage: Query the database to count existing subdomains (e.g.,
current: 0). - Act: If
current < max, insert the new subdomain.
The flaw lies in the silence between Step 2 and Step 3. In a modern web application, these operations are asynchronous. When an attacker sends 50 requests simultaneously, the Node.js event loop and the database work in parallel. All 50 requests might hit Step 2 at the exact same millisecond. They all ask the database: "How many subdomains does this guy have?"
Since none of the requests have reached Step 3 yet, the database truthfully answers "Zero" to every single one of them. The application logic, satisfied that 0 < 1, approves all 50 requests. Moments later, the database is flooded with 50 INSERT commands, and the user suddenly has 50x the resources they paid for.
The Code: The Smoking Gun
Let's look at the crime scene. The vulnerable code was located in apps/web/src/routes/api/$orgSlug/subdomains/index.ts. It uses Drizzle ORM, a modern TypeScript ORM, but modern tools don't prevent logical fallacies.
Here is the simplified vulnerable logic:
// 1. Get the plan limits
const [subscription] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.organizationId, organization.id));
const limit = getPlanLimits(subscription.plan).maxSubdomains;
// 2. Count existing resources (THE VULNERABLE READ)
const existingSubdomains = await db
.select()
.from(subdomains)
.where(eq(subdomains.organizationId, organization.id));
// 3. The Logic Check
if (existingSubdomains.length >= limit) {
return json({ error: "Limit reached" }, { status: 403 });
}
// 4. The Insert (Too late!)
await db.insert(subdomains).values({ ... });The issue is that existingSubdomains is a snapshot of the past by the time line 4 executes. There is no transaction, and more importantly, no lock. The database is free to read/write other rows in between these lines.
The Exploit: Smashing the Race Window
Exploiting this doesn't require advanced binary exploitation skills; it requires a decent HTTP client and good timing. The goal is to widen the race window or just shove enough traffic through it that statistically, some requests overlap.
The Attack Chain:
- Recon: Create a free account. Observe the POST request to
/api/$orgSlug/subdomains/. Note the JSON body required. - Tooling: Fire up Burp Suite Professional (or a simple Python script using
aiohttp). - Turbo Intruder: Send the captured request to Turbo Intruder (a Burp extension). This is crucial because standard Repeater tabs might not send requests perfectly in parallel. We want concurrent connections.
The Payload:
We configure the attack to send 50 requests using a parallel gate. This synchronizes the TCP stacks to release the last byte of the request simultaneously.
# Conceptual Turbo Intruder Script
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=50,
requestsPerConnection=1,
pipeline=False)
for i in range(50):
engine.queue(target.req, label=str(i))The Result: The server logs will show 50 successful 200 OK responses. A quick refresh of the dashboard will reveal a list of 50 subdomains, despite the "Free Plan" banner explicitly stating a limit of 1.
The Fix: Pessimistic Locking
The developer (akinloluwami) patched this correctly by introducing Pessimistic Locking. Instead of just reading the data, we ask the database to lock the relevant rows until we are done. If another request tries to read that row, it must wait until the first request finishes.
In the patch, they wrap the logic in a transaction and use .for("update") on the subscription row. Why the subscription row? Because the subscription row always exists. You can't lock the "count" of subdomains effectively if there are zero subdomains to lock (phantom reads are tricky). By locking the parent subscriptions record, we serialize all operations for that organization.
The Patched Code:
await db.transaction(async (tx) => {
// LOCKING HAPPENS HERE
const [subscription] = await tx
.select()
.from(subscriptions)
.where(eq(subscriptions.organizationId, organizationId))
.for("update"); // <--- This halts other parallel requests
// Now this check is safe because we hold the lock
const activeCount = await getActiveCount(tx);
if (activeCount >= limit) {
throw new Error("Limit reached");
}
await tx.insert(subdomains).values({ ... });
});Now, when 50 requests hit the server, Request #1 acquires the lock. Requests #2 through #50 are paused by the database engine. Request #1 inserts the row and commits. Request #2 wakes up, reads the new count (which is now 1), sees the limit is reached, and fails. Justice is restored.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:LAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Outray akinloluwami | < Commit 73e8a09 | Commit 73e8a09 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-367 (TOCTOU) |
| Attack Vector | Network (API) |
| CVSS v3.1 | 5.9 (Medium) |
| Impact | Business Logic Bypass |
| Prerequisites | Authenticated User (Low Priv) |
| Exploit Difficulty | Low (Scriptable) |
MITRE ATT&CK Mapping
The product checks the state of a resource before using it, but the resource's state can change between the check and the use in a way that invalidates the results of the check.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.