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 Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 18, 2026•Reviewed June 3, 2026

Answer Summary

A TOCTOU (Time-of-Check to Time-of-Use) race condition vulnerability in Rust's lock file creation allowed attackers to exploit a timing window between checking if a file exists and creating it, using symlinks to redirect writes to arbitrary paths. The fix applies the `O_NOFOLLOW` flag when opening files on Unix systems, instructing the OS to refuse following symlinks and fail loudly rather than silently writing to attacker-controlled destinations.

Vulnerability at a Glance

cweCWE-367 (Time-of-check Time-of-use (TOCTOU) Race Condition)
fixApply O_NOFOLLOW flag to refuse symlink following and fail safely on Unix systems
riskAttackers can redirect file writes to arbitrary locations via symlink exploitation during the race condition window
languageRust
root causeFile existence check and creation are not atomic; attacker can create symlink between check and write
vulnerabilityTOCTOU (Time-of-Check to Time-of-Use) Symlink Attack

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.


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_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.

Prevention and further reading

Related Articles

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

HTTP Client `danger_accept_invalid_certs` Permitted MITM Credential

The HTTP client's `validate_certs` parameter allowed disabling TLS certificate validation through `danger_accept_invalid_certs(true)`, exposing Basic Auth credentials to interception. The fix replaces this dangerous capability with a hard error, forcing developers to use proper certificate management instead.

critical

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and

medium

How Uninitialized Memory Vulnerabilities Happen in Rust and How to Fix Them

The fuser crate (versions prior to 0.16.0) contained a critical vulnerability that allowed uninitialized memory to be read and leaked through FUSE operations. This security issue was fixed by upgrading fuser from 0.15.1 to 0.16.0, which tightens memory handling and prevents potential information disclosure in applications that interact with the filesystem via FUSE.

high

How Remote Memory Exhaustion Happens in Rust QUIC Implementations and How to Fix It

A high-severity vulnerability in `quinn-proto` allowed remote attackers to exhaust server memory by sending carefully crafted out-of-order QUIC stream data, triggering unbounded buffer growth during reassembly. The fix upgrades `rustls-webpki` from `0.103.10` to `0.103.13` in `Cargo.lock`, closing a related denial-of-service primitive that could be chained with the stream reassembly weakness. Together, these changes harden the QUIC stack against memory exhaustion attacks that require no authenti

high

How cryptographic binding vulnerabilities happen in Rust OpenSSL and how to fix it

CVE-2026-41676 is a high-severity vulnerability in the rust-openssl crate that could allow attackers to exploit cryptographic operations. The fix involves upgrading from version 0.10.63 to 0.10.81, removing unsafe dependency chains, and ensuring proper OpenSSL binding integrity. This vulnerability demonstrates why keeping cryptographic libraries current is critical for production Rust applications.