CVE-2026-22820

Infinite Tunnels, One Free Plan: Race Conditioning Outray

Alon Barad
Alon Barad
Software Engineer

Jan 14, 2026·7 min read

Executive Summary (TL;DR)

Outray, a localhost tunneling tool, trusted its limit-checking logic a bit too much. The server checked if a user had hit their tunnel cap, then—after a brief pause—created the tunnel. By sending concurrent requests during that pause, attackers can trick the server into authorizing more tunnels than the subscription allows. The fix involves implementing database transactions with row-level locking.

A classic Time-of-Check to Time-of-Use (TOCTOU) race condition in the Outray CLI allows users to bypass subscription limits by flooding the registration endpoint. By failing to wrap database checks and insertions in an atomic transaction, the system allows free-tier users to spin up unlimited tunnels.

The Hook: Tunnels for Everyone

We all know the drill. You are building a webhook handler, or maybe showing off a localhost demo to a client, and you need a public URL. Tools like ngrok or localtunnel are the go-to standard, but outray entered the chat as a modern alternative. Like any SaaS worth its salt, Outray has a business model: you get a few tunnels for free, but if you want to run a massive botnet—err, I mean, 'enterprise microservices architecture'—you need to pay up.

The logic seems simple enough on paper. When a user runs outray http 8080, the CLI asks the server, "Hey, can I have a tunnel?" The server checks the database, counts how many tunnels the user already has, compares it to their plan limit, and either says "Sure!" or "Upgrade your plan, cheapskate."

But here is the thing about computers: they are really fast, but they process instructions sequentially unless told otherwise. If you ask the server the same question ten times simultaneously, and the server isn't using the database correctly, it might give you the same answer ten times before it realizes you have already used up your allowance. That is exactly what happened here.

The Flaw: Time-of-Check to Time-of-Use (TOCTOU)

The vulnerability here is a textbook Time-of-Check to Time-of-Use (TOCTOU) race condition, specifically CWE-367. It lives in the gap between reading data and writing data. In the world of web apps, we often assume that lines of code execute instantaneously, but network latency and database I/O create windows of opportunity that last milliseconds—an eternity in CPU time.

In the vulnerable version of Outray, the /api/tunnel/register endpoint performed a logical two-step dance. Step one: Query Redis and the SQL database to see how many tunnels the organization currently has active. Step two: If that number is below the limit, insert a new tunnel record. The fatal flaw was that these two steps were independent operations. They were not wrapped in a transaction, and more importantly, they didn't lock the rows they were reading.

Imagine you have a limit of 1 tunnel. You send Request A and Request B at the exact same millisecond. Request A asks the DB: "Count tunnels." The DB says "0." Request B asks the DB: "Count tunnels." The DB, having not yet committed Request A's insertion, still says "0." Both requests proceed to the insertion phase. Both succeed. Now you have 2 tunnels on a 1-tunnel plan. Rinse and repeat with a script, and you own the server resources.

The Code: The Smoking Gun

Let's look at the crime scene. The code below shows the naive implementation where the check and the act are separated. This pattern is incredibly common in Node.js applications using ORMs, where developers often forget that await yields control of the event loop, allowing other requests to be processed in between lines of code.

The Vulnerable Logic:

// 1. CHECK: Read the plan limits
const [subscription] = await db
  .select()
  .from(subscriptions)
  .where(eq(subscriptions.organizationId, organizationId));
 
const currentPlan = subscription?.plan || "free";
const tunnelLimit = getPlanLimits(currentPlan).maxTunnels;
 
// ... logical gap where race conditions thrive ...
 
// 2. ACT: Insert the tunnel
if (currentCount < tunnelLimit) {
  await db.insert(tunnels).values(tunnelRecord);
}

The fix, implemented in commit 08c61495761349e7fd2965229c3faa8d7b1c1581, introduces the concept of a database transaction with pessimistic locking. By using .for("update"), the database is instructed to lock the selected rows. If a second request comes in while the first is processing, the database forces the second request to wait until the first transaction commits or rolls back.

The Fix:

