Infinite Tunnels, One Free Plan: Race Conditioning Outray
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 onWhen 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 UPDATEbecause 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.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:LAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
outray akinloluwami | < 2026-01-13 | 08c61495761349e7fd2965229c3faa8d7b1c1581 (Commit) |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-367 (TOCTOU Race Condition) |
| Attack Vector | Network (API) |
| CVSS | 5.4 (Moderate) |
| Impact | Resource Exhaustion / Billing Bypass |
| Exploit Status | PoC Available |
| Patch Date | 2026-01-13 |
MITRE ATT&CK Mapping
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.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.