Critical UAC Pipe Hijacking: When rand() Opens the Door to SYSTEM
Introduction
There's a deceptively simple class of vulnerability that has haunted Windows security for decades: named pipe squatting. It looks harmless at first glance — just a string, just a random number — but when that string becomes the key to a privileged communication channel, "good enough" randomness becomes a critical security failure.
This post breaks down a recently patched critical severity vulnerability in the UAC (User Account Control) component of Tabby, a popular open-source terminal application. The vulnerability allowed a local attacker to predict the name of a Windows named pipe used for elevated inter-process communication, hijack that pipe before the privileged process could claim it, and inject arbitrary commands that would execute with Administrator or SYSTEM privileges.
If you write any code that:
- Generates "random" identifiers for security-sensitive resources
- Uses Windows named pipes for IPC (Inter-Process Communication)
- Bridges privilege boundaries in your application
...this post is required reading.
The Vulnerability Explained
Background: What Is a Named Pipe?
Windows named pipes are a form of inter-process communication (IPC). They work like a two-way street between processes — one process creates the pipe (the server), and another connects to it (the client). Named pipes live in the Windows object namespace under \\.\pipe\<name>.
In Tabby's UAC flow, the application needs to spin up an elevated subprocess (e.g., for terminal operations requiring admin rights) and communicate with it. The mechanism chosen was a named pipe with a dynamically generated name in the format:
\\.\pipe\uac-pty-<N>
Where <N> is a number generated by rand().
The Fatal Flaw: rand() Is Not Random Enough
Here's the core of the problem, in simplified pseudocode resembling the vulnerable code in UAC.cpp:
// VULNERABLE CODE (simplified)
#include <cstdlib>
#include <ctime>
std::string GeneratePipeName() {
srand(time(NULL)); // Seeded with current time
int pipeId = rand();
return "\\\\.\\pipe\\uac-pty-" + std::to_string(pipeId);
}
This looks reasonable at a glance. But it has two fatal problems:
-
rand()is a pseudo-random number generator (PRNG), not a cryptographically secure RNG (CSPRNG). Its output is deterministic and predictable given the seed. -
time(NULL)returns the current Unix timestamp in seconds. An attacker on the same machine knows roughly what time it is. With only ~1 second of resolution, the attacker needs to try at most a few thousand values to enumerate every pipe name that could have been generated in a plausible time window.
How the Attack Works: Pipe Squatting
Windows named pipes have a critical security property: the first process to create a pipe with a given name wins. If an attacker creates \\.\pipe\uac-pty-12345 before the legitimate elevated process does, the attacker is now the server.
Here's the attack flow, step by step:
Timeline:
─────────────────────────────────────────────────────────────────
T=0 User triggers a UAC elevation in Tabby
T=1 Tabby's unprivileged process calls GeneratePipeName()
→ Produces "uac-pty-1693847200" (based on time seed)
T=2 ⚠️ ATTACKER pre-creates \\.\pipe\uac-pty-1693847200
(attacker predicted the name by brute-forcing time seeds)
T=3 Tabby's ELEVATED (Admin) process tries to create the pipe
→ Fails silently or connects to the attacker's pipe instead
T=4 Elevated process sends privileged commands down the pipe
→ Attacker receives and responds with malicious data
T=5 Attacker injects commands → executed as ADMINISTRATOR 🔥
─────────────────────────────────────────────────────────────────
Real-World Impact
This is a local privilege escalation (LPE) vulnerability. An attacker needs:
- A local account on the machine (even a low-privilege guest or standard user)
- The ability to run a process that creates named pipes (standard on Windows)
- Timing awareness (trivially achievable)
The payoff? Full Administrator or SYSTEM code execution. On a shared workstation, a corporate endpoint, or a developer machine, this is game over. Sensitive files, credentials, registry keys, other users' sessions — all exposed.
CVE Equivalents to Reference:
- This attack pattern is well-documented under CWE-330: Use of Insufficiently Random Values
- The pipe squatting technique relates to CWE-377: Insecure Temporary File (same race-condition primitive)
- Privilege escalation via IPC abuse: MITRE ATT&CK T1559.001
The Fix
What Changed
The PR titled "fix: remove unsafe exec() in UAC.cpp" addresses the vulnerability at its root by:
- Removing the
rand()-based pipe name generation - Replacing it with a cryptographically secure random identifier
- Eliminating the unsafe
exec()call pattern that compounded the risk
The Secure Approach
The fix replaces the predictable PRNG-based naming with a cryptographically secure random number generator. On Windows, the correct API is BCryptGenRandom (or its predecessor CryptGenRandom):
// SECURE CODE - After the fix
#include <bcrypt.h>
#include <string>
#include <iomanip>
#include <sstream>
std::string GenerateSecurePipeName() {
unsigned char randomBytes[16]; // 128 bits of entropy
NTSTATUS status = BCryptGenRandom(
NULL,
randomBytes,
sizeof(randomBytes),
BCRYPT_USE_SYSTEM_PREFERRED_RNG
);
if (!BCRYPT_SUCCESS(status)) {
throw std::runtime_error("Failed to generate secure random bytes");
}
// Convert to hex string
std::ostringstream oss;
oss << "\\\\.\\pipe\\uac-pty-";
for (int i = 0; i < sizeof(randomBytes); i++) {
oss << std::hex << std::setw(2) << std::setfill('0')
<< (int)randomBytes[i];
}
return oss.str();
// Result: \\.\pipe\uac-pty-a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5
}
Why This Works
| Property | rand() + time() |
BCryptGenRandom |
|---|---|---|
| Entropy source | System clock (seconds) | OS entropy pool (hardware RNG, interrupts, etc.) |
| Predictability | ~32,768 possible values per second | 2^128 possible values |
| Brute-force feasibility | Minutes on local hardware | Computationally infeasible |
| Security classification | PRNG | CSPRNG |
With 128 bits of cryptographic randomness, an attacker would need to try 340 undecillion (3.4 × 10³⁸) combinations to guess the pipe name. The heat death of the universe comes first.
The exec() Problem
The PR also removes an unsafe exec() call. In the context of UAC elevation, calling exec() (or its Windows equivalent ShellExecute/CreateProcess with unsanitized input) to launch the elevated process opens the door to command injection. If any part of the pipe name or process arguments is attacker-influenced, it could be used to inject additional commands.
The fix ensures the elevated process is launched with fully sanitized, hardcoded arguments — no user-controlled strings in the execution path.
Prevention & Best Practices
1. Never Use rand() for Security-Sensitive Identifiers
This is the cardinal rule. rand() is designed for statistical simulations, games, and non-security use cases. For anything that needs to be unpredictable to an adversary, use a CSPRNG:
| Platform | Secure API |
|---|---|
| Windows (C/C++) | BCryptGenRandom, CryptGenRandom |
| Linux (C/C++) | getrandom(), /dev/urandom |
| C++17 | std::random_device (implementation-defined, verify it's truly random) |
| OpenSSL | RAND_bytes() |
| Node.js | crypto.randomBytes() |
| Python | secrets.token_hex() |
// ❌ NEVER do this for security tokens
int id = rand();
// ✅ DO this instead
unsigned char id[16];
BCryptGenRandom(NULL, id, 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
2. Apply Strict ACLs to Named Pipes
Even with a secure name, always set explicit security descriptors on named pipes to restrict which users can connect:
// Set pipe security: only allow the creating user to connect
SECURITY_ATTRIBUTES sa;
SECURITY_DESCRIPTOR sd;
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
// Set DACL to restrict access to current user only
SetSecurityDescriptorDacl(&sd, TRUE, pACL, FALSE);
sa.lpSecurityDescriptor = &sd;
HANDLE hPipe = CreateNamedPipe(
pipeName.c_str(),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
1, // Max instances
4096, 4096, 0,
&sa // Apply security descriptor
);
3. Verify Pipe Server Identity
When connecting to a named pipe as a client, verify the server's identity before sending sensitive data. Use GetNamedPipeServerProcessId() and validate the server process:
HANDLE hPipe = CreateFile(pipeName, GENERIC_READWRITE, ...);
if (hPipe != INVALID_HANDLE_VALUE) {
ULONG serverPid;
GetNamedPipeServerProcessId(hPipe, &serverPid);
// Verify serverPid matches the expected elevated process
if (!IsExpectedElevatedProcess(serverPid)) {
CloseHandle(hPipe);
throw std::runtime_error("Pipe server identity verification failed");
}
}
4. Treat All IPC Boundaries as Trust Boundaries
Any time data crosses a privilege boundary (user → admin, process → kernel, client → server), treat it with the same scrutiny as data from an untrusted network source:
- Validate all inputs before acting on them
- Authenticate both sides of the channel
- Use the principle of least privilege — don't elevate more than necessary
- Minimize the attack surface — keep the privileged process as simple as possible
5. Use Static Analysis Tools
Tools that can catch PRNG misuse and IPC security issues:
- CodeQL — Has queries for insecure randomness (
cpp/weak-cryptographic-algorithm) - Semgrep — Customizable rules for
rand()in security contexts - PVS-Studio — Detects weak PRNG usage (V1010, V697)
- Coverity — PRNG and race condition detection
- Microsoft's BinSkim — Binary-level security checks for Windows executables
6. Reference Security Standards
- OWASP: Cryptographic Failures (A02:2021)
- CWE-330: Use of Insufficiently Random Values
- CWE-338: Use of Cryptographically Weak PRNG
- NIST SP 800-90A: Recommendation for Random Number Generation
- Microsoft SDL: Cryptographic Practices
Conclusion
This vulnerability is a perfect illustration of how small, seemingly innocuous decisions compound into critical security failures. Using rand() instead of a CSPRNG seems like a minor implementation detail — until that "random" number becomes the key to a privileged communication channel, and a local attacker can predict it in milliseconds.
Key Takeaways
🔑
rand()is never acceptable for security-sensitive identifiers. Full stop.🔑 Named pipes are security boundaries. Treat their names, ACLs, and connection validation with the same rigor as network authentication.
🔑 Privilege escalation paths deserve extra scrutiny. Any code that touches UAC, sudo, or privilege elevation should be reviewed by a security engineer.
🔑 Race conditions in resource creation are a real attack vector. "First one wins" semantics mean that predictable names are exploitable names.
🔑 Automated security scanning catches these issues early. This fix was identified by automated AI-assisted security scanning — integrate security tooling into your CI/CD pipeline before vulnerabilities reach production.
The good news: the fix is straightforward, the patch is clean, and the lesson is transferable to dozens of similar patterns across codebases everywhere. Security vulnerabilities like this one are most dangerous when they stay hidden — sharing them openly is how we make the whole ecosystem safer.
This vulnerability was identified and fixed by the OrbisAI Security automated security scanning platform. Automated security review was performed as part of the project's continuous security monitoring pipeline.
Have a similar vulnerability in your codebase? Consider integrating automated security scanning into your CI/CD pipeline.