// Wrapped in a transaction
const result = await db.transaction(async (tx) => {
  // LOCK: Select with FOR UPDATE
  const [subscription] = await tx
    .select()
    .from(subscriptions)
    .where(eq(subscriptions.organizationId, organizationId))
    .for("update"); // <--- This stops the race
 
  // ... validation logic ...
 
  await tx.insert(tunnels).values(tunnelRecord);
});

The Exploit: Synchronizing Chaos

Exploiting this doesn't require complex binary exploitation or heap grooming. It just requires timing. We need to fire off multiple instances of the outray client so that they hit the API at the exact same moment. While you could write a Python script with asyncio or use Burp Suite's Turbo Intruder, the PoC provided by the community takes a more "sysadmin-chic" approach using tmux.

The script below creates a tmux session, splits the screen into multiple panes (one for each tunnel we want to force), and uses the synchronize-panes feature. This feature allows you to type a command once and have it execute in all panes simultaneously. It is a brilliant way to visually demonstrate the race condition.

#!/usr/bin/env bash
SESSION="outray-race"
PORTS=(8090 4000 5000 6000)
 
# Create session and split panes
tmux new-session -d -s "$SESSION" "bash"
for i in "${!PORTS[@]}"; do
  if [ "$i" -ne 0 ]; then
    tmux split-window -t "$SESSION" -h
    tmux select-layout -t "$SESSION" tiled
  fi
  # Queue the command in every pane
  tmux send-keys -t "$SESSION" "outray ${PORTS[$i]}" C-m
done
 
# The magic switch: input in one pane goes to all
tmux set-window-option -t "$SESSION" synchronize-panes on

When executed, this launches 4 instances of the client instantly. The server sees 4 incoming requests, checks the limit (say, limit=1, current=0) for all 4, and approves them all. Voila: 400% resource usage allocation.

The Impact: Why Should We Panic?

On the surface, this looks like a simple billing bypass. "Oh no, someone got a free premium feature." But the implications for a tunneling service are actually much nastier. Tunneling services are resource-intensive; they maintain persistent TCP connections and route traffic. They rely heavily on these limits to prevent infrastructure exhaustion.

If an attacker can spin up thousands of tunnels, they can easily exhaust the server's ephemeral ports or file descriptors, causing a Denial of Service (DoS) for paying customers. Furthermore, malicious actors often use these services to hide C2 (Command and Control) servers or phishing pages. Being able to generate infinite disposable URLs makes it significantly harder for abuse teams to ban the actor—they just race the registration endpoint again and get a fresh batch of domains.

The Fix: Transactional Integrity

The remediation here is the golden rule of concurrency: If you check a value and then act on it, you must ensure the value cannot change between the check and the act.

The Outray team correctly identified that Redis (often used for speed) is not the place to enforce strict consistency for billing limits unless you are using Lua scripts or Redis transactions (MULTI/EXEC) very carefully. Instead, they moved the logic into the primary SQL database and applied row-level locking.

[!NOTE] Developers often avoid SELECT FOR UPDATE because it can reduce performance by serializing requests. However, in a registration flow like this, the performance hit is negligible compared to the security risk. Accuracy beats speed when money and resources are on the line.

If you are running a self-hosted instance of Outray, pull the latest docker image or update your npm packages immediately. If you are developing similar systems, audit your INSERT logic. If it depends on a SELECT, you are likely vulnerable.

Fix Analysis (1)

Technical Appendix

CVSS Score
5.4/ 10
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Affected Systems

Outray CLI (npm package)Outray Server (self-hosted instances)

Affected Versions Detail

Product
Affected Versions
Fixed Version
outray
akinloluwami
< 2026-01-1308c61495761349e7fd2965229c3faa8d7b1c1581 (Commit)
AttributeDetail
CWE IDCWE-367 (TOCTOU Race Condition)
Attack VectorNetwork (API)
CVSS5.4 (Moderate)
ImpactResource Exhaustion / Billing Bypass
Exploit StatusPoC Available
Patch Date2026-01-13
CWE-367
Time-of-Check Time-of-Use (TOCTOU) Race Condition

The software 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.

Vulnerability Timeline

Patch Committed
2026-01-13
GHSA Published
2026-01-13
CVE Assigned
2026-01-13

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.