Feb 20, 2026·7 min read·1 visit
Outray versions prior to 0.1.5 contain a race condition in the tunnel registration endpoint. By spamming requests simultaneously, an attacker can bypass the 'max tunnels' check and provision more resources than their plan allows. Fixed by implementing database transactions with row-level locking.
A deep dive into a classic Time-of-Check Time-of-Use (TOCTOU) race condition in Outray, an open-source ngrok alternative. This vulnerability allows attackers to bypass subscription quotas and register an unlimited number of tunnels by exploiting non-atomic validation logic.
In the world of SaaS, limits are everything. You pay for 5 users, you get 5 users. You pay for 3 tunnels, you get 3 tunnels. It’s the digital bouncer keeping the riffraff (and the non-paying customers) from eating all the buffet shrimp. Outray, a nifty open-source alternative to ngrok, tries to enforce these limits just like everyone else. But as we've seen time and time again, enforcing limits in distributed systems is harder than it looks.
CVE-2026-22820 is the kind of bug that makes CFOs cry and hackers grin. It’s not a flashy remote code execution that drops a shell; it’s a logic flaw—specifically, a race condition—that turns the application's business model into Swiss cheese. The vulnerability lies in how Outray counts your active tunnels before letting you create a new one.
Imagine a vending machine that checks if you have a dollar, sees that you do, and then begins the slow mechanical process of dispensing a soda. If you’re fast enough to press the button five times between the 'check' and the 'dispense,' and the machine isn't smart enough to debit your credit immediately, you might just walk away with a six-pack for the price of one. That is exactly what's happening here, but instead of soda, we're stealing infrastructure.
The root cause here is a textbook Time-of-Check Time-of-Use (TOCTOU) vulnerability (CWE-367). In the computer science world, we often assume that code executes linearly and instantly. Line A runs, finishes, then Line B runs. But in a web server handling concurrent requests, 'linear' is a lie we tell ourselves to sleep at night.
The vulnerable logic in Outray's register.ts endpoint followed a pattern that is disastrously common in web development:
SCARD) to see how many tunnels the organization currently has online.maxTunnels allowed by the subscription plan.The problem is the gap between Step 1 and Step 3. It might only be a few milliseconds, but to a computer, a millisecond is an eternity. If an attacker sends ten registration requests simultaneously, all ten threads might reach Step 1 at the exact same moment. They all query Redis. They all see: "Current Tunnels: 0. Max Allowed: 1." They all say, "Looks good to me!" and proceed to Step 3.
The result? Ten tunnels are created, despite the limit being one. The database is now in an inconsistent state, the subscription limits are effectively garbage, and the developer is left wondering why their server costs just tripled.
Let's look at the smoking gun. While I don't have the original source file in front of me, the patch diff tells the whole story. The original code likely looked something like this pseudocode:
// VULNERABLE LOGIC
const activeTunnels = await redis.scard(`org:${orgId}:online_tunnels`);
const planLimit = await db.getPlanLimit(orgId);
if (activeTunnels >= planLimit) {
throw new Error("Quota Exceeded");
}
// The Gap of Doom exists here
await db.insert("tunnels", { ...tunnelData });This is a naive implementation. It relies on the state remaining static between the read (scard) and the write (insert). In a high-concurrency environment, state is never static.
The fix, introduced in commit 08c61495761349e7fd2965229c3faa8d7b1c1581, changes the game by moving the logic into a database transaction with aggressive locking. Here is the conceptual fix:
// PATCHED LOGIC
await db.transaction(async (tx) => {
// Lock the subscription row for UPDATE
// This forces other transactions to WAIT right here until we are done
const subscription = await tx.selectFrom('subscriptions')
.where('orgId', orgId)
.for('update') // <--- The Magic Sauce
.executeTakeFirst();
const activeCount = await tx.selectFrom('tunnels')
.where('orgId', orgId)
.executeCount();
if (activeCount >= subscription.maxTunnels) {
throw new Error("Quota Exceeded");
}
await tx.insertInto('tunnels').values({ ... }).execute();
});By using .for('update'), the database locks the specific row for the organization's subscription. If Request A acquires this lock, Request B (coming in 1ms later) hits the select statement and freezes. It cannot proceed until Request A commits or rolls back. This serializes the requests, ensuring that Request B sees the true count of tunnels only after Request A has finished its business.
Exploiting this does not require elite hacking tools or reverse engineering binary blobs. It requires curl and a lack of patience. The goal is to maximize concurrency. We want to jam as many requests as possible into the server's processing pipeline before the first one completes.
A simple bash script running background jobs is often enough to win the race:
#!/bin/bash
TARGET="https://api.outray.local/api/v1/register"
AUTH_TOKEN="ey..."
echo "Starting race..."
# Spawn 20 processes simultaneously
for i in {1..20}; do
curl -X POST $TARGET \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"region": "us-east", "proto": "http"}' \
-s & # The ampersand puts it in the background immediately
done
wait
echo "Race finished. Check your dashboard."If you run this against a vulnerable version of Outray with a 'Starter' plan (limit: 1 tunnel), you will likely see 5, 10, or even all 20 requests return a 200 OK status. When you check the dashboard, you'll see a list of active tunnels scrolling off the page, all happily forwarding traffic, all completely free of charge.
This works because the network latency and database processing time create a window—perhaps 50ms to 100ms—where the count in Redis hasn't been updated yet. In that window, you are god.
Why should we care? "Oh no, someone got free tunnels." While this isn't a data breach that spills credit card numbers, it falls under the category of Resource Exhaustion and Business Logic Flaws.
For a self-hosted instance, this might just mean annoying your sysadmin. But for a managed service provider offering Outray as a SaaS, this is a direct financial impact. Tunnels consume sockets, bandwidth, and memory. An attacker could automate account creation and tunnel spamming to perform a Denial of Service (DoS) attack against the infrastructure itself, exhausting ephemeral ports or crashing the database with connection limits.
Furthermore, this compromises the integrity of the application's billing logic. If your enforcement mechanism relies on code that can be bypassed by hitting 'Enter' really fast, you don't have an enforcement mechanism; you have a suggestion.
The fix provided by the Outray team is robust because it pushes the concurrency problem down to the layer best equipped to handle it: the database.
Databases like PostgreSQL and MySQL are built with ACID properties (Atomicity, Consistency, Isolation, Durability) in mind. By wrapping the check and the insert in a transaction and applying a FOR UPDATE lock, the application delegates the synchronization to the database engine.
To mitigation this vulnerability manually:
0.1.5 immediately.SELECT org_id, COUNT(*) as c FROM tunnels GROUP BY org_id HAVING c > [limit]. You might find some users who 'accidentally' discovered this bug before you did.CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Outray Outray | < 0.1.5 | 0.1.5 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-367 (TOCTOU Race Condition) |
| Attack Vector | Network (API) |
| CVSS v4.0 | 6.3 (Medium) |
| CVSS v3.1 | 3.7 (Low) |
| Impact | Integrity, Resource Exhaustion |
| Exploit Status | Poc Available (Trivial) |
| Patch Commit | 08c61495761349e7fd2965229c3faa8d7b1c1581 |