TOCTOU Symlink Attack Fixed: How Race Conditions Threaten Lock Files in Rust
Introduction
At first glance, creating a lock file seems trivial — just open a file, write some data, and you're done. But hidden inside that simple operation is a subtle, dangerous assumption: that the path you checked is still the path you're writing to.
This assumption is exactly what a TOCTOU (Time-of-Check to Time-of-Use) race condition exploits. A recently patched vulnerability in src/peers/lock.rs demonstrated precisely this problem. The lock file creation logic used .create(true) when opening the file, which is functionally equivalent to POSIX O_CREAT — but critically, without O_EXCL. This left a small but exploitable window where a local attacker could slip a symlink into place and redirect the application's file writes to an attacker-controlled path.
If you write Rust (or any systems-level code) that deals with file creation, lock files, or temporary files — especially in shared or world-writable directories — this post is for you.
The Vulnerability Explained
What Is a TOCTOU Race Condition?
A TOCTOU vulnerability occurs when a program:
- Checks a condition (e.g., "does this file exist?")
- Uses a resource based on that check (e.g., "great, I'll create it")
...but between steps 1 and 2, the state of the world changes. An attacker who can observe or predict what the program is about to do can manipulate the filesystem in that tiny window to cause the program to do something unintended.
How the Vulnerable Code Worked
The original lock file creation in src/peers/lock.rs looked something like this:
// VULNERABLE: Before the fix
use std::fs::OpenOptions;
fn create_lock_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.write(true)
.create(true) // Creates if not exists — but NOT exclusively
.open(path)
}
The .create(true) flag maps to the POSIX O_CREAT flag. This tells the OS: "open the file, and create it if it doesn't exist." Sounds reasonable — but it has a critical flaw: it will happily follow symlinks.
This means if the path /var/run/myapp/app.lock is actually a symlink pointing to /etc/cron.d/backdoor, the application will dutifully open and write to /etc/cron.d/backdoor without any complaint.
The Attack Scenario
Here's how a local attacker could exploit this step by step:
1. Attacker observes (or predicts) the lock file path:
/var/run/myapp/app.lock
2. Attacker removes the lock file (if it exists) or waits for a restart.
3. Attacker creates a symlink:
$ ln -s /etc/cron.d/backdoor /var/run/myapp/app.lock
4. Application starts, calls create_lock_file("/var/run/myapp/app.lock")
5. OS follows the symlink transparently.
6. Application writes its lock data to /etc/cron.d/backdoor instead.
7. Attacker now has a cron job (or worse) installed with the application's privileges.
The attack window is small — but on a loaded system or with a slow startup sequence, it's absolutely exploitable. And in automated environments (containers, CI/CD pipelines, systemd restarts), the timing becomes even more predictable.
Real-World Impact
Depending on what the application writes to the lock file and what privileges it runs with, this vulnerability could enable:
- Arbitrary file write — overwriting sensitive system files
- Privilege escalation — if the app runs as root or a privileged service account
- Denial of service — corrupting files the application depends on
- Persistence — writing attacker-controlled content to startup scripts or cron directories
This is rated medium severity, but in the right context (e.g., a daemon running as root), it can escalate quickly to critical impact.
The Fix
What Changed
The fix adds the O_NOFOLLOW flag when opening the lock file on Unix systems. This is a well-known, battle-tested defense against symlink-based TOCTOU attacks.
// FIXED: After the patch
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
fn create_lock_file(path: &Path) -> std::io::Result<File> {
#[cfg(unix)]
{
OpenOptions::new()
.write(true)
.create(true)
.custom_flags(libc::O_NOFOLLOW) // ← The critical addition
.open(path)
}
#[cfg(not(unix))]
{
// Windows doesn't support O_NOFOLLOW; preserve original behavior
OpenOptions::new()
.write(true)
.create(true)
.open(path)
}
}
How O_NOFOLLOW Solves the Problem
The O_NOFOLLOW flag instructs the operating system: "If the final component of this path is a symlink, do not follow it — fail instead."
When an attacker places a symlink at the lock file path and the application tries to open it with O_NOFOLLOW:
- The OS detects the symlink at the final path component
- Instead of following it, the
open()syscall returns-1 errnois set toELOOP- The Rust code receives an
Err(...)and can handle it gracefully
Without O_NOFOLLOW:
open("/var/run/myapp/app.lock") → follows symlink → writes to attacker target ❌
With O_NOFOLLOW:
open("/var/run/myapp/app.lock") → detects symlink → returns ELOOP error ✅
The attack is stopped cold. The application fails safely rather than silently doing something dangerous.
Why the #[cfg(unix)] Guard?
The fix is carefully wrapped in platform-specific compilation guards:
#[cfg(unix)]
// ... use O_NOFOLLOW via libc
#[cfg(not(unix))]
// ... use original behavior
This is important for two reasons:
O_NOFOLLOWis a POSIX/Unix concept — Windows handles symlinks differently and doesn't have this flag.libc::O_NOFOLLOWwon't compile on Windows — the guard prevents build failures on non-Unix targets.
This is idiomatic, safe Rust: apply the strongest available protection on platforms that support it, while maintaining compatibility elsewhere.
Prevention & Best Practices
1. Always Use O_EXCL with O_CREAT for Exclusive Creation
If your intent is to create a file that must not already exist, use both flags together:
// Atomic exclusive creation — fails if file already exists
OpenOptions::new()
.write(true)
.create_new(true) // Rust's equivalent of O_CREAT | O_EXCL
.open(path)?;
create_new(true) in Rust maps to O_CREAT | O_EXCL, which is atomic — the OS guarantees the check and creation happen as one indivisible operation. No race window exists.
2. Use O_NOFOLLOW for Sensitive File Operations
Whenever you open files in paths that could be influenced by untrusted users (world-writable directories, user-controlled paths, temp directories), add O_NOFOLLOW:
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.write(true)
.create(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
3. Avoid Predictable Lock File Paths in Shared Directories
Lock files in /tmp or other world-writable directories are particularly dangerous. Prefer:
- Application-specific directories with restricted permissions (
chmod 700) - Runtime directories managed by systemd (
RuntimeDirectory=) or similar - Paths that unprivileged users cannot create files in
4. Validate Symlinks Before Use (Defense in Depth)
For extra caution, you can explicitly check whether a path is a symlink before opening it:
use std::fs;
fn open_safe(path: &Path) -> std::io::Result<File> {
// Explicit symlink check (defense in depth — not a replacement for O_NOFOLLOW)
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Refusing to open symlink at lock file path",
));
}
// Proceed with O_NOFOLLOW as well
OpenOptions::new()
.write(true)
.create(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
⚠️ Note: A standalone symlink check is still subject to TOCTOU by itself — always combine it with
O_NOFOLLOWrather than relying on the check alone.
5. Use Established Lock File Libraries
For production code, consider using well-audited crates that handle these edge cases for you:
fd-lock— Cross-platform file locking using file descriptorsfslock— Simple, safe file lockinglockfile— Lock file creation with proper exclusivity
6. Run Security Linters and Audits
cargo audit— Checks your dependency tree for known vulnerabilitiescargo-geiger— Identifies unsafe code blocks that may need extra scrutiny- Clippy — While it won't catch all security issues, it flags many unsafe patterns
- Manual code review — File I/O code, especially involving paths and permissions, deserves careful human review
Relevant Security Standards
| Standard | Reference |
|---|---|
| CWE-362 | Concurrent Execution Using Shared Resource with Improper Synchronization (Race Condition) |
| CWE-61 | UNIX Symbolic Link (Symlink) Following |
| CWE-377 | Insecure Temporary File |
| OWASP | File Upload and Path Traversal |
| POSIX | open(2) man page — O_NOFOLLOW, O_EXCL flags |
Conclusion
This vulnerability is a perfect illustration of how a single missing flag — O_NOFOLLOW — can be the difference between secure and exploitable code. The original code wasn't carelessly written; .create(true) is the natural, obvious way to create a file in Rust. But in security-sensitive contexts, "obvious" and "safe" aren't always the same thing.
Key Takeaways
- ✅ TOCTOU race conditions are real and exploitable, even with small time windows
- ✅
O_NOFOLLOWis your friend — use it whenever opening files in potentially attacker-influenced paths - ✅
create_new(true)in Rust (i.e.,O_CREAT | O_EXCL) provides atomic exclusive file creation - ✅ Platform guards (
#[cfg(unix)]) let you apply the strongest protection available without breaking cross-platform compatibility - ✅ Defense in depth — combine multiple mitigations rather than relying on any single one
Security vulnerabilities in file handling are among the oldest and most well-understood class of bugs — yet they continue to appear in modern codebases. The fix here is clean, minimal, and idiomatic. It's a great reminder that security improvements don't always require architectural changes; sometimes, one well-placed flag is all it takes.
Stay curious, audit your file I/O code, and keep your lock files safe. 🔒
This post is part of our ongoing series on security vulnerabilities discovered and fixed in open-source Rust projects. If you found this helpful, consider sharing it with your team or checking out our other posts on secure systems programming.