Skip to content

TOCTOU Race Condition in Filesystem Write Path Validation #455

Description

@janfeddersen-wq

Security Report: TOCTOU Race Condition in Filesystem Write Path Validation

Component: crates/monty/src/fs/path_security.rs, crates/monty/src/fs/common.rs
Category: Filesystem Security Boundary — Time-of-Check-Time-of-Use (TOCTOU)


Summary

As requested after mail disclosure, here the public issue.

Monty's filesystem security boundary contains a Time-of-Check-Time-of-Use (TOCTOU) gap between path validation and the actual filesystem operation in write paths (write_text, write_bytes, mkdir, rename). When multiple independent processes or sessions share the same host mount directory backing a Monty sandbox, a concurrent process outside the sandbox can create a symbolic link at a validated path after validation completes but before the write executes. This causes the write to follow the symlink and write to an arbitrary location on the host filesystem.

Important: This vulnerability is not exploitable by pure Monty sandbox code alone, because Monty does not expose any API for creating symbolic links (os.symlink, Path.symlink_to, etc.). Exploitation requires a concurrent external actor with write access to the shared mount directory.

Example

This would allow to access resources that are symlinked at the right point in time to not be checked against the "deny list" if the user has a way to dynamicly generate symlinks.


Affected Code

path_security.rsresolve_creation() (lines 205–227)

fn resolve_creation(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
    if request.candidate_host.exists() {
        return resolve_existing(request, mount_host_path);
    }

    let parent = request.candidate_host.parent().ok_or_else(|| ...)?;
    let file_name = request.final_component()?;

    // Phase 1: Validate parent directory boundary
    let canonical_parent = fs::canonicalize(parent)
        .map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
    check_boundary(&canonical_parent, mount_host_path, &request.normalized_virtual)?;

    // Phase 2: Validate that target is not a symlink
    let resolved_path = canonical_parent.join(file_name);
    validate_creation_symlink_target(
        &resolved_path,
        &canonical_parent,
        mount_host_path,
        &request.normalized_virtual,
    )?;

    // Phase 3: Return validated path for use
    Ok(resolved_path)  // <-- validated, but not atomically locked
}

common.rswrite_text_fs() / write_bytes_fs() (lines 52–68)

pub(super) fn write_text_fs(path: &Path, content: &str, vpath: &str) -> Result<MontyObject, MountError> {
    reject_directory(path, vpath)?;
    fs::write(path, content).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::Int(i64::try_from(content.chars().count()).unwrap_or(i64::MAX)))
}

The fs::write() call in step 3 follows symlinks. If a symlink appears at path between validate_creation_symlink_target() (step 2) and fs::write() (step 3), the write escapes the mount boundary.


Attack Prerequisites

This vulnerability has strict prerequisites that limit its exploitability:

  1. Multiple sessions or processes must share the same host mount directory.
    • Example: 150 Monty sessions all backed by /tmp/shared-mount/mnt/data.
  2. An external actor (not sandboxed Monty code) must create a symlink at the target host path.
    • Monty does not expose os.symlink(), Path.symlink_to(), or any symlink creation API.
    • The symlink must be created by a separate process or by host-level access.
  3. The symlink creation must win the race between validation and write.
    • The attacker must predict the write target path and time the symlink creation.

Without all three prerequisites, this vulnerability is not exploitable.


Attack Scenario (Multi-Session Shared Mount)

Setup:

  • Host directory /tmp/shared-mount is mounted at /mnt/data for 150 concurrent Monty sessions.
  • Session A runs attacker-controlled Python code.
  • A separate host process (or another session with host-level access) can write to /tmp/shared-mount.

Steps:

  1. Attacker identifies a predictable write path used by another session: /mnt/data/results.json.
  2. Attacker's host process runs a high-frequency loop:
    while true; do
      ln -sf /etc/passwd /tmp/shared-mount/results.json 2>/dev/null
      rm -f /tmp/shared-mount/results.json 2>/dev/null
    done
  3. Another session calls Path('/mnt/data/results.json').write_text('malicious').
  4. Monty core calls resolve_creation():
    • Validates /tmp/shared-mount is the mount root.
    • Checks /tmp/shared-mount/results.json does not exist.
    • Returns ResolvedPath { host_path: "/tmp/shared-mount/results.json" }.
  5. Race window: Attacker's host process creates results.json -> /etc/passwd.
  6. Monty core calls fs::write("/tmp/shared-mount/results.json", "malicious").
  7. The write follows the symlink and overwrites /etc/passwd.

Why Existing Symlink Defenses Are Insufficient

Monty already defends against pre-existing symlink escapes:

  • resolve_existing (for reads): Uses fs::canonicalize(), which resolves symlinks and then checks check_boundary() on the resolved target.
  • validate_creation_symlink_target (for writes): Checks if the target path is already a symlink and validates its destination stays within the mount.
  • reject_escaping_symlink: Used by overlay rename to prevent capturing outbound symlinks.

These defenses are static checks at a single point in time. They do not protect against a symlink that is created after the check but before the filesystem operation.


Proposed Fixes

Option A: Atomic Open with O_NOFOLLOW (Recommended)

Open the file with O_NOFOLLOW (Unix) or FILE_FLAG_OPEN_REPARSE_POINT (Windows) so that if a symlink exists at the validated path, the open fails instead of following it. This is the standard defense against symlink races.

Unix:

use std::os::unix::fs::OpenOptionsExt;

let mut file = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .truncate(true)
    .custom_flags(libc::O_NOFOLLOW)
    .open(path)?;

Windows:
Use FILE_FLAG_OPEN_REPARSE_POINT in CreateFileW to prevent following reparse points (symlinks, junctions).

Option B: Re-Validate Immediately Before Write

Add a second validate_creation_symlink_target check immediately before fs::write(). This narrows the race window from milliseconds to microseconds but does not eliminate it.

Option C: Use fs::rename() from a Temporary File

Write to a temporary file in the same directory, then fs::rename() it to the final path. rename() replaces the directory entry atomically without following symlinks. This changes semantics (e.g., write_text truncates differently) but is race-safe.

Option D: Document Defense-in-Depth

Recommend that production deployments use unique mount directories per session rather than sharing a single directory across sessions. This does not fix the race but removes the concurrent access prerequisite.


Recommendations

  1. Immediate: Implement Option A (O_NOFOLLOW / FILE_FLAG_OPEN_REPARSE_POINT) for all write operations in crates/monty/src/fs/common.rs.
  2. Short-term: Add concurrent filesystem security tests that simulate multi-session races.
  3. Documentation: Update AGENTS.md to recommend unique mount directories per session as a defense-in-depth measure.
  4. CI: Add a regression test that validates write_text/write_bytes fail when the target is a symlink created after validation.

References

--
Date: 2026-05-19

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions