Back to Blog
medium SEVERITY8 min read

TOCTOU Symlink Attack Fixed: How Race Conditions Threaten Lock Files

A medium-severity TOCTOU (Time-of-Check to Time-of-Use) race condition vulnerability was discovered and fixed in a Rust application's lock file creation logic, where an attacker could exploit the window between a file existence check and its creation to redirect writes to an attacker-controlled path via a symlink. The fix applies the `O_NOFOLLOW` flag on Unix systems, ensuring the OS refuses to follow symlinks at the lock file path and fails loudly instead of silently writing to an attacker-cont

O
By orbisai0security
May 18, 2026
#rust#security#toctou#race-condition#symlink-attack#file-system-security#unix

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:

  1. Checks a condition (e.g., "does this file exist?")
  2. 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
  • errno is set to ELOOP
  • 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:

  1. O_NOFOLLOW is a POSIX/Unix concept — Windows handles symlinks differently and doesn't have this flag.
  2. libc::O_NOFOLLOW won'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_NOFOLLOW rather 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 descriptors
  • fslock — Simple, safe file locking
  • lockfile — Lock file creation with proper exclusivity

6. Run Security Linters and Audits

  • cargo audit — Checks your dependency tree for known vulnerabilities
  • cargo-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 flagO_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_NOFOLLOW is 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.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

medium

Command Injection in Python Subprocess: A Security Fix Case Study

A medium-severity command injection vulnerability was discovered and fixed in a Python testing utility where unsanitized input could be passed to subprocess calls. This fix demonstrates the critical importance of input validation and safe subprocess handling to prevent attackers from executing arbitrary system commands.

medium

Buffer Overflow in miniz.h: How a Missing Length Check Could Lead to Privilege Escalation

A medium-severity buffer overflow vulnerability was discovered and patched in the miniz.h file embedded within the KittyMemoryEx library, a memory manipulation tool used on Android and iOS platforms. The missing buffer-length check could have allowed attackers to exploit ZIP processing code to achieve arbitrary code execution with elevated privileges. This post breaks down how the vulnerability works, why it's dangerous in privileged contexts, and what developers can do to prevent similar issues

medium

Resource Exhaustion via Unchecked File Imports: How Missing Limits Create DoS Vulnerabilities

A medium-severity vulnerability in a file transfer receiver allowed attackers to exhaust server resources by sending maliciously crafted import files with no size limits, no JSON depth restrictions, and millions of entries loaded directly into memory. The fix introduces explicit input validation guards that reject unauthenticated or malformed requests before any disk or network operations begin. Understanding this class of vulnerability is essential for any developer building file ingestion pipe